仰止

V1

2022/12/08阅读:13主题:默认主题

文本分类

本文基于社交媒体数据,通过文本分类,实现对广州公交满意度的分析。

数据

本文的数据来自微博,使用scrapy,通过关键词匹配,实现对指定数据的爬取。数据的内容大致如下:

本文有两组数据,一组是8000+的已标注数据;另一组是自己重新标注的900条数据,这部分数据严格把控了标注质量。

对微博类数据有一套流程化的处理方法:

1、 url处理

def clean_url(text):
    sentences = text.split(' ')
    # 处理http://类链接
    url_pattern = re.compile(r'(https|http)?:\/\/(\w|\.|\/|\?|\=|\&|\%|\-)*\b', re.S)
    # 处理无http://类链接
    domain_pattern = re.compile(r'(\b)*(.*?)\.(com|cn)')
    if len(sentences) > 0:
        result = []
        for item in sentences:
            text = re.sub(url_pattern, '', item)
            text = re.sub(domain_pattern, '', text)
            result.append(text)
        return ' '.join(result)
    else:
        return re.sub(url_pattern, '', sentences)

2、HTML代码处理

def clean_html(text):
    html_pattern = re.compile('</?\w+[^>]*>', re.S)
    text = re.sub(html_pattern, '', text)
    return text

3、【...】的处理

通过对数据分析,可以发现,括号内的文本比较重要,因此直接将【】替换成对应的标点符号,当【出现在句首时直接删除。

def char_change(text):
    """
    转换【***】为。***。的形式
    "
""
    if "【" in text:
        lst = list(text)
        s = lst.index('【')
        e = 0
        if '】' in text:
            e = lst.index('】')
        if e == 0:
            lst.remove('【')
        else:
            if s == 0:
                lst[e] = '。'
                lst.remove('【')
            else:
                lst[s] = '。'
                lst[e] = '。'
        return ''.join(lst)
    elif "】" in text:
        lst = list(text)
        lst.remove('】')
        return ''.join(lst)
    return text

4、话题(#...#)和@的处理

#...#之间的内容一般为超话的话题名,信息一般没有太大的参考价值,可以直接删除。@后面一般接的昵称,需要删除,这里通过对其后文的结束符号进行匹配,来实现对删除文本长度的控制,防止整段文本被删除的错误产生。

def clean_suffix(text):
    # 主要用来清除@和#后面的文字
    chars = "::!!??,,.。@#()()<>《》“”"";;"
    lst = list(text)
    while '@' in lst:
        idx = lst.index('@')
        i = idx + 1
        e = -1
        while i < len(lst):
            if lst[i] in chars:
                e = i - 1
                break
            if i == len(lst) - 1:
                e = i
            i += 1
        if e >= 0:
            tmp = lst[:idx][:] + lst[e + 1:][:]
            lst = tmp[:]
        else:
            break
    if len(lst) == 0:
        return text
    text = ''.join(lst)
    if '#' in text:
        pattern = re.compile('\#.*\#')
        text = re.sub(pattern, '', text)
    return text

5、清除空数据

def clean_nan(text, label):
    """
    清除空数据
    "
""
    new_text, new_label = [], []
    for i in range(len(label)):
        text[i] = text[i].strip()
        if text[i] != '':
            new_text.append(text[i])
            new_label.append(label[i])
    return new_text, new_label

模型

此处只考虑基于bert的建模,目前使用的有bert、bert-bilstm、bert-textcnn三个模型。

1、bert 使用了bert后接一个线性层。直接使用该模型直接进行预测,最后的acc在0.5附近徘徊,并且在实验时发现,模型有时候对正类的预测更准,有时候对负类的预测更准,估计是因为下游网络层的参数是随机初始化的,并没有训练,所以结果具有一定的随机性,最后的准确率维持在0.5左右也说明了这点。对于这个问题,刚开始还以为是预训练模型的通用知识更加偏向于正样本那边,后来发现重建模型后,得到的结果不一致,说明是下游参数的原因。因此,该处正负类的准确率并不表示存在偏置。

class Bert(nn.Module):
    def __init__(self,num_class,emb_size,max_length):
        super(Bert,self).__init__()
        self.max_length=max_length
        self.bert=BertModel.from_pretrained("bert-base-chinese")
        self.dropout=nn.Dropout(0.3)
        self.linear=nn.Linear(emb_size,num_class)
    
    def forward(self,inputs):
        data_token=tokenizer.batch_encode_plus(inputs,
                                               padding=True,
                                               truncation=True,
                                               max_length=self.max_length)
        
        input_ids=torch.tensor(data_token["input_ids"]).to(device)
        attention_mask=torch.tensor(data_token["attention_mask"]).to(device)
        token_type_ids=torch.tensor(data_token["token_type_ids"]).to(device)
        encode=self.bert(input_ids=input_ids,attention_mask=attention_mask,token_type_ids=token_type_ids)
        output=self.dropout(encode[0][:,0,:])
        output=self.linear(output)
        return output

2、bert-bilstm

文本分类上分的常用模型结构,bilstm用来提取语法结构和语义信息

class BertLstm(nn.Module):
    def __init__(self,num_class,emb_size,max_length,unit):
        super(BertLstm,self).__init__()
        self.max_length=max_length
        self.bert=BertModel.from_pretrained("bert-base-chinese")
        self.bilstm=nn.LSTM(input_size=emb_size,hidden_size=unit,bidirectional=True)
        self.dropout=nn.Dropout(0.3)
        self.linear=nn.Linear(unit*2,num_class)
    
    def forward(self,inputs):
        data_token=tokenizer.batch_encode_plus(inputs,
                                               padding=True,
                                               truncation=True,
                                               max_length=self.max_length)
        
        input_ids=torch.tensor(data_token["input_ids"]).to(device)
        attention_mask=torch.tensor(data_token["attention_mask"]).to(device)
        token_type_ids=torch.tensor(data_token["token_type_ids"]).to(device)
        encode=self.bert(input_ids=input_ids,attention_mask=attention_mask,token_type_ids=token_type_ids)
        output=self.bilstm(encode[0][:,0,:])
        output=self.dropout(output[0])
        output=self.linear(output)
        return output

3、bert-textcnn

文本分类常用结构,textcnn进一步提取句子中的关键信息

class TextCNN(nn.Module):
    def __init__(self,filter_sizes,emb_size,in_channels,num_filter):
        super(TextCNN,self).__init__()
        self.convlist=nn.ModuleList([nn.Conv2d(in_channels=in_channels,
                                 out_channels=num_filter,
                                 kernel_size=[filter_size,emb_size]) for filter_size in filter_sizes])
        
    def forward(self,inputs):
        encodes=[]
        inputs=inputs.unsqueeze(1)
        for conv in self.convlist:
            output=conv(inputs)
            output=F.relu(output)
            output=output.squeeze(3)
            output=F.max_pool1d(output, output.shape[2]).squeeze(2)
            encodes.append(output)
        encode=torch.concat(encodes,dim=-1)
        return encode


class bert_textcnn(nn.Module):
    def __init__(self,num_filter,filter_sizes,in_channels,num_class,emb_size,max_length):
        super(bert_textcnn,self).__init__()
        self.max_length=max_length
        self.bert=BertModel.from_pretrained("bert-base-chinese")
        self.textcnn=TextCNN(filter_sizes=filter_sizes,
                             emb_size=emb_size,
                             in_channels=in_channels,
                             num_filter=num_filter)
        self.textcnn.to(device)
        self.dropout=nn.Dropout(0.3)
        self.linear=nn.Linear(num_filter*len(filter_sizes),num_class)
    
    def forward(self,inputs):
        token_dic=tokenizer.batch_encode_plus(inputs,
                                                padding=True,
                                                truncation=True,
                                                max_length=self.max_length)
        input_ids=torch.tensor(token_dic["input_ids"]).to(device)
        attention_mask=torch.tensor(token_dic["attention_mask"]).to(device)
        token_type_ids=torch.tensor(token_dic["token_type_ids"]).to(device)
        bert_output=self.bert(input_ids=input_ids,
                              attention_mask=attention_mask,
                              token_type_ids=token_type_ids)
        last_hidden_out=bert_output[0][:,:,:]
        textcnn_output=self.textcnn(last_hidden_out)
        output=self.dropout(textcnn_output)
        output=self.linear(output)
        return output

4、分层学习率

nlp中常用的学习率处理方法,这里将bert和下游网络层参数设置不同的学习率,因bert参数是经过充足预训练的,所以其参数已经在最优解附近,只需使用小学习率逐渐靠近最优解即可,下游层是随机初始化参数,可以设置大学习率,防止模型进入局部最优,同时也可以加快训练速度。

学习率一般呈现由大到小的变化,(对于warmup存在一个有小变大,再有大变小的过程),因此本次实验使用的是学习率线性下降的方法,通过对学习步数的记录,达到指定步数后学习率下降为 倍。

#分层学习率
optim_group=[
    {"params":[p for n,p in bert_textcnn.named_parameters() if 'bert' in n],"lr":1e-5},
    {"params":[p for n,p in bert_textcnn.named_parameters() if 'bert' not in n],"lr":1e-4}
]

#分层学习率
lf=nn.CrossEntropyLoss()
optimizer=torch.optim.Adam(optim_group,weight_decay=0.01)
scheduler=torch.optim.lr_scheduler.StepLR(optimizer=optimizer,step_size=len(dataloader),gamma=0.6)

5、实验结果

如下所示,主要对学习率、batch_size、模型结构、数据、max_length以及下游层的输出向量维度进行了调参。每个模型进行5个epoch,取在评测集上最好的一个epoch的模型。最后,整体的acc可达0.933,正负两类各自的最大acc可达0.96。

这仅仅是使用719条训练数据的结果(剩余的是作为评测的)。同时,通过对bad case的分析,发现都是一段文本里包含两个相反情感或者以一种自嘲的方式进行夸奖的case。对于这类case模型确实难以做出判断,一般都会使用规则去解决。

代码

代码和ipynb文件地址:

https://github.com/gzglss/study-notes/tree/main/nlp-task/nlu/classification

分类:

人工智能

标签:

自然语言处理

作者介绍

仰止
V1