Shortcuts

(测试版) BERT上的动态量化

创建于:2019年12月06日 | 最后更新:2024年8月29日 | 最后验证:2024年11月05日

提示

为了充分利用本教程,我们建议使用这个 Colab 版本。这将允许您尝试下面提供的信息。

作者: Jianyu Huang

审核人: Raghuraman Krishnamoorthi

编辑者: Jessica Lin

介绍

在本教程中,我们将对BERT模型应用动态量化,紧密跟随HuggingFace Transformers示例中的BERT模型。通过这个逐步的旅程,我们希望展示如何将像BERT这样的知名最先进模型转换为动态量化模型。

  • BERT,即来自Transformers的双向嵌入表示,是一种新的预训练语言表示方法,它在许多流行的自然语言处理(NLP)任务中实现了最先进的准确率结果,例如问答、文本分类等。原始论文可以在这里找到here

  • PyTorch中的动态量化支持将浮点模型转换为量化模型,其中权重使用静态int8或float16数据类型,激活使用动态量化。当权重量化为int8时,激活会动态地(每批次)量化为int8。在PyTorch中,我们有torch.quantization.quantize_dynamic API,它用动态仅权重量化版本替换指定的模块,并输出量化模型。

  • 我们在通用语言理解评估基准(GLUE)中的微软研究释义语料库(MRPC)任务上展示了准确性和推理性能结果。MRPC(Dolan和Brockett,2005)是一个从在线新闻源自动提取的句子对语料库,并有人工标注的句子对是否在语义上等价。由于类别不平衡(68%正例,32%负例),我们遵循常见做法并报告F1分数。 MRPC是语言对分类的常见NLP任务,如下所示。

../_images/bert.png

1. 设置

1.1 安装 PyTorch 和 HuggingFace Transformers

要开始本教程,我们首先按照PyTorch 这里和HuggingFace Github仓库 这里的安装说明进行操作。此外,我们还安装了scikit-learn包,因为我们将重用其内置的F1分数计算辅助函数。

pip install sklearn
pip install transformers==4.29.2

因为我们将使用PyTorch的测试版部分,建议安装最新版本的torch和torchvision。您可以在这里找到最新的本地安装说明。例如,要在Mac上安装:

yes y | pip uninstall torch torchvision
yes y | pip install --pre torch -f https://download.pytorch.org/whl/nightly/cu101/torch_nightly.html

1.2 导入必要的模块

在这一步中,我们导入了教程所需的Python模块。

import logging
import numpy as np
import os
import random
import sys
import time
import torch

from argparse import Namespace
from torch.utils.data import (DataLoader, RandomSampler, SequentialSampler,
                              TensorDataset)
from tqdm import tqdm
from transformers import (BertConfig, BertForSequenceClassification, BertTokenizer,)
from transformers import glue_compute_metrics as compute_metrics
from transformers import glue_output_modes as output_modes
from transformers import glue_processors as processors
from transformers import glue_convert_examples_to_features as convert_examples_to_features

# Setup logging
logger = logging.getLogger(__name__)
logging.basicConfig(format = '%(asctime)s - %(levelname)s - %(name)s -   %(message)s',
                    datefmt = '%m/%d/%Y %H:%M:%S',
                    level = logging.WARN)

logging.getLogger("transformers.modeling_utils").setLevel(
   logging.WARN)  # Reduce logging

print(torch.__version__)

我们设置了线程数以比较FP32和INT8性能之间的单线程性能。 在本教程的最后,用户可以通过使用正确的并行后端构建PyTorch来设置其他线程数。

torch.set_num_threads(1)
print(torch.__config__.parallel_info())

1.3 了解辅助函数

辅助函数是内置在transformers库中的。我们主要使用以下辅助函数:一个用于将文本示例转换为特征向量;另一个用于测量预测结果的F1分数。

glue_convert_examples_to_features 函数将文本转换为输入特征:

  • 对输入序列进行分词;

  • 在开头插入 [CLS];

  • 在第一句和第二句之间插入[SEP],并在最后插入[SEP];

  • 生成令牌类型ID以指示令牌属于第一个序列还是第二个序列。

glue_compute_metrics 函数具有计算指标的功能,其中包括 F1分数,它可以被解释为精确率和召回率的加权平均值, 其中F1分数的最佳值为1,最差值为0。精确率和召回率对F1分数的相对贡献是相等的。

  • F1分数的公式是:

F1=2(precisionrecall)/(precision+recall)F1 = 2 * (\text{precision} * \text{recall}) / (\text{precision} + \text{recall})

1.4 下载数据集

在运行MRPC任务之前,我们通过运行此脚本下载GLUE数据并将其解压到目录glue_data中。

python download_glue_data.py --data_dir='glue_data' --tasks='MRPC'

2. 微调BERT模型

BERT的精神是预训练语言表示,然后在广泛的任务上微调深度双向表示,使用最少的任务依赖参数,并取得最先进的结果。在本教程中,我们将重点介绍如何使用预训练的BERT模型在MRPC任务上对语义等价的句子对进行分类。

为了微调预训练的BERT模型(HuggingFace transformers中的bert-base-uncased模型)以用于MRPC任务,您可以按照examples中的命令进行操作:

export GLUE_DIR=./glue_data
export TASK_NAME=MRPC
export OUT_DIR=./$TASK_NAME/
python ./run_glue.py \
    --model_type bert \
    --model_name_or_path bert-base-uncased \
    --task_name $TASK_NAME \
    --do_train \
    --do_eval \
    --do_lower_case \
    --data_dir $GLUE_DIR/$TASK_NAME \
    --max_seq_length 128 \
    --per_gpu_eval_batch_size=8   \
    --per_gpu_train_batch_size=8   \
    --learning_rate 2e-5 \
    --num_train_epochs 3.0 \
    --save_steps 100000 \
    --output_dir $OUT_DIR

我们为MRPC任务提供了微调的BERT模型这里。 为了节省时间,您可以直接将模型文件(约400 MB)下载到本地文件夹$OUT_DIR中。

2.1 设置全局配置

在这里,我们设置了用于评估动态量化前后微调BERT模型的全局配置。

configs = Namespace()

# The output directory for the fine-tuned model, $OUT_DIR.
configs.output_dir = "./MRPC/"

# The data directory for the MRPC task in the GLUE benchmark, $GLUE_DIR/$TASK_NAME.
configs.data_dir = "./glue_data/MRPC"

# The model name or path for the pre-trained model.
configs.model_name_or_path = "bert-base-uncased"
# The maximum length of an input sequence
configs.max_seq_length = 128

# Prepare GLUE task.
configs.task_name = "MRPC".lower()
configs.processor = processors[configs.task_name]()
configs.output_mode = output_modes[configs.task_name]
configs.label_list = configs.processor.get_labels()
configs.model_type = "bert".lower()
configs.do_lower_case = True

# Set the device, batch size, topology, and caching flags.
configs.device = "cpu"
configs.per_gpu_eval_batch_size = 8
configs.n_gpu = 0
configs.local_rank = -1
configs.overwrite_cache = False


# Set random seed for reproducibility.
def set_seed(seed):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
set_seed(42)

2.2 加载微调后的BERT模型

我们从configs.output_dir加载分词器和微调后的BERT序列分类模型(FP32)。

tokenizer = BertTokenizer.from_pretrained(
    configs.output_dir, do_lower_case=configs.do_lower_case)

model = BertForSequenceClassification.from_pretrained(configs.output_dir)
model.to(configs.device)

2.3 定义分词和评估函数

我们重用了来自HuggingFace的tokenize和评估函数。

# coding=utf-8
# Copyright 2018 The Google AI Language Team Authors and The HuggingFace Inc. team.
# Copyright (c) 2018, NVIDIA CORPORATION.  All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

def evaluate(args, model, tokenizer, prefix=""):
    # Loop to handle MNLI double evaluation (matched, mis-matched)
    eval_task_names = ("mnli", "mnli-mm") if args.task_name == "mnli" else (args.task_name,)
    eval_outputs_dirs = (args.output_dir, args.output_dir + '-MM') if args.task_name == "mnli" else (args.output_dir,)

    results = {}
    for eval_task, eval_output_dir in zip(eval_task_names, eval_outputs_dirs):
        eval_dataset = load_and_cache_examples(args, eval_task, tokenizer, evaluate=True)

        if not os.path.exists(eval_output_dir) and args.local_rank in [-1, 0]:
            os.makedirs(eval_output_dir)

        args.eval_batch_size = args.per_gpu_eval_batch_size * max(1, args.n_gpu)
        # Note that DistributedSampler samples randomly
        eval_sampler = SequentialSampler(eval_dataset) if args.local_rank == -1 else DistributedSampler(eval_dataset)
        eval_dataloader = DataLoader(eval_dataset, sampler=eval_sampler, batch_size=args.eval_batch_size)

        # multi-gpu eval
        if args.n_gpu > 1:
            model = torch.nn.DataParallel(model)

        # Eval!
        logger.info("***** Running evaluation {} *****".format(prefix))
        logger.info("  Num examples = %d", len(eval_dataset))
        logger.info("  Batch size = %d", args.eval_batch_size)
        eval_loss = 0.0
        nb_eval_steps = 0
        preds = None
        out_label_ids = None
        for batch in tqdm(eval_dataloader, desc="Evaluating"):
            model.eval()
            batch = tuple(t.to(args.device) for t in batch)

            with torch.no_grad():
                inputs = {'input_ids':      batch[0],
                          'attention_mask': batch[1],
                          'labels':         batch[3]}
                if args.model_type != 'distilbert':
                    inputs['token_type_ids'] = batch[2] if args.model_type in ['bert', 'xlnet'] else None  # XLM, DistilBERT and RoBERTa don't use segment_ids
                outputs = model(**inputs)
                tmp_eval_loss, logits = outputs[:2]

                eval_loss += tmp_eval_loss.mean().item()
            nb_eval_steps += 1
            if preds is None:
                preds = logits.detach().cpu().numpy()
                out_label_ids = inputs['labels'].detach().cpu().numpy()
            else:
                preds = np.append(preds, logits.detach().cpu().numpy(), axis=0)
                out_label_ids = np.append(out_label_ids, inputs['labels'].detach().cpu().numpy(), axis=0)

        eval_loss = eval_loss / nb_eval_steps
        if args.output_mode == "classification":
            preds = np.argmax(preds, axis=1)
        elif args.output_mode == "regression":
            preds = np.squeeze(preds)
        result = compute_metrics(eval_task, preds, out_label_ids)
        results.update(result)

        output_eval_file = os.path.join(eval_output_dir, prefix, "eval_results.txt")
        with open(output_eval_file, "w") as writer:
            logger.info("***** Eval results {} *****".format(prefix))
            for key in sorted(result.keys()):
                logger.info("  %s = %s", key, str(result[key]))
                writer.write("%s = %s\n" % (key, str(result[key])))

    return results


def load_and_cache_examples(args, task, tokenizer, evaluate=False):
    if args.local_rank not in [-1, 0] and not evaluate:
        torch.distributed.barrier()  # Make sure only the first process in distributed training process the dataset, and the others will use the cache

    processor = processors[task]()
    output_mode = output_modes[task]
    # Load data features from cache or dataset file
    cached_features_file = os.path.join(args.data_dir, 'cached_{}_{}_{}_{}'.format(
        'dev' if evaluate else 'train',
        list(filter(None, args.model_name_or_path.split('/'))).pop(),
        str(args.max_seq_length),
        str(task)))
    if os.path.exists(cached_features_file) and not args.overwrite_cache:
        logger.info("Loading features from cached file %s", cached_features_file)
        features = torch.load(cached_features_file)
    else:
        logger.info("Creating features from dataset file at %s", args.data_dir)
        label_list = processor.get_labels()
        if task in ['mnli', 'mnli-mm'] and args.model_type in ['roberta']:
            # HACK(label indices are swapped in RoBERTa pretrained model)
            label_list[1], label_list[2] = label_list[2], label_list[1]
        examples = processor.get_dev_examples(args.data_dir) if evaluate else processor.get_train_examples(args.data_dir)
        features = convert_examples_to_features(examples,
                                                tokenizer,
                                                label_list=label_list,
                                                max_length=args.max_seq_length,
                                                output_mode=output_mode,
                                                pad_on_left=bool(args.model_type in ['xlnet']),                 # pad on the left for xlnet
                                                pad_token=tokenizer.convert_tokens_to_ids([tokenizer.pad_token])[0],
                                                pad_token_segment_id=4 if args.model_type in ['xlnet'] else 0,
        )
        if args.local_rank in [-1, 0]:
            logger.info("Saving features into cached file %s", cached_features_file)
            torch.save(features, cached_features_file)

    if args.local_rank == 0 and not evaluate:
        torch.distributed.barrier()  # Make sure only the first process in distributed training process the dataset, and the others will use the cache

    # Convert to Tensors and build dataset
    all_input_ids = torch.tensor([f.input_ids for f in features], dtype=torch.long)
    all_attention_mask = torch.tensor([f.attention_mask for f in features], dtype=torch.long)
    all_token_type_ids = torch.tensor([f.token_type_ids for f in features], dtype=torch.long)
    if output_mode == "classification":
        all_labels = torch.tensor([f.label for f in features], dtype=torch.long)
    elif output_mode == "regression":
        all_labels = torch.tensor([f.label for f in features], dtype=torch.float)

    dataset = TensorDataset(all_input_ids, all_attention_mask, all_token_type_ids, all_labels)
    return dataset

3. 应用动态量化

我们在模型上调用torch.quantization.quantize_dynamic来对HuggingFace BERT模型应用动态量化。具体来说,

  • 我们指定我们希望模型中的 torch.nn.Linear 模块被量化;

  • 我们指定我们希望权重被转换为量化的int8值。

quantized_model = torch.quantization.quantize_dynamic(
    model, {torch.nn.Linear}, dtype=torch.qint8
)
print(quantized_model)

3.1 检查模型大小

首先让我们检查模型的大小。我们可以观察到模型大小显著减少(FP32 总大小:438 MB;INT8 总大小:181 MB):

def print_size_of_model(model):
    torch.save(model.state_dict(), "temp.p")
    print('Size (MB):', os.path.getsize("temp.p")/1e6)
    os.remove('temp.p')

print_size_of_model(model)
print_size_of_model(quantized_model)

本教程中使用的BERT模型(bert-base-uncased)的词汇表大小V为30522。嵌入大小为768,词嵌入表的总大小为~ 4(字节/FP32)* 30522 * 768 = 90 MB。因此,借助量化,非嵌入表部分的模型大小从350 MB(FP32模型)减少到90 MB(INT8模型)。

3.2 评估推理准确性和时间

接下来,让我们比较一下原始FP32模型和动态量化后的INT8模型在推理时间和评估准确性方面的差异。

def time_model_evaluation(model, configs, tokenizer):
    eval_start_time = time.time()
    result = evaluate(configs, model, tokenizer, prefix="")
    eval_end_time = time.time()
    eval_duration_time = eval_end_time - eval_start_time
    print(result)
    print("Evaluate total time (seconds): {0:.1f}".format(eval_duration_time))

# Evaluate the original FP32 BERT model
time_model_evaluation(model, configs, tokenizer)

# Evaluate the INT8 BERT model after the dynamic quantization
time_model_evaluation(quantized_model, configs, tokenizer)

在MacBook Pro上本地运行此操作,不进行量化时,推理(针对MRPC数据集中的所有408个示例)大约需要160秒,而进行量化后仅需约90秒。我们总结了在MacBook Pro上运行量化BERT模型推理的结果如下:

| Prec | F1 score | Model Size | 1 thread | 4 threads |
| FP32 |  0.9019  |   438 MB   | 160 sec  | 85 sec    |
| INT8 |  0.902   |   181 MB   |  90 sec  | 46 sec    |

在对MRPC任务上的微调BERT模型应用训练后动态量化后,我们的F1分数准确率降低了0.6%。作为比较,在最近的一篇论文(表1)中,通过应用训练后动态量化达到了0.8788,而通过应用量化感知训练达到了0.8956。主要区别在于我们在PyTorch中支持非对称量化,而那篇论文仅支持对称量化。

请注意,在本教程中,我们将线程数设置为1以进行单线程比较。我们还支持这些量化INT8运算符的内部操作并行化。用户现在可以通过torch.set_num_threads(N)N是内部操作并行化线程的数量)设置多线程。启用内部操作并行化支持的一个初步要求是使用正确的后端(如OpenMP、Native或TBB)构建PyTorch。您可以使用torch.__config__.parallel_info()来检查并行化设置。在使用PyTorch和Native后端进行并行化的同一台MacBook Pro上,我们可以获得大约46秒的时间来处理MRPC数据集的评估。

3.3 序列化量化模型

我们可以通过跟踪模型后使用torch.jit.save来序列化并保存量化模型以供将来使用。

def ids_tensor(shape, vocab_size):
    #  Creates a random int32 tensor of the shape within the vocab size
    return torch.randint(0, vocab_size, shape=shape, dtype=torch.int, device='cpu')

input_ids = ids_tensor([8, 128], 2)
token_type_ids = ids_tensor([8, 128], 2)
attention_mask = ids_tensor([8, 128], vocab_size=2)
dummy_input = (input_ids, attention_mask, token_type_ids)
traced_model = torch.jit.trace(quantized_model, dummy_input)
torch.jit.save(traced_model, "bert_traced_eager_quant.pt")

要加载量化模型,我们可以使用 torch.jit.load

loaded_quantized_model = torch.jit.load("bert_traced_eager_quant.pt")

结论

在本教程中,我们演示了如何将像BERT这样的知名最先进的NLP模型转换为动态量化模型。动态量化可以在仅对准确性有有限影响的情况下减少模型的大小。

感谢阅读!一如既往,我们欢迎任何反馈,所以如果您有任何反馈,请在这里创建一个问题。

参考文献

[1] J.Devlin, M. Chang, K. Lee 和 K. Toutanova, BERT: 用于语言理解的深度双向变换器的预训练 (2018).

[2] HuggingFace Transformers.

[3] O. Zafrir, G. Boudoukh, P. Izsak, 和 M. Wasserblat (2019). Q8BERT: 量化8位BERT.