目录
03 自定义Processor:run_classifier.py
一 :今日吐槽
接触tensorflow 1.X之前,我听过一个笑话。
面试官:请手写个简单的神经网络。
面试者:import tensorflow as tf。
这个笑话让我以为 TF boy 很好当,调个包年薪百万。
当我在 tensorflow 和 pytorch 之间做选择时,我义无反顾地选择了 tensorflow ,并在简历上提前写上:熟练使用tensorflow。
理论学不懂,调包咱还不会吗?
直到我看了个 tensorflow1.X 的NER项目,看了半个月还懵逼。
不是说调包侠好当吗,难道我这智商连调包都成问题?
是我没有tensorflow的基础,强行上车吗?
也不是啊,我看了沈兄安利的神书,还做了笔记。
可见,就算手握贤者之书,在开局就六神装加身的tensorflow 1.X大项目面前,我也只能乖乖交出一血。尽管如此惨淡,我还是倔强地写上:熟练使用tensorflow。
入职几个月后,某天,和明少一起端着盒饭。
明少:原来你们也觉得 tensorflow 难啊,我还以为就我一个人这么觉得。
邓邓:tensorflow 的难度,给我的感觉是,给我再多时间,我也看不懂。所以这次 BERT 的代码是 1.X 还是 2.0 的?
1.X 的。
代码有多少行?
上千行吧。
......
二:内容预告
文本多分类是工作中最常见的任务,所以接下来,首先尝试用谷歌原生的BERT做文本多分类,再尝试做多标签分类。
BERT,全名是:Pre-training of Deep Bidirectional Transformers forLanguage Understanding。
这个是谷歌在2018年搞出来的一个预训练的语言模型,用 Transformer 做特征抽取器,在多项NLP任务上带来了5~8个点的绝对提升,爆炸程度堪比CV界的AlexNet。
2013年以后,搞NLP的人,不能不懂word2vec。
2018年以后,搞NLP的人,不能不懂BERT。
网上有很多好的博客,讲解 BERT 的原理,我就不赘述了。
说了嘛,不做嘴炮工程师!
不过实战中,我发现原生的BERT在文本分类上的效果,并没有那么惊艳。原因可能是谷歌的BERT,是在中文维基百科语料上做预训练的。模型本身,也还存在很多可以改进的地方。
百度研究出来的变体 ERNIE ,加入了实体和短语的MASK,预训练语料增加了百度百科、百度贴吧、百度新闻等,在中文的NLP任务上表现更好。
所以这次用原生的 BERT 来做文本分类任务,更多是出于学习的目的。
本文主要关注以下六方面的内容:
用BERT做文本分类的两种方法是什么?
怎么进行BERT格式的数据预处理?
怎么接下游任务做fine-tuning?
怎么修改评估函数?
怎么调参(推荐的参数)?
怎么加载预训练的中文模型?
三:BERT 小总结
BERT是站在巨人的肩膀上研究出来的一个模型,这些巨人包括Encoder-Decoder、Attention和Transformer。
GPT用了 Transformer 的Decoder结构,注意力机制为 Masked Multi-head Attention(带遮罩的多头注意力机制),也就是单向Transformer。
而BERT用了 Transformer 的Encoder结构,注意力机制为Multi-head Attention,没有遮罩,所以是双向Transformer。
BERT使用双向Transformer,取得了如此惊人的效果,证明了双向Transformer具有更强的文本特征抽取能力。
同时,BERT在大型语料上进行无监督的训练,然后用预训练的参数,对下游任务进行fine-tuning,可以消除很多繁重的NLP任务的具体网络结构。
比如在文本分类任务上,我们就不需要再搭各种网络结构:TextCNN、BiLSTM+Attention等,直接在预训练层外面加一个Dense层,再做sigmoid或softmax操作。
但是BERT不适合做文本生成任务。
推荐一些官方资料:
-
BERT 官方源码:https://github.com/google-research/bert
-
BERT 论文: https://arxiv.org/pdf/1810.04805
四:使用BERT的两种方式
用BERT做下游任务(文本分类、命名实体识别、问答),有两种方式。
01 feature-based
就是把BERT预训练模型的隐层参数(768维),拿出来作为字向量,输入到其他模型中去。比如把BERT的字向量,输入到TextCNN中,去做分类任务。
我认为,这种做法的问题,是把BERT解决一词多义问题的优势,给浪费了。
BERT做下游任务时,字/词的向量表示,会随着上下文的不同而变动,从而解决一词多义的问题。
而word2vec等词向量,向量表示是确定的,就无法解决这个问题。
所以把BERT当字向量来用,就没有这个优势了。
另外,BERT字向量的维度是768维的,是百度百科字向量(300维)的两倍以上,会大大降低模型训练和推理的速度。
在做长文本分类任务时,同样的一段文本,分字后的序列长度大概是分词后的两倍,又进一步增大了计算量。
双重打击。
所以,我觉得这种方法可能比较适合线下任务,适合对速度没那么高要求的场景。
我试了试,把原生BERT的字向量,输入到TextCNN中,也就是字向量模型,做多标签分类,速度慢得很,效果也不如TextCNN+百度百科字向量的模型。
02fine-tuning
就是在预训练模型层上添加新的网络层,然后预训练层和新网络层联合训练。
比如命名实体识别,在外面添加BiLSTM+CRF层,就成了BERT+BiLSTM+CRF模型。
这个例子可能不太典型,因为还是加了繁重的网络结构。
文本分类的例子最典型了,最后加一个Dense层,把输出维度降至类别数,再进行sigmoid或softmax。
这样,不需要很多数据,也不需要跑很多epochs,就能得到不错的效果。
五:BERT 的代码结构
01 BERT官方代码结构
首先从BERT的官方github地址上,把BERT的源码拉取下来。
https://github.com/google-research/bert
这是用tensorflow 1.X 写的代码,代码的结构如下。
├── CONTRIBUTING.md
├── create_pretraining_data.py
├── extract_features.py # 用于提取BERT字向量
├── __init__.py
├── LICENSE
├── modeling.py # 所需文件一:BERT的网络模型文件
├── modeling_test.py
├── multilingual.md
├── optimization.py # 所需要文件二:优化器文件
├── optimization_test.py
├── predicting_movie_reviews_with_bert_on_tf_hub.ipynb # 电影评论情感分类的案例
├── README.md
├── requirements.txt
├── run_classifier.py # 所需文件三:模型的fine-tuning文件
├── run_classifier_with_tfhub.py
├── run_pretraining.py # 做预训练的文件
├── run_squad.py
├── sample_text.txt
├── tokenization.py # 所需文件四:用于文本预处理(分字)的文件
└── tokenization_test.py
文件比较多,在做多分类和多标签分类时,只需要用到以下四个文件。
├── modeling.py # 所需文件一:BERT的网络模型文件
├── optimization.py # 所需要文件二:优化器文件
├── run_classifier.py # 所需文件三:模型的fine-tuning文件
├── tokenization.py # 所需文件四:用于文本预处理(分字)的文件
不需要修改的文件:
tokenization.py,optimization.py 和 modeling.py。
分别用于文本预处理(分字)、定义优化器和搭建 BERT 模型。
需要修改的文件:run_classifier.py。
用于导入数据,按模型的输入格式处理数据,在预训练层加下游任务,修改评估函数和输出结果等。
02下载预训练的中文BERT模型
还是在 BERT 的官方github上,找到预训练的中文BERT模型,下载到本地。
这是一个BERT-BASE 模型,12层 Transformer Encoder,12个自注意力头,768维字向量,1.1亿参数。
打开后有如下内容:
.
└── chinese_L-12_H-768_A-12
├── bert_config.json # BERT 的配置文件
├── bert_model.ckpt.data-00000-of-00001 # 预训练的模型
├── bert_model.ckpt.index
├── bert_model.ckpt.meta
└── vocab.txt # BERT字粒度的词表
查看BERT 的配置文件,可以看到:
-
激活函数为gelu;
-
字向量的维度(hidden_size)为768;
-
自注意力头(attention_heads)个数为12;
-
使用了12层 Transformer 的encoder(hidden_layers);
-
字表的大小为21128。
{
"attention_probs_dropout_prob": 0.1,
"directionality": "bidi",
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 768,
"initializer_range": 0.02,
"intermediate_size": 3072,
"max_position_embeddings": 512,
"num_attention_heads": 12,
"num_hidden_layers": 12,
"pooler_fc_size": 768,
"pooler_num_attention_heads": 12,
"pooler_num_fc_layers": 3,
"pooler_size_per_head": 128,
"pooler_type": "first_token_transform",
"type_vocab_size": 2,
"vocab_size": 21128
}
03 tokenization的具体作用
tokenization.py 是用于对文本进行预处理的模块,主要用有以下几个函数:
1:convert_to_unicode :用于将文本转化为unicode编码格式。
2:WordpieceTokenizer:这个用于英文单词的处理,以便做 WordPiece Embedding。
这类似于DSSM中的 Word Hashing 和 letter-tri-grams 的技巧,比如英文单词为:"unaffable",WordPiece之后为 ["un", "##aff", "##able"]。
这一方面可以缓解未登录词问题,另一方面也防止词表维度太大。
3:BasicTokenizer:用于中文文本的单字拆分。同样,有以上两个优点,而且不用去停用词。
比如中文文本为:"我不想加班",处理之后为["我", "不", "想", "加", "班"]。
4:FullTokenizer:结合了 WordpieceTokenizer 和 BasicTokenizer,对文本进行两种解析,同时可以进行 token 到 id,以及 id 到 token 的转换。
六:BERT 的数据预处理
01 数据介绍和tensorflow的版本
这次用BERT做历史试题的多分类,把历史题目分类为:古代史,近代史和现代史。
样本和标签的情况如下:
his_df[["item","label"]].head()
item label
0 [题目]\n据《左传》记载,春秋后期鲁国大夫 ... 古代史
1 [题目]\n秦始皇统一六国后创制了一套御玺。... 古代史
2 [题目]\n北宋加强中央集权的主要措施有( ... 古代史
3 [题目]\n商朝人崇信各种鬼神,把占卜、祭祀... 古代史
4 [题目]\n公元963年,北宋政府在江淮地区设 ... 古代史
我使用的tensorflow 版本:tensorflow 1.13.1。
项目代码的结构如下。
我的github地址:https://github.com/DengYangyong/exam_annotation
├── bert # 需要用到的 BERT 文件
│ ├── __init__.py
│ ├── modeling.py
│ ├── optimization.py
│ ├── run_classifier.py
│ └── tokenization.py
├── config.py # 参数配置
├── data_processor.py # 对样本做拆字
├── metrics.py # 自定义 f1,precision和recall
├── pretrained_model # 预训练的中文BERT模型
│ └── chinese_L-12_H-768_A-12
│ ├── bert_config.json
│ ├── bert_model.ckpt.data-00000-of-00001
│ ├── bert_model.ckpt.index
│ ├── bert_model.ckpt.meta
│ └── vocab.txt
├── run_classifier.py # 定义下游任务,并做训练、验证和预测的脚本。
├── run.sh # 执行的 shell 脚本
└── run_test.py # 模型测试脚本
02 文本拆字:data_processor.py
先写个配置参数和数据路径的模块:config.py。
输入的最大长度为400,学习率为2e-5,跑6个epoch。
#coding:utf-8
import os,pathlib
root = pathlib.Path(os.path.abspath(__file__)).parent.parent
class Config(object):
def __init__(self):
self.his_origin_dir = os.path.join(root,"data","百度题库/高中_历史/origin")
self.his_proc_dir = os.path.join(root,"data","bert_multi_cls_results","高中_历史","proc")
self.output_dir = os.path.join(root,"data","bert_multi_cls_results")
self.vocab_file = os.path.join("pretrained_model","chinese_L-12_H-768_A-12","vocab.txt")
self.max_len = 400
self.output_dim = 3
self.learning_rate = 2e-5
self.num_epochs = 6
接着写个 data_processor.py 的模块,用于文本拆字,划分数据集,以及保存。
下面的代码读取数据后,给样本贴上标签,划分为训练集、验证集和测试集。
同时创建了三个文件夹,用于保存处理后的数据。
这几步建议不要在 run_classifier.py 里做,单独写个模块比较好,否则会显得代码臃肿。
#coding:utf-8
import sys
sys.path.append("bert")
import pandas as pd
import os
from bert import tokenization
from sklearn.model_selection import train_test_split
from config import Config
config = Config()
""" 一: 读取数据、贴标签和划分数据集 """
def load_dataset():
""" 1: 读取数据 """
print("\n读取数据 ... \n")
ancient_his_df = pd.read_csv(os.path.join(config.his_origin_dir,'古代史.csv'))
contemp_his_df = pd.read_csv(os.path.join(config.his_origin_dir,'现代史.csv'))
modern_his_df = pd.read_csv(os.path.join(config.his_origin_dir,'近代史.csv'))
""" 2: 贴标签 """
print("\n贴标签 ... \n")
ancient_his_df['label'] = '古代史'
contemp_his_df['label'] = '现代史'
modern_his_df['label'] = '近代史'
""" 3: 划分数据集并保存 """
print("\n划分数据集并保存 ... \n")
his_df = pd.concat([ancient_his_df,contemp_his_df,modern_his_df],axis=0,sort=True)
print(f"\nThe shape of the dataset : {his_df.shape}\n")
df_train, df_test = train_test_split(his_df[:], test_size=0.2, shuffle=True)
df_valid, df_test = train_test_split(df_test[:], test_size=0.5, shuffle=True)
return df_train, df_valid, df_test
""" 二:创建保存训练集、验证集和测试集的目录 """
def create_dir():
print("\n创建保存训练集、验证集和测试集的目录 ... \n")
proc_dir = config.his_proc_dir
if not os.path.exists(output_dir):
os.makedirs(os.path.join(output_dir, "train"))
os.makedirs(os.path.join(output_dir, "valid"))
os.makedirs(os.path.join(output_dir, "test"))
return proc_dir
最后,对中文文本拆字,对英文文本做WordPiece,保存。
""" 三:按字粒度切分样本 """
def prepare_dataset():
""" 1: 读取数据、贴标签和划分数据集"""
df_train, df_valid, df_test = load_dataset()
""" 2: 创建保存训练集、验证集和测试集的目录"""
proc_dir = create_dir()
""" 3: 初始化 bert_token 工具"""
bert_tokenizer = tokenization.FullTokenizer(vocab_file=config.vocab_file, do_lower_case=True)
""" 4: 按字进行切分"""
print("\n按字进行切分 ... \n")
type_list = ["train", "valid", "test"]
for set_type, df_data in zip(type_list, [df_train, df_valid, df_test]):
print(f'datasize: {len(df_data)}')
""" 打开文件 """
text_f = open(os.path.join(proc_dir, set_type,"text.txt"), "w",encoding='utf-8')
token_in_f = open(os.path.join(proc_dir, set_type, "token_in.txt"),"w",encoding='utf-8')
label_f = open(os.path.join(proc_dir, set_type,"label.txt"), "w",encoding='utf-8')
""" 按字进行切分 """
text = '\n'.join(df_data.item)
text_tokened = df_data.item.apply(bert_tokenizer.tokenize)
text_tokened = '\n'.join([' '.join(row) for row in text_tokened])
label = '\n'.join(df_data.label)
""" 写入文件 """
text_f.write(text)
token_in_f.write(text_tokened)
label_f.write(label)
text_f.close()
token_in_f.close()
label_f.close()
if __name__ == "__main__":
prepare_dataset()
处理完毕后,token_in.text 的内容如下:
[ 题 目 ] 20 世 纪 30 年 代 经 济 危 机 爆 发 后 , 各 主 要 资 本 主 义 国 家 应 对 危 机 措 施 的 共 同 点 有 ( ) ① 实 行 [UNK] 自 由 放 任 [UNK] 的 经 济 政 策 ② 加 紧 对 经 济 的 干 预 ③ 加 紧 争 夺 世 界 市 场 ④ 加 紧 对 殖 民 地 和 半 殖 民 地 的 掠 夺 ( ) a . ① ##② ##③ ##b . ② ##③ ##④ ##c . ① ##② ##④ ##d . ① ##③ ##④ ...
label.txt 的内容如下:
现代史
现代史
近代史
近代史
现代史
03 自定义Processor:run_classifier.py
run_classifier.py模型中,包含的类和函数如下。
class InputExample(object):
class PaddingInputExample(object):
class InputFeatures(object):
class DataProcessor(object):
""" 1: 创建自己的DataProcessor """
class HisProcessor(DataProcessor):
""" 2: 标签进行one-hot编码,多分类和多标签都适用。"""
def label_to_id(labels, label_map):
label_map_length = len(label_map)
label_ids = [0] * label_map_length
for label in labels:
label_ids[label_map[label]] = 1
return label_ids
def convert_single_example(ex_index, example, label_list, max_seq_length,
tokenizer):
def file_based_convert_examples_to_features(
examples, label_list, max_seq_length, tokenizer, output_file):
def file_based_input_fn_builder(input_file, seq_length, label_length,
is_training, drop_remainder):
def truncate_seq_pair(tokens_a, tokens_b, max_length):
""" 6: 修改模型,在预训练层外面增加一个dense层,做softmax """
def create_model(bert_config, is_training, input_ids, input_mask, segment_ids,
labels, num_labels, use_one_hot_embeddings):
""" 10: 修改模型评估函数,增加F1值、precision和recall """
def model_fn_builder(bert_config, num_labels, init_checkpoint, learning_rate,
num_train_steps, num_warmup_steps, use_tpu,
use_one_hot_embeddings):
def main(_):
if __name__ == "__main__":
flags.mark_flag_as_required("data_dir")
flags.mark_flag_as_required("task_name")
flags.mark_flag_as_required("vocab_file")
flags.mark_flag_as_required("bert_config_file")
flags.mark_flag_as_required("output_dir")
tf.app.run()
首先介绍几个不用修改的函数:
InputFeatures 这个类定义了 BERT 的输入格式,也就是准备 input_ids,input_mask和 segment_ids 这三种数据。
class InputFeatures(object):
"""A single set of features of data."""
def __init__(self,
input_ids,
input_mask,
segment_ids,
label_ids,
is_real_example=True):
self.input_ids = input_ids
self.input_mask = input_mask
self.segment_ids = segment_ids
self.label_ids = label_ids
self.is_real_example = is_real_example
input_mask,也就是,如果定义输入的最大长度为400,而某篇文本的实际长度为150,那么input_mask的前150个元素为1,后250个元素为0。
segment_ids,也就是文本前后需要加 [CLS] 和 [SEP] 两个标识,用于区分输入的是单条文本,还是文本对。
(a) For sequence pairs:
tokens: [CLS] is this jack ##son ##ville ? [SEP] no it is not . [SEP]
type_ids: 0 0 0 0 0 0 0 0 1 1 1 1 1 1
(b) For single sequences:
tokens: [CLS] the dog is hairy . [SEP]
type_ids: 0 0 0 0 0 0 0
DataProcessor 这个类是数据预处理的基类,用于加载数据集和类别标签。
我们需要继承这个基类,然后自定义一个自己任务的类。
class DataProcessor(object):
"""Base class for data converters for sequence classification data sets."""
def get_train_examples(self, data_dir):
"""Gets a collection of `InputExample`s for the train set."""
raise NotImplementedError()
def get_dev_examples(self, data_dir):
"""Gets a collection of `InputExample`s for the dev set."""
raise NotImplementedError()
def get_test_examples(self, data_dir):
"""Gets a collection of `InputExample`s for prediction."""
raise NotImplementedError()
def get_labels(self):
"""Gets the list of labels for this data set."""
raise NotImplementedError()
@classmethod
def _read_tsv(cls, input_file, quotechar=None):
"""Reads a tab separated value file."""
with tf.gfile.Open(input_file, "r") as f:
reader = csv.reader(f, delimiter="\t", quotechar=quotechar)
lines = []
for line in reader:
lines.append(line)
return lines
接着介绍需要修改的类:
首先继承 DataProcessor 类,自定义一个自己任务的类,用于读取数据集,得到标签。
guid 是样本的唯一标识,用类似 "train-0" 这样的格式来表示。
""" 1: 创建自己的DataProcessor """
class HisProcessor(DataProcessor):
def __init__(self):
self.language = "zh"
@staticmethod
def _load_examples(data_dir):
with open(os.path.join(data_dir, "token_in.txt"), encoding='utf-8') as token_in_f:
with open(os.path.join(data_dir, "label.txt"), encoding='utf-8') as label_f:
token_in_list = [seq.replace("\n", '') for seq in token_in_f.readlines()]
label_list = [label.replace("\n", '') for label in label_f.readlines()]
assert len(token_in_list) == len(label_list)
examples = list(zip(token_in_list, label_list))
return examples
@staticmethod
def _create_example(lines, set_type):
"""Creates examples for the training and dev sets."""
examples = []
for (i, line) in enumerate(lines):
guid = "%s-%s" % (set_type, i)
text_token = line[0]
label_str = line[1]
examples.append(InputExample(guid=guid, text_a=text_token, text_b=None, label=label_str))
return examples
def get_train_examples(self, data_dir):
return self._create_example(self._load_examples(os.path.join(data_dir, "train")), "train")
def get_dev_examples(self, data_dir):
return self._create_example(self._load_examples(os.path.join(data_dir, "valid")), "valid")
def get_test_examples(self, data_dir):
return self._create_example(self._load_examples(os.path.join(data_dir, "test")), "test")
def get_labels(self):
"""
3 labels
"""
return ["古代史","近代史","现代史"]
然后对标签进行one-hot编码。
这是自己新增的一个函数,源码中没有。因为源码中,直接输入的是 0、1、 2这种数值 id。
之所以这么处理,是因为做多标签分类时,需要把标签处理成one-hot编码。这里把多分类和多标签的类别处理,统一起来。
label_map 是 :{"古代史":0, "近代史":1, "现代史":2}。
所以,如果标签为 古代史,那么one-hot编码为:[1,0,0]。
""" 2: 标签进行one-hot编码,多分类和多标签都适用。"""
def label_to_id(labels, label_map):
label_map_length = len(label_map)
label_ids = [0] * label_map_length
for label in labels:
label_ids[label_map[label]] = 1
return label_ids
七:BERT 的代码细节
01定义下游任务:run_classifier.py
create_model这个函数,是最关键的,我们需要在这里定义下游任务,做fine-tuning。
对应多分类,就是需要在预训练层加一个dense层,做softmax操作。
BERT 预训练层的输出,有两种。
model.get_sequence_output() # 用于序列标注任务
model.get_pooled_output() # 用于文本分类任务
第一种的输出维度是[batch_size, seq_length, embedding_dim],是token级别的,适用于做命名实体识别等序列标注任务。
第二种的输出维度是[batch_szie, embedding_dim],是句子级别的,适用于做文本分类任务。
我们采用第二种输出。
AI村村长问我:
不应该是用第一个 token( [CLS] ) 的输出,来接dense层吗?为什么源码中取的是所有token的输出,进行平均池化后的结果?
这个我也不知道。
所以,从预训练层的输出取 model.get_pooled_output(),再依次添加 weights和bias,形成dense层。得到的输出维度为 [batch_size, num_labels]。
最后对输出做softmax操作,计算概率分布和loss。
""" 6: 修改模型,在预训练层外面增加一个dense层,做softmax """
def create_model(bert_config, is_training, input_ids, input_mask, segment_ids,
labels, num_labels, use_one_hot_embeddings):
"""Creates a classification model."""
model = modeling.BertModel(
config=bert_config,
is_training=is_training,
input_ids=input_ids,
input_mask=input_mask,
token_type_ids=segment_ids,
use_one_hot_embeddings=use_one_hot_embeddings)
# If you want to use the token-level output, use model.get_sequence_output()
# instead.
""" 7: 文本分类,适用对序列做池化后的输出;序列标注,适用序列输出 """
output_layer = model.get_pooled_output()
""" 8: 依次添加weights和bias,构成dense层。"""
hidden_size = output_layer.shape[-1].value
output_weights = tf.get_variable(
"output_weights", [num_labels, hidden_size],
initializer=tf.truncated_normal_initializer(stddev=0.02))
output_bias = tf.get_variable(
"output_bias", [num_labels], initializer=tf.zeros_initializer())
with tf.variable_scope("loss"):
if is_training:
# I.e., 0.1 dropout
output_layer = tf.nn.dropout(output_layer, keep_prob=0.9)
logits_wx = tf.matmul(output_layer, output_weights, transpose_b=True)
logits = tf.nn.bias_add(logits_wx, output_bias)
probs = tf.nn.softmax(logits, axis=-1)
log_probs = tf.nn.log_softmax(logits,axis=-1)
""" 9: 与源码相比,已经是one-hot编码了,不用再转换。"""
one_hot_labels = tf.cast(labels, tf.float32)
per_example_loss = - tf.reduce_sum(one_hot_labels * log_probs, axis=-1)
loss = tf.reduce_mean(per_example_loss)
return (loss, per_example_loss, logits, probs)
02修改评估函数:run_classifier.py
BERT的源码中,只计算了 accuracy 这个评估指标,以及loss:
def model_fn_builder(bert_config, num_labels, init_checkpoint, learning_rate,
num_train_steps, num_warmup_steps, use_tpu,
use_one_hot_embeddings):
...
def metric_fn(per_example_loss, label_ids, logits, is_real_example):
predictions = tf.argmax(logits, axis=-1, output_type=tf.int32)
accuracy = tf.metrics.accuracy(
labels=label_ids, predictions=predictions, weights=is_real_example)
loss = tf.metrics.mean(values=per_example_loss, weights=is_real_example)
return {
"eval_accuracy": accuracy,
"eval_loss": loss,
}
eval_metrics = (metric_fn,
[per_example_loss, label_ids, logits, is_real_example])
而对于多分类,显然用 f1值、precision和recall 更合适。
我从网上找了一段代码,整理成一个 metrics.py 模块,用于计算这几个指标。
模块中有两个函数。这两个函数的作用,一个是得到计算混淆矩阵的 tensorflow op,另一个是从计算结果中取出这三个评估指标。
import numpy as np
import tensorflow as tf
from tensorflow.python.ops.metrics_impl import _streaming_confusion_matrix
""" 1: 得到混淆矩阵的op,将生成的混淆矩阵转换成tensor """
def get_metrics_ops(labels, predictions, num_labels):
cm, op = _streaming_confusion_matrix(labels, predictions, num_labels)
tf.logging.info(type(cm))
tf.logging.info(type(op))
return (tf.convert_to_tensor(cm), op)
""" 2: 得到numpy类型的混淆矩阵,然后计算precision,recall,f1值。"""
def get_metrics(conf_mat, num_labels):
precisions = []
recalls = []
for i in range(num_labels):
tp = conf_mat[i][i].sum()
col_sum = conf_mat[:, i].sum()
row_sum = conf_mat[i].sum()
precision = tp / col_sum if col_sum > 0 else 0
recall = tp / row_sum if row_sum > 0 else 0
precisions.append(precision)
recalls.append(recall)
pre = sum(precisions) / len(precisions)
rec = sum(recalls) / len(recalls)
f1 = 2 * pre * rec / (pre + rec)
return pre, rec, f1
那么我们把这两个函数 import 到 run_classifier.py 中去。
用第一个函数:get_metrics_ops ,去计算 confusion_matrix。
""" 得到包含F1,precision和recall的confusion_matrix。"""
confusion_matrix = get_metrics_ops(label_ids,predicted_labels,num_labels)
后面在 main 函数中,我们再用另外一个函数,把这三个评估指标的值取出来。
from metrics import get_metrics_ops, get_metrics
""" 10: 修改模型评估函数,增加F1值、precision和recall """
def model_fn_builder(bert_config, num_labels, init_checkpoint, learning_rate,
num_train_steps, num_warmup_steps, use_tpu,
use_one_hot_embeddings):
...
""" 12: 修改评估函数,增加 confusion_matrix """
def metric_fn(per_example_loss,label_ids, logits, num_labels,is_real_example):
label_ids = tf.argmax(label_ids,axis=-1,output_type=tf.int32)
predicted_labels = tf.argmax(logits,axis=-1,output_type=tf.int32)
accuracy = tf.metrics.accuracy(label_ids, predicted_labels,weights=is_real_example)
loss = tf.metrics.mean(values=per_example_loss,weights=is_real_example)
""" 13: 调用metrics模型的ops 函数,
得到包含F1,precision和recall的confusion_matrix。"""
confusion_matrix = get_metrics_ops(label_ids,predicted_labels,num_labels)
return {"eval_accuracy": accuracy,
"eval_loss": loss,
"eval_matrix": confusion_matrix}
eval_metrics = (metric_fn,
[per_example_loss,label_ids,logits,num_labels,is_real_example])
03主函数:run_classifier.py
train、eval 和 predict 的运算,在这个main函数中。
pocessor 使用我们自定义的 HisProcessor。
在eval部分,用get_metrics函数,把上一步计算的三个评估指标,取出来,打印日志并写入文件。
pre,rec,f1 = get_metrics(result["eval_matrix"],len(label_list))
训练过程中,打印的结果如下:
INFO:tensorflow:***** Eval results *****
INFO:tensorflow:eval_precision: 0.8563151862202495
INFO:tensorflow:eval_recall: 0.8422645377359008
INFO:tensorflow:eval_f1: 0.8492317485083197
INFO:tensorflow:eval_accuracy: 0.8289738297462463
INFO:tensorflow:eval_loss: 0.31580930948257446
在predict部分,模型输出的是3个概率值,我们用 np.argmax() 得到最大值的索引,从而得到预测的文本标签。同时,把在测试集上预测的结果保存起来。
比如概率值为[0.4,0.3,0.3],得到最大值的索引为0,预测为:古代史。
idx = np.argmax(probabilities,axis=-1)
这里我踩了两个坑:
一是不能根据值大于0.5这个判断条件,来得到索引,而是根据是否为最大值。
值大于0.5,来得到索引,适用于二分类和多标签分类。
二是用 np.argmax() 来得到索引,而不能用 tf.argmax(),否则会报错。
报错的意思是:计算图已经构建结束了,不能再调整计算图。
我的理解是,这个probabilities已经是numpy的格式,而不是tensorflow的格式,如果再用 tensorflow 的函数,那就会调整计算图。这是不允许的。
RuntimeError: Graph is finalized and cannot be modified.
INFO:tensorflow:prediction_loop marked as finished
main函数的大致结构如下:
def main(_):
tf.logging.set_verbosity(tf.logging.INFO)
""" 14: 使用自定义的 DataProcessor"""
processors = {
"history_multi_cls": HisProcessor,
}
...
if FLAGS.do_train:
train_file = os.path.join(FLAGS.output_dir, "train.tf_record")
file_based_convert_examples_to_features(
train_examples, label_list, FLAGS.max_seq_length, tokenizer, train_file)
tf.logging.info("***** Running training *****")
tf.logging.info(" Num examples = %d", len(train_examples))
tf.logging.info(" Batch size = %d", FLAGS.train_batch_size)
tf.logging.info(" Num steps = %d", num_train_steps)
""" 15: 相比源码,多传入了一个参数:label_length"""
train_input_fn = file_based_input_fn_builder(
input_file=train_file,
seq_length=FLAGS.max_seq_length,
label_length=label_length,
is_training=True,
drop_remainder=True)
estimator.train(input_fn=train_input_fn, max_steps=num_train_steps)
if FLAGS.do_eval:
...
result = estimator.evaluate(input_fn=eval_input_fn, steps=eval_steps)
output_eval_file = os.path.join(FLAGS.output_dir, "eval_results.txt")
""" 16: 增加评估指标打印和写入 """
with tf.gfile.GFile(output_eval_file, "w") as writer:
tf.logging.info("***** Eval results *****")
pre,rec,f1 = get_metrics(result["eval_matrix"],len(label_list))
tf.logging.info("eval_precision: {}".format(pre))
tf.logging.info("eval_recall: {}".format(rec))
tf.logging.info("eval_f1: {}".format(f1))
tf.logging.info("eval_accuracy: {}".format(result["eval_accuracy"]))
tf.logging.info("eval_loss: {}".format(result["eval_loss"]))
for key in sorted(result.keys()):
if key == "eval_matrix":
continue
writer.write("%s = %s\n" % (key, str(result[key])))
for k,v in zip(["eval_precision","eval_recall","eval_f1"],[pre,rec,f1]):
writer.write("%s = %s\n" % (k, str(v)))
if FLAGS.do_predict:
predict_examples = processor.get_test_examples(FLAGS.data_dir)
...
""" 17: 相比源码,多传入了一个参数:label_length """
predict_input_fn = file_based_input_fn_builder(
input_file=predict_file,
seq_length=FLAGS.max_seq_length,
label_length=label_length,
is_training=False,
drop_remainder=predict_drop_remainder)
result = estimator.predict(input_fn=predict_input_fn)
output_predict_file = os.path.join(FLAGS.output_dir, "predicted_label.txt")
with tf.gfile.GFile(output_predict_file, "w") as writer:
num_written_lines = 0
tf.logging.info("***** Predict results *****")
for (i, prediction) in enumerate(result):
probabilities = prediction["probabilities"]
if i >= num_actual_predict_examples:
break
""" 18: 相比源码输出概率值,这里改为输出文本标签 """
idx = np.argmax(probabilities,axis=-1)
predict_label = label_list[idx]
output_line_predict = predict_label + "\n"
writer.write(output_line_predict)
num_written_lines += 1
assert num_written_lines == num_actual_predict_examples
八:模型训练和测试
01模型测试:run_test.py
在main函数中,我们把模型在测试集上预测的结果,保存了起来。
所以写一个测试脚本,在测试集上做模型评估。
#coding:utf-8
from sklearn.metrics import f1_score,confusion_matrix,classification_report
from config import Config
import os
config = Config()
all_labels = ["古代史","近代史","现代史"]
labels_map = {label:i for i,label in enumerate(all_labels)}
true_file = os.path.join(config.his_proc_dir,"test","label.txt")
predict_file = os.path.join(config.output_dir,"epochs6","predicted_label.txt")
y_true, y_pred = [], []
with open(true_file, encoding='utf8') as f:
for line in f.readlines():
y_true.append(labels_map[line.strip()])
with open(predict_file, encoding='utf8') as f:
for i,line in enumerate(f.readlines()):
y_pred.append(labels_map[line.strip()])
f1_macro = f1_score(y_true, y_pred,average='macro')
f1_micro = f1_score(y_true, y_pred,average='micro')
print("1: 混淆矩阵为:\n")
print(confusion_matrix(y_true, y_pred))
print("\n2: 准确率、召回率和F1值为:\n")
print(classification_report(y_true, y_pred, target_names=all_labels, digits=4))
print("\n3: f1-macro of model is {:.4f}".format(f1_macro))
print("\n4: f1-micro of model is {:.4f}".format(f1_micro))
02 执行:run_test.py
一切准备就绪,写一个 shell 脚本:
数据预处理 ——> 模型的训练、验证和预测 ——> 模型评估。
BERT 的论文中,推荐的学习率的范围是 :5e-5, 4e-5, 3e-5, 和 2e-5,这里选择2e-5。
把 BERT 的参数,词表和预训练模型,加载进去,跑6个epoch。
# download bert.ckpt, move to pretrained_model
python data_processor.py
python run_classifier.py \
--task_name history_multi_cls \
--do_train true \
--do_eval true \
--do_predict true \
--data_dir ../data/bert_multi_cls_results/高中_历史/proc/ \
--vocab_file pretrained_model/chinese_L-12_H-768_A-12/vocab.txt \
--bert_config_file pretrained_model/chinese_L-12_H-768_A-12/bert_config.json \
--init_checkpoint pretrained_model/chinese_L-12_H-768_A-12/bert_model.ckpt \
--max_seq_length 400 \
--train_batch_size 32 \
--learning_rate 2e-5 \
--num_train_epochs 6.0 \
--output_dir ../data/bert_multi_cls_results/epochs6/
# test bert
python run_test.py
跑6个epochs的结果:
1: 混淆矩阵为:
[[ 98 0 8]
[ 1 122 42]
[ 5 35 186]]
2: 准确率、召回率和F1值为:
precision recall f1-score support
古代史 0.9423 0.9245 0.9333 106
近代史 0.7771 0.7394 0.7578 165
现代史 0.7881 0.8230 0.8052 226
accuracy 0.8169 497
macro avg 0.8358 0.8290 0.8321 497
weighted avg 0.8173 0.8169 0.8168 497
3: f1-macro of model is 0.8321
4: f1-micro of model is 0.8169
跑1个epoch的结果:
1: 混淆矩阵为:
[[ 82 0 8]
[ 1 137 36]
[ 1 39 193]]
2: 准确率、召回率和F1值为:
precision recall f1-score support
古代史 0.9762 0.9111 0.9425 90
近代史 0.7784 0.7874 0.7829 174
现代史 0.8143 0.8283 0.8213 233
accuracy 0.8290 497
macro avg 0.8563 0.8423 0.8489 497
weighted avg 0.8311 0.8290 0.8298 497
3: f1-macro of model is 0.8489
4: f1-micro of model is 0.8290
既然是fine-tuning,那就不要跑太多 epoch了,一般1~3个 epoch 就可以了。
好了,这就是用BERT做多分类的案例了,更多细节,可以上我的github去查看。
写得好辛苦,想去打农药,呵呵。
END