赞
踩
目录
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是站在巨人的肩膀上研究出来的一个模型,这些巨人包括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预训练模型的隐层参数(768维),拿出来作为字向量,输入到其他模型中去。比如把BERT的字向量,输入到TextCNN中,去做分类任务。
我认为,这种做法的问题,是把BERT解决一词多义问题的优势,给浪费了。
BERT做下游任务时,字/词的向量表示,会随着上下文的不同而变动,从而解决一词多义的问题。
而word2vec等词向量,向量表示是确定的,就无法解决这个问题。
所以把BERT当字向量来用,就没有这个优势了。
另外,BERT字向量的维度是768维的,是百度百科字向量(300维)的两倍以上,会大大降低模型训练和推理的速度。
在做长文本分类任务时,同样的一段文本,分字后的序列长度大概是分词后的两倍,又进一步增大了计算量。
双重打击。
所以,我觉得这种方法可能比较适合线下任务,适合对速度没那么高要求的场景。
我试了试,把原生BERT的字向量,输入到TextCNN中,也就是字向量模型,做多标签分类,速度慢得很,效果也不如TextCNN+百度百科字向量的模型。
就是在预训练模型层上添加新的网络层,然后预训练层和新网络层联合训练。
比如命名实体识别,在外面添加BiLSTM+CRF层,就成了BERT+BiLSTM+CRF模型。
这个例子可能不太典型,因为还是加了繁重的网络结构。
文本分类的例子最典型了,最后加一个Dense层,把输出维度降至类别数,再进行sigmoid或softmax。
这样,不需要很多数据,也不需要跑很多epochs,就能得到不错的效果。
首先从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。
用于导入数据,按模型的输入格式处理数据,在预训练层加下游任务,修改评估函数和输出结果等。
还是在 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
- }
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做历史试题的多分类,把历史题目分类为:古代史,近代史和现代史。
样本和标签的情况如下:
- 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 # 模型测试脚本
先写个配置参数和数据路径的模块: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 的内容如下:
- 现代史
- 现代史
- 近代史
- 近代史
- 现代史
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
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)
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])
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
在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))
一切准备就绪,写一个 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
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。