•         使用医疗领域预训练模型ERNIE-Health进行Fine-tune完成中文医疗文本分类
  •         通过该案例掌握PaddleNLP的Transformer 、Tokenizer、Dataset 等API 的使用
  •         熟悉PaddleNLP的数据处理流程

        本案例基于CBLUE数据集, 介绍如下(摘自PaddleNLP):

        中文医学语言理解测评(Chinese Biomedical Language Understanding Evaluation,CBLUE)1.0 版本数据集,这是国内首个面向中文医疗文本处理的多任务榜单,涵盖了医学文本信息抽取(实体识别、关系抽取)、医学术语归一化、医学文本分类、医学句子关系判定和医学问答共5大类任务8个子任务。其数据来源分布广泛,包括医学教材、电子病历、临床试验公示以及互联网用户真实查询等。该榜单一经推出便受到了学界和业界的广泛关注,已逐渐发展成为检验AI系统中文医疗信息处理能力的“金标准”。

  • CMeEE:中文医学命名实体识别
  • CMeIE:中文医学文本实体关系抽取
  • CHIP-CDN:临床术语标准化任务
  • CHIP-CTC:临床试验筛选标准短文本分类
  • CHIP-STS:平安医疗科技疾病问答迁移学习
  • KUAKE-QIC:医疗搜索检索词意图分类
  • KUAKE-QTR:医疗搜索查询词-页面标题相关性
  • KUAKE-QQR:医疗搜索查询词-查询词相关性

        更多关于CBLUE数据集的介绍可前往CBLUE官方网站学习~

        本次案例将学习的是CBLUE数据集中的CHIP-CDN任务。对于临床术语标准化任务(CHIP-CDN),我们按照 ERNIE-Health 中的方法通过检索将原多分类任务转换为了二分类任务,即给定一诊断原词和一诊断标准词,要求判定后者是否是前者对应的诊断标准词。本项目提供了检索处理后的 CHIP-CDN 数据集(简写CHIP-CDN-2C),且构建了基于该数据集的example代码。下面就通过代码来开启paddlenlp的学习之旅吧!

         本次学习的是医疗文本分类的脚本,我将代码抽象成了以下几块,挑重点去学习。

  1. 导包
  2. 定义指标类别
  3. 添加命令行参数
  4. 设置随机种子
  5. 定义评估方法
  6. 定义训练方法
  7. 定义主函数

         这里最重要的就是5步也就是训练方法,下面具体看看详细的代码。

训练方法

def do_train():
    paddle.set_device(args.device)
    rank = paddle.distributed.get_rank()
    if paddle.distributed.get_world_size() > 1:
        paddle.distributed.init_parallel_env()

    set_seed(args.seed)

    train_ds, dev_ds = load_dataset('cblue',
                                    args.dataset,
                                    splits=['train', 'dev'])

    model = ElectraForSequenceClassification.from_pretrained(
        'ernie-health-chinese',
        num_classes=len(train_ds.label_list),
        activation='tanh')
    tokenizer = ElectraTokenizer.from_pretrained('ernie-health-chinese')

    trans_func = partial(convert_example,
                         tokenizer=tokenizer,
                         max_seq_length=args.max_seq_length)
    batchify_fn = lambda samples, fn=Tuple(
        Pad(axis=0, pad_val=tokenizer.pad_token_id, dtype='int64'),  # input
        Pad(axis=0, pad_val=tokenizer.pad_token_type_id, dtype='int64'
            ),  # segment
        Pad(axis=0, pad_val=args.max_seq_length - 1, dtype='int64'),  # position
        Stack(dtype='int64')): [data for data in fn(samples)]
    train_data_loader = create_dataloader(train_ds,
                                          mode='train',
                                          batch_size=args.batch_size,
                                          batchify_fn=batchify_fn,
                                          trans_fn=trans_func)
    dev_data_loader = create_dataloader(dev_ds,
                                        mode='dev',
                                        batch_size=args.batch_size,
                                        batchify_fn=batchify_fn,
                                        trans_fn=trans_func)

    if args.init_from_ckpt and os.path.isfile(args.init_from_ckpt):
        state_dict = paddle.load(args.init_from_ckpt)
        state_keys = {
            x: x.replace('discriminator.', '')
            for x in state_dict.keys() if 'discriminator.' in x
        }
        if len(state_keys) > 0:
            state_dict = {
                state_keys[k]: state_dict[k]
                for k in state_keys.keys()
            }
        model.set_dict(state_dict)
    if paddle.distributed.get_world_size() > 1:
        model = paddle.DataParallel(model)

    num_training_steps = args.max_steps if args.max_steps > 0 else len(
        train_data_loader) * args.epochs
    args.epochs = (num_training_steps - 1) // len(train_data_loader) + 1

    lr_scheduler = LinearDecayWithWarmup(args.learning_rate, num_training_steps,
                                         args.warmup_proportion)

    # Generate parameter names needed to perform weight decay.
    # All bias and LayerNorm parameters are excluded.
    decay_params = [
        p.name for n, p in model.named_parameters()
        if not any(nd in n for nd in ['bias', 'norm'])
    ]

    optimizer = paddle.optimizer.AdamW(
        learning_rate=lr_scheduler,
        parameters=model.parameters(),
        weight_decay=args.weight_decay,
        apply_decay_param_fun=lambda x: x in decay_params)

    criterion = paddle.nn.loss.CrossEntropyLoss()
    if METRIC_CLASSES[args.dataset] is Accuracy:
        metric = METRIC_CLASSES[args.dataset]()
        metric_name = 'accuracy'
    elif METRIC_CLASSES[args.dataset] is MultiLabelsMetric:
        metric = METRIC_CLASSES[args.dataset](
            num_labels=len(train_ds.label_list))
        metric_name = 'macro f1'
    else:
        metric = METRIC_CLASSES[args.dataset]()
        metric_name = 'micro f1'
    if args.use_amp:
        scaler = paddle.amp.GradScaler(init_loss_scaling=args.scale_loss)
    global_step = 0
    tic_train = time.time()
    total_train_time = 0
    for epoch in range(1, args.epochs + 1):
        for step, batch in enumerate(train_data_loader, start=1):
            input_ids, token_type_ids, position_ids, labels = batch
            with paddle.amp.auto_cast(
                    args.use_amp,
                    custom_white_list=['layer_norm', 'softmax', 'gelu', 'tanh'],
            ):
                logits = model(input_ids, token_type_ids, position_ids)
                loss = criterion(logits, labels)
            probs = F.softmax(logits, axis=1)
            correct = metric.compute(probs, labels)
            metric.update(correct)

            if isinstance(metric, Accuracy):
                result = metric.accumulate()
            elif isinstance(metric, MultiLabelsMetric):
                _, _, result = metric.accumulate('macro')
            else:
                _, _, _, result, _ = metric.accumulate()

            if args.use_amp:
                scaler.scale(loss).backward()
                scaler.minimize(optimizer, loss)
            else:
                loss.backward()
                optimizer.step()
            lr_scheduler.step()
            optimizer.clear_grad()

            global_step += 1
            if global_step % args.logging_steps == 0 and rank == 0:
                time_diff = time.time() - tic_train
                total_train_time += time_diff
                print(
                    'global step %d, epoch: %d, batch: %d, loss: %.5f, %s: %.5f, speed: %.2f step/s'
                    % (global_step, epoch, step, loss, metric_name, result,
                       args.logging_steps / time_diff))

            if global_step % args.valid_steps == 0 and rank == 0:
                evaluate(model, criterion, metric, dev_data_loader)

            if global_step % args.save_steps == 0 and rank == 0:
                save_dir = os.path.join(args.save_dir, 'model_%d' % global_step)
                if not os.path.exists(save_dir):
                    os.makedirs(save_dir)
                if paddle.distributed.get_world_size() > 1:
                    model._layers.save_pretrained(save_dir)
                else:
                    model.save_pretrained(save_dir)
                tokenizer.save_pretrained(save_dir)

            if global_step >= num_training_steps:
                return
            tic_train = time.time()

    if rank == 0 and total_train_time > 0:
        print('Speed: %.2f steps/s' % (global_step / total_train_time))

do_train方法中比较重要的部分有:

  • 加载数据集load_dataset方法
  • 创建数据加载器create_dataloader方法
  • 加载模型ElectraForSequenceClassification.from_pretrained方法
  • 加载分词器ElectraTokenizer.from_pretrained方法

其实和pytorch大同小异,其他一些地方就不说了,主要学习一下这几个接口的使用。

load_dataset()

目前PaddleNLP内置20余个NLP数据集,涵盖阅读理解,文本分类,序列标注,机器翻译等多项任务。目前提供的数据集可以在 数据集列表 中找到。

以加载msra_ner数据集为例:

from paddlenlp.datasets import load_dataset
train_ds, test_ds = load_dataset("msra_ner", splits=("train", "test"))

load_dataset() 方法会从 paddlenlp.datasets 下找到msra_ner数据集对应的数据读取脚本(默认路径:paddlenlp/datasets/msra_ner.py),并调用脚本中 DatasetBuilder 类的相关方法生成数据集。

生成数据集可以以 MapDataset 和 IterDataset 两种类型返回,分别是对 paddle.io.Dataset 和 paddle.io.IterableDataset 的扩展,只需在 load_dataset() 时设置 lazy 参数即可获取相应类型。Flase 对应返回 MapDataset ,True 对应返回 IterDataset,默认值为None,对应返回 DatasetBuilder 默认的数据集类型,大多数为 MapDataset 。

PaddleNLP学习日记(一)CBLUE医疗文本分类-LMLPHP

关于 MapDataset 和 IterDataset 功能和异同可以参考API文档 datasets

在此文本分类案例中,加载的是cblue中的子数据集,load_dataset()中提供了一个name参数用来指定想要获取的子数据集。

do_train方法中的加载数据集的时候把name参数省略了,但实际上还是用name实现获取子数据集的。

 train_ds, dev_ds = load_dataset('cblue',
                                    args.dataset,
                                    splits=['train', 'dev'])

当然也可以加载自定义数据集,想更深入了解请前往加载内置数据集 食用~

create_dataloader()

该方法包含在utils.py中,具体代码如下:

def create_dataloader(dataset,
                      mode='train',
                      batch_size=1,
                      batchify_fn=None,
                      trans_fn=None):
    if trans_fn:
        dataset = dataset.map(trans_fn)

    shuffle = True if mode == 'train' else False
    if mode == 'train':
        batch_sampler = paddle.io.DistributedBatchSampler(dataset,
                                                          batch_size=batch_size,
                                                          shuffle=shuffle)
    else:
        batch_sampler = paddle.io.BatchSampler(dataset,
                                               batch_size=batch_size,
                                               shuffle=shuffle)

    return paddle.io.DataLoader(dataset=dataset,
                                batch_sampler=batch_sampler,
                                collate_fn=batchify_fn,
                                return_list=True)

可以看出它最后返回的时候还是调用的DataLoader方法,前面一些代码主要是根据传进来的参数对数据集dataset和取样器batch_sampler做了一些变化/选择。

OK,那么PaddlePaddle中DataLoader是啥样的呢?往下看!

只说create_dataloader方法中的DataLoader用的到几个参数吧,也就是这几个:

paddle.io.DataLoader(dataset=dataset,
                     batch_sampler=batch_sampler,
                     collate_fn=batchify_fn,
                     return_list=True)

DataLoader定义:

        DataLoader返回一个迭代器,该迭代器根据 batch_sampler 给定的顺序迭代一次给定的 dataset。

dataset参数:

        DataLoader当前支持 map-style 和 iterable-style 的数据集, map-style 的数据集可通过下标索引样本,请参考 paddle.io.Dataset ; iterable-style 数据集只能迭代式地获取样本,类似Python迭代器,请参考 paddle.io.IterableDataset 。这一点和上面的load_dataset()方法对应起来了,通过load_dataset()加载进来的数据集也只有两种类型—— MapDataset 和 IterDataset 两种类型。所以对于dataset参数只需要选择他是用map还是iter类型的就可以了,代码中的trans_fn应该就是做这个事的。

PaddleNLP学习日记(一)CBLUE医疗文本分类-LMLPHP

batch_sampler参数:

        批采样器的基础实现,用于 paddle.io.DataLoader 中迭代式获取mini-batch的样本下标数组,数组长度与 batch_size 一致。

        所有用于 paddle.io.DataLoader 中的批采样器都必须是 paddle.io.BatchSampler 的子类并实现以下方法:

__iter__: 迭代式返回批样本下标数组。

__len__: 每epoch中mini-batch数。

参数包含:

  • dataset (Dataset) - 此参数必须是 paddle.io.Dataset 或 paddle.io.IterableDataset 的一个子类实例或实现了 __len__ 的Python对象,用于生成样本下标。默认值为None。

  • sampler (Sampler) - 此参数必须是 paddle.io.Sampler 的子类实例,用于迭代式获取样本下标。dataset 和 sampler 参数只能设置一个。默认值为None。

  • shuffle (bool) - 是否需要在生成样本下标时打乱顺序。默认值为False。

  • batch_size (int) - 每mini-batch中包含的样本数。默认值为1。

  • drop_last (bool) - 是否需要丢弃最后无法凑整一个mini-batch的样本。默认值为False。

        在create_dataloader中的paddle.io.BatchSampler和paddle.io.DistributedBatchSampler中只用到了三个参数——dataset、batch_size、shuffle,得到实例batch_sampler,包含了样本下标数组的迭代器,然后将它传入DataLoader中去作为采样器。

参考paddle.io.BatchSampler

collate_fn参数:

        用过pytorch加载数据集的都应该知道这个参数的作用,就是传入一个函数名,用来将一批中的数据集对齐成相同长度并转成tensor类型数据或对每一批数据做一些其他操作的。

在此案例代码中他是这样使用的:

batchify_fn = lambda samples, fn=Tuple(
        Pad(axis=0, pad_val=tokenizer.pad_token_id, dtype='int64'),  # input
        Pad(axis=0, pad_val=tokenizer.pad_token_type_id, dtype='int64'),  # segment
        Pad(axis=0, pad_val=args.max_seq_length - 1, dtype='int64'),  # position
        Stack(dtype='int64')): [data for data in fn(samples)]

         用了一个lambda表达式,对于每一个batch的samples和fn,调用一次

[data for data in fn(samples)]

        其中fn=Tuple(3个Pad、1个Stack),3个pad分别代表input、segment、position用当前批次最大句子长度进行填充,1个Stack代表将当前批次的label顺序堆叠。

        不得不说,这种写法很优雅,很装杯,学到了哈哈哈哈~ 

ElectraForSequenceClassification.from_pretrained()

        PaddleNLP加载预训练模型的方式和pytorch差不多,也是用from_pretrained(),只需要往里面传一个模型实例即可。根据官方文档找到对应的模型直接加载就行了。Electra 模型在输出层的顶部有一个线性层,用于序列分类/回归任务,如 GLUE 任务。

ElectraTokenizer.from_pretrained()

        与ElectraForSequenceClassification.from_pretrained()配套的Tokenizer,也同pytorch一致,传入路径加载即可。

         最后来总结一下使用paddlenlp完成医疗文本分类的流程,详细代码请移步医疗文本分类~

  1. 导包:参考github代码
  2. 定义指标类别:对于不同的子数据集及任务,使用不同的指标如Accuracy、MultiLabelsMetric、AccuracyAndF1。
  3. 添加命令行参数:主要用于接受用户从控制台输入的参数。
  4. 设置随机种子:用于复现训练和测试结果,方便后续进行调试。
  5. 定义评估方法:传入model、数据加载器、评价指标和损失函数,得到数据集对应的指标。
  6. 定义训练方法:指定分布式设置、加载数据集、分词器和模型、使用partial将已经提前得到的tokenizer和max_seq_length先传到convert_example中、定义批量处理方法batchify_fn并使用create_dataloader定义数据加载器、加载预训练模型的checkpoint、定义步数和衰减率等参数、定义优化器、定义损失函数、定义评价指标、开始训练(使用自动混合精度)
  7. 定义主函数:运行训练方法。

        大功告成!主要学习了一下如何用PaddleNLP进行医疗文本分类,get了比较关键的几个api的使用,总结了整体处理流程,官方文档查阅能力+1~

11-03 08:50