NLP

迁移学习

GLUE数据集、Fine-tuning

Posted by 新宇 on October 23, 2020

一、迁移学习理论

  • 预训练模型(Pretrained model):
    • 一般情况下预训练模型都是大型模型,具备复杂的网络结构,众多的参数量,以及在足够大的数据集下进行训练而产生的模型. 在NLP领域,预训练模型往往是语言模型,因为语言模型的训练是无监督的,可以获得大规模语料,同时语言模型又是许多典型NLP任务的基础,如机器翻译,文本生成,阅读理解等,常见的预训练模型有BERT,- GPT, roBERTa, transformer-XL等.
  • 微调(Fine-tuning):
    • 根据给定的预训练模型,改变它的部分参数或者为其新增部分输出结构后,通过在小部分数据集上训练,来使整个模型更好的适应特定任务.
  • 微调脚本(Fine-tuning script):
    • 实现微调过程的代码文件。这些脚本文件中,应包括对预训练模型的调用,对微调参数的选定以及对微调结构的更改等,同时,因为微调是一个训练过程,它同样需要一些- 超参数的设定,以及损失函数和优化器的选取等, 因此微调脚本往往也包含了整个迁移学习的过程.
  • 关于微调脚本的说明:
    • 一般情况下,微调脚本应该由不同的任务类型开发者自己编写,但是由于目前研究的NLP任务类型(分类,提取,生成)以及对应的微调输出结构都是有限的,有些微调方- 式已经在很多数据集上被验证是有效的,因此微调脚本也可以使用已经完成的规范脚本.
  • 两种迁移方式:
    • 直接使用预训练模型,进行相同任务的处理,不需要调整参数或模型结构,这些模型开箱即用。但是这种情况一般只适用于普适任务, 如:fasttest工具包中预训练的词向量模型。另外,很多预训练模型开发者为了达到开箱即用的效果,将模型结构分各个部分保存为不同的预训练模型,提供对应的加载方法来完成特定目标.
    • 更加主流的迁移学习方式是发挥预训练模型特征抽象的能力,然后再通过微调的方式,通过训练更新小部分参数以此来适应不同的任务。这种迁移方式需要提供小部分的标- 注数据来进行监督学习.
  • 关于迁移方式的说明:
    • 直接使用预训练模型的方式, 已经在fasttext的词向量迁移中学习. 接下来的迁移学习实践将主要讲解通过微调的方式进行迁移学习.

二、NLP中的标准数据集

  • GLUE数据集合的介绍:
    • GLUE由纽约大学, 华盛顿大学, Google联合推出, 涵盖不同NLP任务类型, 截止至2020年1月其中包括11个子任务数据集, 成为衡量NLP研究发展的衡量标准.
  • GLUE数据集合包含以下数据集
    • CoLA 数据集
    • SST-2 数据集
    • MRPC 数据集
    • STS-B 数据集
    • QQP 数据集
    • MNLI 数据集
    • SNLI 数据集
    • QNLI 数据集
    • RTE 数据集
    • WNLI 数据集
    • diagnostics数据集(官方未完善)

GLUE数据集合的下载方式:

''' Script for downloading all GLUE data.'''

import os
import sys
import shutil
import argparse
import tempfile
import urllib.request
import zipfile

TASKS = ["CoLA", "SST", "MRPC", "QQP", "STS", "MNLI", "SNLI", "QNLI", "RTE", "WNLI", "diagnostic"]
TASK2PATH = {"CoLA":'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2FCoLA.zip?alt=media&token=46d5e637-3411-4188-bc44-5809b5bfb5f4',
             "SST":'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2FSST-2.zip?alt=media&token=aabc5f6b-e466-44a2-b9b4-cf6337f84ac8',
             "MRPC":'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2Fmrpc_dev_ids.tsv?alt=media&token=ec5c0836-31d5-48f4-b431-7480817f1adc',
             "QQP":'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2FQQP.zip?alt=media&token=700c6acf-160d-4d89-81d1-de4191d02cb5',
             "STS":'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2FSTS-B.zip?alt=media&token=bddb94a7-8706-4e0d-a694-1109e12273b5',
             "MNLI":'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2FMNLI.zip?alt=media&token=50329ea1-e339-40e2-809c-10c40afff3ce',
             "SNLI":'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2FSNLI.zip?alt=media&token=4afcfbb2-ff0c-4b2d-a09a-dbf07926f4df',
             "QNLI": 'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2FQNLIv2.zip?alt=media&token=6fdcf570-0fc5-4631-8456-9505272d1601',
             "RTE":'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2FRTE.zip?alt=media&token=5efa7e85-a0bb-4f19-8ea2-9e1840f077fb',
             "WNLI":'https://firebasestorage.googleapis.com/v0/b/mtl-sentence-representations.appspot.com/o/data%2FWNLI.zip?alt=media&token=068ad0a0-ded7-4bd7-99a5-5e00222e0faf',
             "diagnostic":'https://storage.googleapis.com/mtl-sentence-representations.appspot.com/tsvsWithoutLabels%2FAX.tsv?GoogleAccessId=firebase-adminsdk-0khhl@mtl-sentence-representations.iam.gserviceaccount.com&Expires=2498860800&Signature=DuQ2CSPt2Yfre0C%2BiISrVYrIFaZH1Lc7hBVZDD4ZyR7fZYOMNOUGpi8QxBmTNOrNPjR3z1cggo7WXFfrgECP6FBJSsURv8Ybrue8Ypt%2FTPxbuJ0Xc2FhDi%2BarnecCBFO77RSbfuz%2Bs95hRrYhTnByqu3U%2FYZPaj3tZt5QdfpH2IUROY8LiBXoXS46LE%2FgOQc%2FKN%2BA9SoscRDYsnxHfG0IjXGwHN%2Bf88q6hOmAxeNPx6moDulUF6XMUAaXCSFU%2BnRO2RDL9CapWxj%2BDl7syNyHhB7987hZ80B%2FwFkQ3MEs8auvt5XW1%2Bd4aCU7ytgM69r8JDCwibfhZxpaa4gd50QXQ%3D%3D'}

MRPC_TRAIN = 'https://dl.fbaipublicfiles.com/senteval/senteval_data/msr_paraphrase_train.txt'
MRPC_TEST = 'https://dl.fbaipublicfiles.com/senteval/senteval_data/msr_paraphrase_test.txt'

def download_and_extract(task, data_dir):
    print("Downloading and extracting %s..." % task)
    data_file = "%s.zip" % task
    urllib.request.urlretrieve(TASK2PATH[task], data_file)
    with zipfile.ZipFile(data_file) as zip_ref:
        zip_ref.extractall(data_dir)
    os.remove(data_file)
    print("\tCompleted!")

def format_mrpc(data_dir, path_to_data):
    print("Processing MRPC...")
    mrpc_dir = os.path.join(data_dir, "MRPC")
    if not os.path.isdir(mrpc_dir):
        os.mkdir(mrpc_dir)
    if path_to_data:
        mrpc_train_file = os.path.join(path_to_data, "msr_paraphrase_train.txt")
        mrpc_test_file = os.path.join(path_to_data, "msr_paraphrase_test.txt")
    else:
        print("Local MRPC data not specified, downloading data from %s" % MRPC_TRAIN)
        mrpc_train_file = os.path.join(mrpc_dir, "msr_paraphrase_train.txt")
        mrpc_test_file = os.path.join(mrpc_dir, "msr_paraphrase_test.txt")
        urllib.request.urlretrieve(MRPC_TRAIN, mrpc_train_file)
        urllib.request.urlretrieve(MRPC_TEST, mrpc_test_file)
    assert os.path.isfile(mrpc_train_file), "Train data not found at %s" % mrpc_train_file
    assert os.path.isfile(mrpc_test_file), "Test data not found at %s" % mrpc_test_file
    urllib.request.urlretrieve(TASK2PATH["MRPC"], os.path.join(mrpc_dir, "dev_ids.tsv"))

    dev_ids = []
    with open(os.path.join(mrpc_dir, "dev_ids.tsv"), encoding="utf8") as ids_fh:
        for row in ids_fh:
            dev_ids.append(row.strip().split('\t'))

    with open(mrpc_train_file, encoding="utf8") as data_fh, \
         open(os.path.join(mrpc_dir, "train.tsv"), 'w', encoding="utf8") as train_fh, \
         open(os.path.join(mrpc_dir, "dev.tsv"), 'w', encoding="utf8") as dev_fh:
        header = data_fh.readline()
        train_fh.write(header)
        dev_fh.write(header)
        for row in data_fh:
            label, id1, id2, s1, s2 = row.strip().split('\t')
            if [id1, id2] in dev_ids:
                dev_fh.write("%s\t%s\t%s\t%s\t%s\n" % (label, id1, id2, s1, s2))
            else:
                train_fh.write("%s\t%s\t%s\t%s\t%s\n" % (label, id1, id2, s1, s2))

    with open(mrpc_test_file, encoding="utf8") as data_fh, \
            open(os.path.join(mrpc_dir, "test.tsv"), 'w', encoding="utf8") as test_fh:
        header = data_fh.readline()
        test_fh.write("index\t#1 ID\t#2 ID\t#1 String\t#2 String\n")
        for idx, row in enumerate(data_fh):
            label, id1, id2, s1, s2 = row.strip().split('\t')
            test_fh.write("%d\t%s\t%s\t%s\t%s\n" % (idx, id1, id2, s1, s2))
    print("\tCompleted!")

def download_diagnostic(data_dir):
    print("Downloading and extracting diagnostic...")
    if not os.path.isdir(os.path.join(data_dir, "diagnostic")):
        os.mkdir(os.path.join(data_dir, "diagnostic"))
    data_file = os.path.join(data_dir, "diagnostic", "diagnostic.tsv")
    urllib.request.urlretrieve(TASK2PATH["diagnostic"], data_file)
    print("\tCompleted!")
    return

def get_tasks(task_names):
    task_names = task_names.split(',')
    if "all" in task_names:
        tasks = TASKS
    else:
        tasks = []
        for task_name in task_names:
            assert task_name in TASKS, "Task %s not found!" % task_name
            tasks.append(task_name)
    return tasks

def main(arguments):
    parser = argparse.ArgumentParser()
    parser.add_argument('--data_dir', help='directory to save data to', type=str, default='glue_data')
    parser.add_argument('--tasks', help='tasks to download data for as a comma separated string',
                        type=str, default='all')
    parser.add_argument('--path_to_mrpc', help='path to directory containing extracted MRPC data, msr_paraphrase_train.txt and msr_paraphrase_text.txt',
                        type=str, default='')
    args = parser.parse_args(arguments)

    if not os.path.isdir(args.data_dir):
        os.mkdir(args.data_dir)
    tasks = get_tasks(args.tasks)

    for task in tasks:
        if task == 'MRPC':
            format_mrpc(args.data_dir, args.path_to_mrpc)
        elif task == 'diagnostic':
            download_diagnostic(args.data_dir)
        else:
            download_and_extract(task, args.data_dir)


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
# 假设你已经将以上代码copy到download_glue_data.py文件中

# 运行这个python脚本, 你将同目录下得到一个glue文件夹

python download_glue_data.py

三、NLP中常见的预训练模型

  • 当下NLP中流行的预训练模型
    • BERT
    • GPT
    • GPT-2
    • Transformer-XL
    • XLNet
    • XLM
    • RoBERTa
    • DistilBERT
    • ALBERT
    • T5
    • XLM-RoBERTa
  • BERT及其变体:
    • bert-base-uncased: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共110M参数量, 在小写的英文文本上进行训练而得到.
    • bert-large-uncased: 编码器具有24个隐层, 输出1024维张量, 16个自注意力头, 共340M参数量, 在小写的英文文本上进行训练而得到.
    • bert-base-cased: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共110M参数量, 在不区分大小写的英文文本上进行训练而得到.
    • bert-large-cased: 编码器具有24个隐层, 输出1024维张量, 16个自注意力头, 共340M参数量, 在不区分大小写的英文文本上进行训练而得到.
    • bert-base-multilingual-uncased: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共110M参数量, 在小写的102种语言文本上进行训练而得到.
    • bert-large-multilingual-uncased: 编码器具有24个隐层, 输出1024维张量, 16个自注意力头, 共340M参数量, 在小写的102种语言文本上进行训练而得到.
    • bert-base-chinese: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共110M参数量, 在简体和繁体中文文本上进行训练而得到.
  • GPT:
    • openai-gpt: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共110M参数量, 由OpenAI在英文语料上进行训练而得到.
  • GPT-2及其变体:
    • gpt2: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共117M参数量, 在OpenAI GPT-2英文语料上进行训练而得到.
    • gpt2-xl: 编码器具有48个隐层, 输出1600维张量, 25个自注意力头, 共1558M参数量, 在大型的OpenAI GPT-2英文语料上进行训练而得到.
  • Transformer-XL:
    • transfo-xl-wt103: 编码器具有18个隐层, 输出1024维张量, 16个自注意力头, 共257M参数量, 在wikitext-103英文语料进行训练而得到.
  • XLNet及其变体:
    • xlnet-base-cased: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共110M参数量, 在英文语料上进行训练而得到.
    • xlnet-large-cased: 编码器具有24个隐层, 输出1024维张量, 16个自注意力头, 共240参数量, 在英文语料上进行训练而得到.
  • XLM:
    • xlm-mlm-en-2048: 编码器具有12个隐层, 输出2048维张量, 16个自注意力头, 在英文文本上进行训练而得到.
  • RoBERTa及其变体:
    • roberta-base: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共125M参数量, 在英文文本上进行训练而得到.
    • roberta-large: 编码器具有24个隐层, 输出1024维张量, 16个自注意力头, 共355M参数量, 在英文文本上进行训练而得到.
  • DistilBERT及其变体:
    • distilbert-base-uncased: 基于bert-base-uncased的蒸馏(压缩)模型, 编码器具有6个隐层, 输出768维张量, 12个自注意力头, 共66M参数量.
    • distilbert-base-multilingual-cased: 基于bert-base-multilingual-uncased的蒸馏(压缩)模型, 编码器具有6个隐层, 输出768维张量, 12个自注意力头, 共66M参数量.
  • ALBERT:
    • albert-base-v1: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共125M参数量, 在英文文本上进行训练而得到.
    • albert-base-v2: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共125M参数量, 在英文文本上进行训练而得到, 相比v1使用了更多的数据量, 花费更长的训练时间.
  • T5及其变体:
    • t5-small: 编码器具有6个隐层, 输出512维张量, 8个自注意力头, 共60M参数量, 在C4语料上进行训练而得到.
    • t5-base: 编码器具有12个隐层, 输出768维张量, 12个自注意力头, 共220M参数量, 在C4语料上进行训练而得到.
    • t5-large: 编码器具有24个隐层, 输出1024维张量, 16个自注意力头, 共770M参数量, 在C4语料上进行训练而得到.
  • XLM-RoBERTa及其变体:
    • xlm-roberta-base: 编码器具有12个隐层, 输出768维张量, 8个自注意力头, 共125M参数量, 在2.5TB的100种语言文本上进行训练而得到.
    • xlm-roberta-large: 编码器具有24个隐层, 输出1027维张量, 16个自注意力头, 共355M参数量, 在2.5TB的100种语言文本上进行训练而得到.
  • 预训练模型说明:
    • 所有上述预训练模型及其变体都是以transformer为基础,只是在模型结构如神经元连接方式,编码器隐层数,多头注意力的头数等发生改变,这些改变方式的大部分依据都是由在标准数据集上的表现而定,因此,对于我们使用者而言,不需要从理论上深度探究这些预训练模型的结构设计的优劣,只需要在自己处理的目标数据上,尽量遍历所有可用的模型对比得到最优效果即可.

四、加载和使用预训练模型

  • 加载和使用预训练模型的工具
    • 在这里我们使用torch.hub工具进行模型的加载和使用.
    • 这些预训练模型由世界先进的NLP研发团队huggingface提供.
  • 加载和使用预训练模型的步骤
    • 第一步: 确定需要加载的预训练模型并安装依赖包.
    • 第二步: 加载预训练模型的映射器tokenizer.
    • 第三步: 加载带/不带头的预训练模型.
    • 第四步: 使用模型获得输出结果.

1. 确定需要加载的预训练模型并安装依赖包

pip install tqdm boto3 requests regex sentencepiece sacremoses

2. 加载预训练模型的映射器tokenizer

import torch

# 预训练模型来源

source = 'huggingface/pytorch-transformers'
# 选定加载模型的哪一部分, 这里是模型的映射器

part = 'tokenizer'
# 加载的预训练模型的名字

model_name = 'bert-base-chinese'
tokenizer = torch.hub.load(source, part, model_name)  

3. 加载带/不带头的预训练模型

  • 加载预训练模型时我们可以选择带头或者不带头的模型
  • 这里的’头’是指模型的任务输出层, 选择加载不带头的模型, 相当于使用模型对输入文本进行特征表示.
  • 选择加载带头的模型时, 有三种类型的’头’可供选择, modelWithLMHead(语言模型头), modelForSequenceClassification(分类模型头), modelForQuestionAnswering(问答模型头)
  • 不同类型的’头’, 可以使预训练模型输出指定的张量维度. 如使用’分类模型头’, 则输出尺寸为(1,2)的张量, 用于进行分类任务判定结果.
# 加载不带头的预训练模型

part = 'model'
model = torch.hub.load(source, part, model_name)

# 加载带有语言模型头的预训练模型

part = 'modelWithLMHead'
lm_model = torch.hub.load(source, part, model_name)

# 加载带有类模型头的预训练模型

part = 'modelForSequenceClassification'
classification_model = torch.hub.load(source, part, model_name)

# 加载带有问答模型头的预训练模型

part = 'modelForQuestionAnswering'
qa_model = torch.hub.load(source, part, model_name)

4. 使用模型获得输出结果

# 输入的中文文本

input_text = "人生该如何起头"

# 使用tokenizer进行数值映射

indexed_tokens = tokenizer.encode(input_text)

# 打印映射后的结构

print("indexed_tokens:", indexed_tokens)

# 将映射结构转化为张量输送给不带头的预训练模型

tokens_tensor = torch.tensor([indexed_tokens])

# 使用不带头的预训练模型获得结果

with torch.no_grad():
    encoded_layers, _ = model(tokens_tensor)

print("不带头的模型输出结果:", encoded_layers)

print("不带头的模型输出结果的尺寸:", encoded_layers.shape)

使用带有语言模型头的模型进行输出:

# 使用带有语言模型头的预训练模型获得结果

with torch.no_grad():
    lm_output = lm_model(tokens_tensor)

print("带语言模型头的模型输出结果:", lm_output)

print("带语言模型头的模型输出结果的尺寸:", lm_output[0].shape)

使用带有分类模型头的模型进行输出:

# 使用带有分类模型头的预训练模型获得结果

with torch.no_grad():
    classification_output = classification_model(tokens_tensor)

print("带分类模型头的模型输出结果:", classification_output)

print("带分类模型头的模型输出结果的尺寸:", classification_output[0].shape)

使用带有问答模型头的模型进行输出:

# 使用带有问答模型头的模型进行输出时, 需要使输入的形式为句子对
# 第一条句子是对客观事物的陈述
# 第二条句子是针对第一条句子提出的问题
# 问答模型最终将得到两个张量, 
# 每个张量中最大值对应索引的分别代表答案的在文本中的起始位置和终止位置.

input_text1 = "我家的小狗是黑色的"
input_text2 = "我家的小狗是什么颜色的呢?"


# 映射两个句子
indexed_tokens = tokenizer.encode(input_text1, input_text2)
print("句子对的indexed_tokens:", indexed_tokens)

# 输出结果: [101, 2769, 2157, 4638, 2207, 4318, 3221, 7946, 5682, 4638, 102, 2769, 2157, 4638, 2207, 4318, 3221, 784, 720, 7582, 5682, 4638, 1450, 136, 102]

# 用0,1来区分第一条和第二条句子

segments_ids = [0]*11 + [1]*14

# 转化张量形式

segments_tensors = torch.tensor([segments_ids])
tokens_tensor = torch.tensor([indexed_tokens])

# 使用带有问答模型头的预训练模型获得结果

with torch.no_grad():
    start_logits, end_logits = qa_model(tokens_tensor, token_type_ids=segments_tensors)


print("带问答模型头的模型输出结果:", (start_logits, end_logits))
print("带问答模型头的模型输出结果的尺寸:", (start_logits.shape, end_logits.shape))

五、迁移学习实践

  • 指定任务类型的微调脚本:
    • huggingface研究机构向我们提供了针对GLUE数据集合任务类型的微调脚本, 这些微调脚本的核心都是微调模型的最后一个全连接层.
    • 通过简单的参数配置来指定GLUE中存在任务类型(如: CoLA对应文本二分类, MRPC对应句子对文本二分类, STS-B对应句子对文本多分类), 以及指定需要微调的预训练模型.
  • 指定任务类型的微调脚本使用步骤
    • 第一步: 下载微调脚本文件
    • 第二步: 配置微调脚本参数
    • 第三步: 运行并检验效果

1. 指定任务类型的微调脚本使用

1.1 下载微调脚本文件

# 克隆huggingface的transfomers文件

git clone https://github.com/huggingface/transformers.git

# 进行transformers文件夹

cd transformers

# 安装python的transformer工具包, 因为微调脚本是py文件.

pip install .

# 当前的版本可能跟我们教学的版本并不相同,你还需要执行:


pip install transformers==2.3.0

# 进入微调脚本所在路径并查看

cd examples
ls

# 其中run_glue.py就是针对GLUE数据集合任务类型的微调脚本

1.2 配置微调脚本参数

# 定义DATA_DIR: 微调数据所在路径, 这里我们使用glue_data中的数据作为微调数据

export DATA_DIR="../../glue_data"
# 定义SAVE_DIR: 模型的保存路径, 我们将模型保存在当前目录的bert_finetuning_test文件中

export SAVE_DIR="./bert_finetuning_test/"

# 使用python运行微调脚本
# --model_type: 选择需要微调的模型类型, 这里可以选择BERT, XLNET, XLM, roBERTa, distilBERT, ALBERT
# --model_name_or_path: 选择具体的模型或者变体, 这里是在英文语料上微调, 因此选择bert-base-uncased
# --task_name: 它将代表对应的任务类型, 如MRPC代表句子对二分类任务
# --do_train: 使用微调脚本进行训练
# --do_eval: 使用微调脚本进行验证
# --data_dir: 训练集及其验证集所在路径, 将自动寻找该路径下的train.tsv和dev.tsv作为训练集和验证集
# --max_seq_length: 输入句子的最大长度, 超过则截断, 不足则补齐
# --learning_rate: 学习率
# --num_train_epochs: 训练轮数
# --output_dir $SAVE_DIR: 训练后的模型保存路径
# --overwrite_output_dir: 再次训练时将清空之前的保存路径内容重新写入

python run_glue.py \
  --model_type BERT \
  --model_name_or_path bert-base-uncased \
  --task_name MRPC \
  --do_train \
  --do_eval \
  --data_dir $DATA_DIR/MRPC/ \
  --max_seq_length 128 \
  --learning_rate 2e-5 \
  --num_train_epochs 1.0 \
  --output_dir $SAVE_DIR \
  --overwrite_output_dir

1.3 运行并检验效果

# 使用sh命令运行

sh run_glue.sh

2. 通过微调脚本微调后模型的使用步骤

  • 第一步: 在https://huggingface.co/join上创建一个帐户
  • 第二步: 在服务器终端使用transformers-cli登陆
  • 第三步: 使用transformers-cli上传模型并查看
  • 第四步: 使用pytorch.hub加载模型进行使用

2.1 第一步: 创建帐户

# 默认账户

username: ItcastAI
password: ItcastAI

在 https://huggingface.co/join 上创建一个帐户

2.2 第二步: 在服务器终端使用transformers-cli登陆

# 在微调模型的服务器上登陆
# 使用刚刚注册的用户名和密码
# 默认username: ItcastAI
# 默认password: ItcastAI

$ transformers-cli login

2.3 第三步: 使用transformers-cli上传模型并查看

# 使用transformers-cli upload命令上传模型
# 选择正确的微调模型路径
$ transformers-cli upload ./bert_finetuning_test/

# 查看上传结果
$ transformers-cli ls

Filename                                              LastModified             ETag                               Size
----------------------------------------------------- ------------------------ ---------------------------------- ---------
bert_finetuning_test/added_tokens.json                2020-01-05T17:39:57.000Z "99914b932bd37a50b983c5e7c90ae93b"         2
bert_finetuning_test/checkpoint-400/config.json       2020-01-05T17:26:49.000Z "74d53ea41e5acb6d60496bc195d82a42"       684
bert_finetuning_test/checkpoint-400/training_args.bin 2020-01-05T17:26:47.000Z "b3273519c2b2b1cb2349937279880f50"      1207
bert_finetuning_test/checkpoint-450/config.json       2020-01-05T17:15:42.000Z "74d53ea41e5acb6d60496bc195d82a42"       684
bert_finetuning_test/checkpoint-450/pytorch_model.bin 2020-01-05T17:15:58.000Z "077cc0289c90b90d6b662cce104fe4ef" 437982584
bert_finetuning_test/checkpoint-450/training_args.bin 2020-01-05T17:15:40.000Z "b3273519c2b2b1cb2349937279880f50"      1207
bert_finetuning_test/config.json                      2020-01-05T17:28:50.000Z "74d53ea41e5acb6d60496bc195d82a42"       684
bert_finetuning_test/eval_results.txt                 2020-01-05T17:28:56.000Z "67d2d49a96afc4308d33bfcddda8a7c5"        81
bert_finetuning_test/pytorch_model.bin                2020-01-05T17:28:59.000Z "d46a8ccfb8f5ba9ecee70cef8306679e" 437982584
bert_finetuning_test/special_tokens_map.json          2020-01-05T17:28:54.000Z "8b3fb1023167bb4ab9d70708eb05f6ec"       112
bert_finetuning_test/tokenizer_config.json            2020-01-05T17:28:52.000Z "0d7f03e00ecb582be52818743b50e6af"        59
bert_finetuning_test/training_args.bin                2020-01-05T17:28:48.000Z "b3273519c2b2b1cb2349937279880f50"      1207
bert_finetuning_test/vocab.txt                        2020-01-05T17:39:55.000Z "64800d5d8528ce344256daf115d4965e"    231508

2.4 第四步: 使用pytorch.hub加载模型进行使用

# 若之前使用过huggingface的transformers, 请清除~/.cache

import torch
# 如: ItcastAI/bert_finetuning_test
source = 'huggingface/pytorch-transformers'
# 选定加载模型的哪一部分, 这里是模型的映射器
part = 'tokenizer'

#############################################
# 加载的预训练模型的名字
# 使用自己的模型名字"username/model_name"
# 如:'ItcastAI/bert_finetuning_test'
model_name = 'ItcastAI/bert_finetuning_test'
#############################################

tokenizer = torch.hub.load('huggingface/pytorch-transformers', 'tokenizer', model_name)
model =  torch.hub.load('huggingface/pytorch-transformers', 'modelForSequenceClassification', model_name)
index = tokenizer.encode("Talk is cheap", "Please show me your code!")
# 102是bert模型中的间隔(结束)符号的数值映射
mark = 102

# 找到第一个102的索引, 即句子对的间隔符号
k = index.index(mark)

# 句子对分割id列表, 由0,1组成, 0的位置代表第一个句子, 1的位置代表第二个句子
segments_ids = [0]*(k + 1) + [1]*(len(index) - k - 1)
# 转化为tensor
tokens_tensor = torch.tensor([index])
segments_tensors = torch.tensor([segments_ids])

# 使用评估模式
with torch.no_grad():
    # 使用模型预测获得结果
    result = model(tokens_tensor, token_type_ids=segments_tensors)
    # 打印预测结果以及张量尺寸
    print(result)
    print(result[0].shape)

3. 通过微调方式进行迁移学习的两种类型

  • 类型一: 使用指定任务类型的微调脚本微调预训练模型, 后接带有输出头的预定义网络输出结果.
  • 类型二: 直接加载预训练模型进行输入文本的特征表示, 后接自定义网络进行微调输出结果.

3.1 类型一实战演示

  • 使用文本二分类的任务类型SST-2的微调脚本微调中文预训练模型, 后接带有分类输出头的预定义网络输出结果. 目标是判断句子的情感倾向.
  • 准备中文酒店评论的情感分析语料, 语料样式与SST-2数据集相同, 标签0代表差评, 标签1好评.
  • 语料存放在与glue_data/同级目录cn_data/下, 其中的SST-2目录包含train.tsv和dev.tsv

3.1.1 在run_glue.py同级目录下创建run_cn.sh文件

# 定义DATA_DIR: 微调数据所在路径
export DATA_DIR="../../cn_data"
# 定义SAVE_DIR: 模型的保存路径, 我们将模型保存在当前目录的bert_finetuning文件中
export SAVE_DIR="./bert_cn_finetuning/"

# 使用python运行微调脚本
# --model_type: 选择BERT
# --model_name_or_path: 选择bert-base-chinese
# --task_name: 句子二分类任务SST-2
# --do_train: 使用微调脚本进行训练
# --do_eval: 使用微调脚本进行验证
# --data_dir: "./cn_data/SST-2/", 将自动寻找该路径下的train.tsv和dev.tsv作为训练集和验证集
# --max_seq_length: 128,输入句子的最大长度
# --output_dir $SAVE_DIR: "./bert_finetuning/", 训练后的模型保存路径
python run_glue.py \
  --model_type BERT \
  --model_name_or_path bert-base-chinese \
  --task_name SST-2 \
  --do_train \
  --do_eval \
  --data_dir $DATA_DIR/SST-2/ \
  --max_seq_length 128 \
  --learning_rate 2e-5 \
  --num_train_epochs 1.0 \
  --output_dir $SAVE_DIR \

3.1.2 运行并检验效果

# 使用sh命令运行

sh run_cn.sh

3.1.3 使用transformers-cli上传模型

# 默认username: ItcastAI
# 默认password: ItcastAI
$ transformers-cli login

# 使用transformers-cli upload命令上传模型
# 选择正确的微调模型路径
$ transformers-cli upload ./bert_cn_finetuning/

3.1.4 通过pytorch.hub加载模型进行使用

import torch

source = 'huggingface/pytorch-transformers'
# 模型名字为'ItcastAI/bert_cn_finetuning'

model_name = 'ItcastAI/bert_cn_finetuning'

tokenizer = torch.hub.load(source, 'tokenizer', model_name)
model =  torch.hub.load(source, 'modelForSequenceClassification', model_name)

def get_label(text):
    index = tokenizer.encode(text)
    tokens_tensor = torch.tensor([index])
    # 使用评估模式

    with torch.no_grad():
        # 使用模型预测获得结果

        result = model(tokens_tensor)
    predicted_label = torch.argmax(result[0]).item()
    return predicted_label

if __name__ == "__main__":
    # text = "早餐不好,服务不到位,晚餐无西餐,早餐晚餐相同,房间条件不好"

    text = "房间应该超出30平米,是HK同级酒店中少有的大;重装之后,设备也不错."
    print("输入文本为:", text)
    print("预测标签为:", get_label(text))

3.2 类型二实战演示

  • 直接加载预训练模型进行输入文本的特征表示, 后接自定义网络进行微调输出结果.
  • 使用语料和完成的目标与类型一实战相同.

直接加载预训练模型进行输入文本的特征表示:

import torch
# 进行句子的截断补齐(规范长度)

from keras.preprocessing import sequence

source = 'huggingface/pytorch-transformers'

# 直接使用预训练的bert中文模型

model_name = 'bert-base-chinese'

# 通过torch.hub获得已经训练好的bert-base-chinese模型

model =  torch.hub.load(source, 'model', model_name)

# 获得对应的字符映射器, 它将把中文的每个字映射成一个数字

tokenizer = torch.hub.load(source, 'tokenizer', model_name)

# 句子规范长度

cutlen = 32

def get_bert_encode(text):
    """
    description: 使用bert-chinese编码中文文本
    :param text: 要进行编码的文本
    :return: 使用bert编码后的文本张量表示
    """

    # 首先使用字符映射器对每个汉字进行映射

    # 这里需要注意, bert的tokenizer映射后会为结果前后添加开始和结束标记即101和102 

    # 这对于多段文本的编码是有意义的, 但在我们这里没有意义, 因此使用[1:-1]对头和尾进行切片

    indexed_tokens = tokenizer.encode(text[:cutlen])[1:-1]
    # 对映射后的句子进行截断补齐

    indexed_tokens = sequence.pad_sequences([indexed_tokens], cutlen) 
    # 之后将列表结构转化为tensor

    tokens_tensor = torch.LongTensor(indexed_tokens)
    # 使模型不自动计算梯度

    with torch.no_grad():
        # 调用模型获得隐层输出

        encoded_layers, _ = model(tokens_tensor)
    # 输出的隐层是一个三维张量, 最外层一维是1, 我们使用[0]降去它.

    encoded_layers = encoded_layers[0]
    return encoded_layers

if __name__ == "__main__":
    text = "早餐不好,服务不到位,晚餐无西餐,早餐晚餐相同,房间条件不好"
    encoded_layers = get_bert_encode(text)
    print(encoded_layers)
    print(encoded_layers.shape)

自定义单层的全连接网络作为微调网络

import torch.nn as nn
import torch.nn.functional as F


class Net(nn.Module):
    """定义微调网络的类"""

    def __init__(self, char_size=32, embedding_size=768):
        """
        :param char_size: 输入句子中的字符数量, 即输入句子规范后的长度128.
        :param embedding_size: 字嵌入的维度, 因为使用的bert中文模型嵌入维度是768, 因此embedding_size为768
        """

        super(Net, self).__init__()
        # 将char_size和embedding_size传入其中

        self.char_size = char_size
        self.embedding_size = embedding_size
        # 实例化一个全连接层

        self.fc1 = nn.Linear(char_size*embedding_size, 2)

    def forward(self, x):
        # 对输入的张量形状进行变换, 以满足接下来层的输入要求

        x = x.view(-1, self.char_size*self.embedding_size)
        # 使用一个全连接层

        x = self.fc1(x)
        return x

if __name__ == "__main__":
    # 随机初始化一个输入参数

    x = torch.randn(1, 32, 768)
    # 实例化网络结构, 所有参数使用默认值

    net = Net()
    nr = net(x)
    print(nr)    

构建训练与验证数据批次生成器

import pandas as pd
from collections import Counter
from functools import reduce
from sklearn.utils import shuffle

def data_loader(train_data_path, valid_data_path, batch_size):
    """
    description: 从持久化文件中加载数据
    :param train_data_path: 训练数据路径
    :param valid_data_path: 验证数据路径
    :param batch_size: 训练和验证数据集的批次大小
    :return: 训练数据生成器, 验证数据生成器, 训练数据数量, 验证数据数量
    """

    # 使用pd进行csv数据的读取, 并去除第一行的列名

    train_data = pd.read_csv(train_data_path, header=None, sep="\t").drop([0])
    valid_data = pd.read_csv(valid_data_path, header=None, sep="\t").drop([0])

    # 打印训练集和验证集上的正负样本数量

    print("训练数据集的正负样本数量:")
    print(dict(Counter(train_data[1].values)))
    print("验证数据集的正负样本数量:")
    print(dict(Counter(valid_data[1].values)))

    # 验证数据集中的数据总数至少能够满足一个批次

    if len(valid_data) < batch_size:
        raise("Batch size or split not match!")

    def _loader_generator(data):
        """
        description: 获得训练集/验证集的每个批次数据的生成器
        :param data: 训练数据或验证数据
        :return: 一个批次的训练数据或验证数据的生成器
        """
        # 以每个批次的间隔遍历数据集

        for batch in range(0, len(data), batch_size):
            # 定义batch数据的张量列表

            batch_encoded = []
            batch_labels = []
            # 将一个bitch_size大小的数据转换成列表形式, 并进行逐条遍历

            for item in shuffle(data.values.tolist())[batch: batch+batch_size]:
                # 使用bert中文模型进行编码

                encoded = get_bert_encode(item[0])
                # 将编码后的每条数据装进预先定义好的列表中

                batch_encoded.append(encoded)
                # 同样将对应的该batch的标签装进labels列表中

                batch_labels.append([int(item[1])])
            # 使用reduce高阶函数将列表中的数据转换成模型需要的张量形式

            # encoded的形状是(batch_size*max_len, embedding_size)

            encoded = reduce(lambda x, y: torch.cat((x, y), dim=0), batch_encoded)
            labels = torch.tensor(reduce(lambda x, y: x + y, batch_labels))
            # 以生成器的方式返回数据和标签

            yield (encoded, labels)

    # 对训练集和验证集分别使用_loader_generator函数, 返回对应的生成器

    # 最后还要返回训练集和验证集的样本数量

    return _loader_generator(train_data), _loader_generator(valid_data), len(train_data), len(valid_data)

if __name__ == "__main__":
    train_data_path = "./cn_data/SST-2/train.tsv"
    valid_data_path = "./cn_data/SST-2/dev.tsv"
    batch_size = 16
    train_data_labels, valid_data_labels, \
    train_data_len, valid_data_len = data_loader(train_data_path, valid_data_path, batch_size)
    print(next(train_data_labels))
    print(next(valid_data_labels))
    print("train_data_len:", train_data_len)
    print("valid_data_len:", valid_data_len)

编写训练和验证函数:

import torch.optim as optim

def train(train_data_labels):
    """
    description: 训练函数, 在这个过程中将更新模型参数, 并收集准确率和损失
    :param train_data_labels: 训练数据和标签的生成器对象
    :return: 整个训练过程的平均损失之和以及正确标签的累加数
    """

    # 定义训练过程的初始损失和准确率累加数

    train_running_loss = 0.0
    train_running_acc = 0.0
    # 循环遍历训练数据和标签生成器, 每个批次更新一次模型参数

    for train_tensor, train_labels in train_data_labels:
        # 初始化该批次的优化器

        optimizer.zero_grad()
        # 使用微调网络获得输出

        train_outputs = net(train_tensor)
        # 得到该批次下的平均损失

        train_loss = criterion(train_outputs, train_labels)
        # 将该批次的平均损失加到train_running_loss中

        train_running_loss += train_loss.item()
        # 损失反向传播

        train_loss.backward()
        # 优化器更新模型参数

        optimizer.step()
        # 将该批次中正确的标签数量进行累加, 以便之后计算准确率

        train_running_acc += (train_outputs.argmax(1) == train_labels).sum().item()
    return train_running_loss, train_running_acc


def valid(valid_data_labels):
    """
    description: 验证函数, 在这个过程中将验证模型的在新数据集上的标签, 收集损失和准确率
    :param valid_data_labels: 验证数据和标签的生成器对象
    :return: 整个验证过程的平均损失之和以及正确标签的累加数
    """

    # 定义训练过程的初始损失和准确率累加数

    valid_running_loss = 0.0
    valid_running_acc = 0.0
    # 循环遍历验证数据和标签生成器

    for valid_tensor, valid_labels in valid_data_labels:
        # 不自动更新梯度

        with torch.no_grad():
            # 使用微调网络获得输出

            valid_outputs = net(valid_tensor)
            # 得到该批次下的平均损失

            valid_loss = criterion(valid_outputs, valid_labels)
            # 将该批次的平均损失加到valid_running_loss中

            valid_running_loss += valid_loss.item()
            # 将该批次中正确的标签数量进行累加, 以便之后计算准确率

            valid_running_acc += (valid_outputs.argmax(1) == valid_labels).sum().item()
    return valid_running_loss,  valid_running_acc

调用并保存模型:

if __name__ == "__main__":
    # 设定数据路径

    train_data_path = "./cn_data/SST-2/train.tsv"
    valid_data_path = "./cn_data/SST-2/dev.tsv"
    # 定义交叉熵损失函数

    criterion = nn.CrossEntropyLoss()
    # 定义SGD优化方法

    optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)
    # 定义训练轮数

    epochs = 4
    # 定义批次样本数量

    batch_size = 16
    # 进行指定轮次的训练

    for epoch in range(epochs):
        # 打印轮次

        print("Epoch:", epoch + 1)
        # 通过数据加载器获得训练数据和验证数据生成器, 以及对应的样本数量

        train_data_labels, valid_data_labels, train_data_len, \
        valid_data_len = data_loader(train_data_path, valid_data_path, batch_size)
        # 调用训练函数进行训练

        train_running_loss, train_running_acc = train(train_data_labels)
        # 调用验证函数进行验证

        valid_running_loss, valid_running_acc = valid(valid_data_labels)
        # 计算每一轮的平均损失, train_running_loss和valid_running_loss是每个批次的平均损失之和

        # 因此将它们乘以batch_size就得到了该轮的总损失, 除以样本数即该轮次的平均损失

        train_average_loss = train_running_loss * batch_size / train_data_len
        valid_average_loss = valid_running_loss * batch_size / valid_data_len

        # train_running_acc和valid_running_acc是每个批次的正确标签累加和,

        # 因此只需除以对应样本总数即是该轮次的准确率

        train_average_acc = train_running_acc /  train_data_len
        valid_average_acc = valid_running_acc / valid_data_len
        # 打印该轮次下的训练损失和准确率以及验证损失和准确率

        print("Train Loss:", train_average_loss, "|", "Train Acc:", train_average_acc)
        print("Valid Loss:", valid_average_loss, "|", "Valid Acc:", valid_average_acc)

    print('Finished Training')

    # 保存路径

    MODEL_PATH = './BERT_net.pth'
    # 保存模型参数

    torch.save(net.state_dict(), MODEL_PATH) 
    print('Finished Saving')    

if __name__ == "__main__":
    MODEL_PATH = './BERT_net.pth'
    # 加载模型参数

    net.load_state_dict(torch.load(MODEL_PATH))

    # text = "酒店设备一般,套房里卧室的不能上网,要到客厅去。"

    text = "房间应该超出30平米,是HK同级酒店中少有的大;重装之后,设备也不错."
    print("输入文本为:", text)
    with torch.no_grad():
        output = net(get_bert_encode(text))
        # 从output中取出最大值对应的索引
        
        print("预测标签为:", torch.argmax(output).item())