微调transformers进行BERT垃圾邮件分类


微调transformers进行BERT垃圾邮件分类

现在我们使用Hugging Face的transformers包提供的预训练模型,在我们之前做的垃圾邮件分类任务上做一个微调,为将来我们的具体任务做准备。

本文主要参考这篇英文博客,并结合我们的数据集完成。

本文使用PyTorch深度学习框架,在GTX 1080ti显卡上运行。

本文跟上一篇的主要区别在于使用的是我们自定义的数据集,而不是Hugging Face封装好的数据集。

数据集读入与停用词去除

首先还是读入数据,这部分与之前用BOW等方法的时候基本一样。首先是读入index文件作为索引:

import pandas as pd
import codecs
import re
import jieba

index = pd.read_csv('index', sep=' ', names=['spam', 'path']) # 读入是两列,一列spam 一列path
index.spam = index.spam.apply(lambda x: 1 if x == 'spam' else 0) # 数据标签,标签为spam的设置为1 否则设置为0
index.path = index.path.apply(lambda x: x[1:]) # 更改文件路径

spam列是标签列,若值为spam则为垃圾邮件;path列是邮件文件存储的路径列,可以直接读取。这是一个二分类任务。

然后是按照路径读入邮件内容:

def get_mail_content(path):
    """
    遍历得到每封邮件的词汇字符串
    :param path: 邮件路径
    :return:(Str)content
    """
    with codecs.open(path, "r", encoding="gbk", errors="ignore") as f: # 以gbk编码打开文件,忽略错误
        lines = f.readlines() # 读入所有行,得到一个包含多个字符串的list,一行是一个字符串

    for i in range(len(lines)):
        if lines[i] == '\n':
            # 去除第一个空行,即在第一个空行之前的邮件协议内容全部舍弃
            lines = lines[i:]
            break
    content = ''.join(''.join(lines).strip().split()) 
    # strip()是丢弃换行符,split()是按照空格分隔开(起到剔除多个空格只留下一个空格的作用),然后再join在一起
    # print(content)
    return content

index['content'] = index.path.apply(lambda x: get_mail_content(x)) # 对content列使用函数,得到邮件内容

定义get_mail_content()函数,第一个换行符之前的内容为英文内容,直接丢弃;中文内容按行读入并整合,写入新建的content列。

现在我们载入数据集提供的停用词列表,然后从content列中去除停用词:

def load_stop_word():
    """
    读出停用词列表
    :return: (List)_stop_words
    """
    with codecs.open("stop", "r") as f: # 载入停用词列表
        lines = f.readlines() # 读入所有行,得到一个包含多个字符串的list,一行是一个字符串
    _stop_words = [i.strip() for i in lines] # 舍弃掉所有换行符
    return _stop_words

stop_words = load_stop_word()
def create_word_dict(content, stop_words_list):
    """
    依据邮件的词汇字符串统计词汇出现记录,依据停止词列表除去某些词语
    :param content: 邮件的词汇字符串
    :param stop_words_list:停止词列表
    :return:(Dict)word_dict
    """
    word_list = []
    word_dict = {}
    # word_dict key:word, value:1
    content = re.findall(u"[\u4e00-\u9fa5]", content) 
    # 正则表达式,只保留汉字,其他字符全部剔除,这句得到的是含有多个字符串的list,其中一个汉字一个字符串
    content = ''.join(content) # 拼起来
    word_list_temp = jieba.cut(content) # 使用jieba进行分词
    for word in word_list_temp:
        if word != '' and word not in stop_words_list: # 去掉空词和停止词
            word_list.append(word)
    return ''.join(word_list)

index['content'] = index.content.apply(lambda x: create_word_dict(x, stop_words)) # 得到新的列content,去除了停用词

到这里数据读入和预处理就做完啦。现在看到的index大致是这样的:

spam path content
0 1 ./data/000/000 非财务纠淼牟莆窆芾沙盘模拟运用财务岳硖岣吖芾砑课程背景一位管理技术人员清楚懂得技术角度衡量合…
1 0 ./data/000/001 讲孔子后人故事一个领导回到家乡儿子感情贪财孙子孔为和睦领导弟弟魏宗万马车洋妞考察民俗家过年孔…
2 1 ./data/000/002 尊敬贵公司财务经理负责人您好深圳金海实业有限公司广州东莞省市分公司我司良好社会关系实力每月进…
3 1 ./data/000/003 贵公司负责人经理财务您好深圳市华龙公司受多家公司委托向外低点代开部分增值税电脑发票左右普通商…
4 1 ./data/000/004 这是一封格式信件广告网络电话包年卡元长途市话全包最快论坛邮址搜索专家最好邮件群发专家论坛短信…
64615 1 ./data/215/115 贵公司负责人经理财务您好公司深圳市华源实业有限公司公司实力雄厚全国各地分公司有着良好社会关系…
64616 1 ./data/215/116 尊敬商家朋友您好深圳市裕华实业有限公司我司实力雄厚有着良好社会关系部分外省市票据进项较多现完…
64617 1 ./data/215/117 贵公司负责人经理财务您好深圳市康特实业有限公司公司全国各地设有分公司广州东莞等市分公司全国分…
64618 1 ./data/215/118 这是一个格式邮件
64619 1 ./data/215/119 贵公司负责人经理财务您好深圳市康特实业有限公司公司全国各地设有分公司广州东莞等市分公司全国分…

文本tokenize

为了把我们的数据集喂给transformers的BERT模型,我们需要把我们的文本做tokenize映射为BERT指定的词汇表中的序号:

from transformers import BertTokenizer

# 载入Bert tokenizer.
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased', do_lower_case=True)

然后我们把index中的文本提取出来:

sentences = list(index['content'])

然后把所有index中的文本都做tokenize:

input_ids = []
attention_masks = []

for sent in sentences:
    # `encode_plus` 的步骤:
    #   (1) 分割文本;
    #   (2) 在文本开头加入 `[CLS]` 符号.
    #   (3) 在文本末尾加入 `[SEP]` 符号.
    #   (4) 把文本分割后的词序列映射为编号序列.
    #   (5) 把编号序列添加 `PAD` 符号扩展到 `max_length`.
    #   (6) 提供一个 `mask` 序列, 值为1表示对应的词不是扩充的,值为0表示对应的词是扩充的空白.
    encoded_dict = tokenizer.encode_plus(
                        sent,                      # 输入的句子.
                        add_special_tokens = True, # 添加 '[CLS]', '[SEP]'
                        max_length = 64,           # 扩展到的最大长度.
                        pad_to_max_length = True,
                        return_attention_mask = True,   # 提供mask.
                        return_tensors = 'pt',     # 返回 pytorch tensors.
                   )
    
    # 把所有编号序列整合.    
    input_ids.append(encoded_dict['input_ids'])
    
    # 所有mask整合.
    attention_masks.append(encoded_dict['attention_mask'])

这个图很简洁明了:

然后我们把得到的编号序列集和mask集,还有数据集的标签转化为PyTorch需要的形式:

input_ids = torch.cat(input_ids, dim=0)
attention_masks = torch.cat(attention_masks, dim=0)
labels = index['spam'].values
labels = torch.tensor(labels)

分割并封装数据集

我们训练模型是要用上一步得到的input_ids, attention_maskslabels的,我们对数据集做的所有处理最后得到的就是这三个变量,现在对它们做分割,分别用来训练和验证:

from torch.utils.data import TensorDataset, random_split

# 封装进TensorDataset.
dataset = TensorDataset(input_ids, attention_masks, labels)

train_size = int(0.9 * len(dataset))
val_size = len(dataset) - train_size

# 90%训练,10%验证
train_dataset, val_dataset = random_split(dataset, [train_size, val_size])

声明两个DataLoader,用于在训练的时候载入数据:

from torch.utils.data import DataLoader, RandomSampler, SequentialSampler

# 每轮训练输入的数据量,BERT作者推荐的值是16或32
batch_size = 32

# 两个DataLoaders,分别载入训练集和验证集.
train_dataloader = DataLoader(
            train_dataset,
            sampler = RandomSampler(train_dataset), # 随机选择batch训练
            batch_size = batch_size
        )

validation_dataloader = DataLoader(
            val_dataset,
            sampler = SequentialSampler(val_dataset), # 有序选择batch测试
            batch_size = batch_size
        )

准备训练

现在打开显卡:

if torch.cuda.is_available():    
    # Tell PyTorch to use the GPU.    
    device = torch.device("cuda")
    print('There are %d GPU(s) available.' % torch.cuda.device_count())
    print('We will use the GPU:', torch.cuda.get_device_name(0))
# If not...
else:
    print('No GPU available, using the CPU instead.')
    device = torch.device("cpu")

这里的输出是:

There are 1 GPU(s) available.
We will use the GPU: NVIDIA GeForce GTX 1080 Ti

我们来载入BERT文本分类模型:

from transformers import BertForSequenceClassification, AdamW, BertConfig

# 这是一个12层的模型,最后一层输出是标签
model = BertForSequenceClassification.from_pretrained(
    "bert-base-uncased",
    num_labels = 2, # 二分类任务
    output_attentions = False, # 不返回 attentions weights.
    output_hidden_states = False, # 不返回所有的 hidden-states.
)

# 告诉PyTorch这个模型要在GPU上运行
model.cuda()

再来设置一个优化器:

optimizer = AdamW(model.parameters(),
                  lr = 2e-5, # 学习率,网络参数的更新系数
                  eps = 1e-8 # 如果新旧lr之间的差异小于eps,则忽略此次更新
                )

再来设置一个学习率调整器,用于让学习率在warm-up(即在num_warmup_steps后升高至optimizer中的预定值)阶段后,然后在total_steps次训练后线性递减至0:

from transformers import get_linear_schedule_with_warmup

# 微调的训练轮次数,BERT作者建议是2~4轮
epochs = 4

#训练步骤数 [number of batches] x [number of epochs]. 
total_steps = len(train_dataloader) * epochs

# 学习率调整器
scheduler = get_linear_schedule_with_warmup(optimizer, 
                                            num_warmup_steps = 0,
                                            num_training_steps = total_steps)

定义一个函数用于计算分类准确率,一个函数用于计算时间消耗:

import numpy as np
import time
import datetime

def flat_accuracy(preds, labels):
    pred_flat = np.argmax(preds, axis=1).flatten()
    labels_flat = labels.flatten()
    return np.sum(pred_flat == labels_flat) / len(labels_flat)

def format_time(elapsed):
    # 取近似值
    elapsed_rounded = int(round((elapsed)))
    # 转化为时分秒
    return str(datetime.timedelta(seconds=elapsed_rounded))

微调训练

现在一切准备就绪,终于可以开始微调模型啦:

import random
import numpy as np

# 固定随机数种子,使得实验结果可重复
seed_val = 42

random.seed(seed_val)
np.random.seed(seed_val)
torch.manual_seed(seed_val)
torch.cuda.manual_seed_all(seed_val)

# 存储训练状态
training_stats = []

# 代码开始运行的时间
total_t0 = time.time()

# 每一轮训练:
for epoch_i in range(0, epochs):
    
    # ========================================
    #               训练过程
    # ========================================
    
    # Perform one full pass over the training set.

    print("")
    print('======== Epoch {:} / {:} ========'.format(epoch_i + 1, epochs))
    print('Training...')

    # 当前时间
    t0 = time.time()

    # 本轮训练loss
    total_train_loss = 0

    # 把BERT模型设置为训练状态
    model.train()

    # 每个Batch的数据:
    for step, batch in enumerate(train_dataloader):

        # 设置每40个batch输出一次log
        if step % 40 == 0 and not step == 0:
            # 时间消耗
            elapsed = format_time(time.time() - t0)
            
            # 训练进程
            print('  Batch {:>5,}  of  {:>5,}.    Elapsed: {:}.'.format(step, len(train_dataloader), elapsed))

        # 把当前batch的数据上传到GPU
        b_input_ids = batch[0].to(device)
        b_input_mask = batch[1].to(device)
        b_labels = batch[2].to(device)

        # 清除之前的梯度,准备下一次训练
        model.zero_grad()        

        # 得到一个输出和loss
        output = model(b_input_ids, token_type_ids=None, attention_mask=b_input_mask, labels=b_labels)
        loss = output.loss
        logits = output.logits

        # 累积loss
        total_train_loss += loss.item()

        # 前向传递参数
        loss.backward()

        # 梯度裁剪,防止梯度爆炸,将梯度约束在某一个区间之内,在训练的过程中,在优化器更新之前进行梯度截断操作
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)

        # 更新参数
        optimizer.step()

        # 更新学习率
        scheduler.step()

    # 计算所有batch的平均loss
    avg_train_loss = total_train_loss / len(train_dataloader)            
    
    # 计算本轮训练花费的时间
    training_time = format_time(time.time() - t0)

    print("")
    print("  Average training loss: {0:.2f}".format(avg_train_loss))
    print("  Training epcoh took: {:}".format(training_time))
        
    # ========================================
    #               验证
    # ========================================

    print("")
    print("Running Validation...")

    t0 = time.time()

    # 把模型设置为验证模式
    model.eval()

    total_eval_accuracy = 0
    total_eval_loss = 0
    nb_eval_steps = 0

    # 逐batch验证
    for batch in validation_dataloader:
        
        b_input_ids = batch[0].to(device)
        b_input_mask = batch[1].to(device)
        b_labels = batch[2].to(device)
        
        # 设置模型无需不跟踪梯度,跟踪梯度是训练的时候要做的
        with torch.no_grad():        

            # logits是模型输出结果
            output = model(b_input_ids, token_type_ids=None, attention_mask=b_input_mask, labels=b_labels)
            loss = output.loss
            logits = output.logits
            
        # 累计loss
        total_eval_loss += loss.item()

        # 从GPU转移到CPU
        logits = logits.detach().cpu().numpy()
        label_ids = b_labels.to('cpu').numpy()

        # 累计准确率
        total_eval_accuracy += flat_accuracy(logits, label_ids)
        

    # 平均准确率
    avg_val_accuracy = total_eval_accuracy / len(validation_dataloader)
    print("  Accuracy: {0:.2f}".format(avg_val_accuracy))

    # 平均loss
    avg_val_loss = total_eval_loss / len(validation_dataloader)
    
    # 验证模型过程的时间消耗
    validation_time = format_time(time.time() - t0)
    
    print("  Validation Loss: {0:.2f}".format(avg_val_loss))
    print("  Validation took: {:}".format(validation_time))

    # 记录起来
    training_stats.append(
        {
            'epoch': epoch_i + 1,
            'Training Loss': avg_train_loss,
            'Valid. Loss': avg_val_loss,
            'Valid. Accur.': avg_val_accuracy,
            'Training Time': training_time,
            'Validation Time': validation_time
        }
    )

print("")
print("Training complete!")

print("Total training took {:} (h:mm:ss)".format(format_time(time.time()-total_t0)))

训练花费的时间并不长,很快就可以训练完毕。经过验证,分类准确率在97%以上。虽然看起来没比之前的机器学习模型高,但我们的数据集毕竟是个简单任务呢。而且BERT预训练模型最时髦呀~~~

Running Validation…
Accuracy: 0.97
Validation Loss: 0.12
Validation took: 0:00:13

Training complete!
Total training took 0:26:04 (h:mm:ss)

max_length的大小我咨询了专业人士,表示一般64足够了,一般不超过200。其他的我暂时感觉没什么问题了。

更换中文预训练模型

更换其他预训练模型的方法很简单,在Hugging Face/Models里搜索Chinese,或者筛选语言zh,找个模型,把名字复制过来,替换掉上面代码中的所有bert-base-uncased即可。

我这里更换的是中文模型里面下载量最大的hfl/chinese-roberta-wwm-ext,这个模型是哈工大在维基百科的简体中文和繁体中文内容上进行学习和训练的,文本数量为13.6M行。哈工大NLP做的是很好的。经过咨询专业人士,每行字符数量不超过512,所以预训练数据集的规模是很大的。

进行1轮微调,准确率就已经达到了99%;经过10轮微调,准确率已经达到100%了。很优秀。

而且BERT有个好处,就是不需要自定义词典,因为token是按照字做的而不是按照词做的。

Running Validation…
Accuracy: 1.00
Validation Loss: 0.04
Validation took: 0:00:13

Training complete!
Total training took 1:04:12 (h:mm:ss)

这节课就是这样啦,怎么样,是不是很好玩😆😆😆


评论
  目录