赞
踩
任务目标为使用深度学习算法,通过对商品的评价进行分析,得到评价中指代的实体商品位置和其对应的情感(好评/差评)。 输入一串文字序列,通过网络模型进行预测,最终输出该文字序列的实体标签和情感标签。本项目采用实体预测和情感预测双模块结构,经过bert分词之后词向量分为两份,其中一份用来进行实体识别任务,另一份用来进行情感分类任务。
首先观察数据,原始数据都是以文件夹的形式存储,每个文件夹中有.test和.train文件。其中第一列为电商评论,第二列为BIO实体标注,第三列为情感标注(其中-1为不关心 0为不好 2为好评价),每句话之间以空行形式进行分割。
我们需要将这些杂乱的数据进行处理,转化为我们能利用上的格式。首先将上面.test和.train文件中一行一个字的形式转化为一行一句话的形式(字与字之间用空格进行分割)。为了看起来直观,我们将情感分类中的-1 0 2 转化为-1 0 1。
# 转化为一行一句话的形式 def format_sample(file_paths, output_path): text = bio = pola = '' items = [] for file_path in file_paths: with open(file_path, encoding='UTF-8') as f: for line in f.readlines(): if line == '\n': items.append({'text': text.strip(), 'bio': bio.strip(), 'pola': pola.strip()}) text = bio = pola = '' continue t, b, p = line.split(' ') text += t + ' ' bio += b + ' ' # 将感情2改为1 p = str(1) if p.strip() == str(2) else p.strip() pola += p + ' ' df = pd.DataFrame(items) df.to_csv(output_path, index=None) # 将错误的样本删除 e.g.如果实体是B,情感是-1则删除 def check_label(): # pola中 -1是不关心 0是差评 1是好评 df = pd.read_csv(TRAIN_FILE_PATH) dct = {} for index, row in df.iterrows(): for b, p in zip(row['bio'].split(), row['pola'].split()): if b == 'B-ASP' and p == '-1': print(index, row) df.drop(index=index, inplace=True) cnt = dct.get((b,p), 0) dct[(b,p)] = cnt+1 print(dct) df.to_csv(TRAIN_FILE_PATH, index=None) format_sample([ './input/camera/camera.atepc.train.dat', './input/car/car.atepc.train.dat', './input/notebook/notebook.atepc.train.dat', './input/phone/phone.atepc.train.dat', ], TRAIN_FILE_PATH) format_sample([ './input/camera/camera.atepc.test.dat', './input/car/car.atepc.test.dat', './input/notebook/notebook.atepc.test.dat', './input/phone/phone.atepc.test.dat', ], TEST_FILE_PATH) check_label()
由于每个样本句子过短且每句中所含实体个数少,所以我们将样本中相邻的两句话用分号(;)拼接,实体标签用O进行拼接,情感标签用-1进行拼接,形成新的训练样本并按照每个batch中最长的句子来进行padding填充。返回时,除了返回batchsize个input_id,mask(bool形式,用于后面CRF层传入参数),实体和情感标签之外,还要返回batchsize个句子中每个句子随机选择的一个实体得到的cdm和cdw用来后续预测情感分类。还要返回所有实体的位置和情感的标签作为一个元组,用来计算模型预测的准确率。
class Dataset(data.Dataset): def __init__(self, type='train'): super().__init__() file_path = TRAIN_FILE_PATH if type == 'train' else TEST_FILE_PATH self.df = pd.read_csv(file_path) self.tokenizer = BertTokenizer.from_pretrained(BERT_MODEL_NAME) def __len__(self): return len(self.df) - 1 def __getitem__(self, index): # 相邻两个句子拼接 text1, bio1, pola1 = self.df.loc[index] text2, bio2, pola2 = self.df.loc[index+1] text = text1 + ' ; ' + text2 bio = bio1 + ' O ' + bio2 pola = pola1 + ' -1 ' + pola2 # 为避免样本中有中英混用导致模型分词错误的情况,我们采用手动tokenize tokens = ['[CLS]'] + text.split(' ') + ['[SEP]'] input_ids = self.tokenizer.convert_tokens_to_ids(tokens) # 实体标签转id bio_arr = ['O'] + bio.split(' ') + ['O'] bio_label = [BIO_MAP[l] for l in bio_arr] # 情感标签转数字 pola_arr = ['-1'] + pola.split(' ') + ['-1'] pola_label = list(map(int, pola_arr)) return input_ids, bio_label, pola_label def collate_fn(self, batch): # 统计最大句子长度 batch.sort(key=lambda x: len(x[0]), reverse=True) max_len = len(batch[0][0]) # 变量初始化 batch_input_ids = [] batch_bio_label = [] batch_mask = [] batch_ent_cdm = [] batch_ent_cdw = [] batch_pola_label = [] batch_pairs = [] for input_ids, bio_label, pola_label in batch: # 获取实体位置,没有实体跳过 ent_pos = get_ent_pos(bio_label) if len(ent_pos) == 0: continue # 填充句子长度 pad_len = max_len - len(input_ids) batch_input_ids.append(input_ids + [BERT_PAD_ID] * pad_len) batch_mask.append([1] * len(input_ids) + [0] * pad_len) batch_bio_label.append(bio_label + [BIO_O_ID] * pad_len) # 实体和情感分类对应 pairs = [] for pos in ent_pos: pola = pola_label[pos[0]] pairs.append((pos, pola)) batch_pairs.append(pairs) # 由于训练样本中有可能一句话中有不定的实体数量,为避免造成系统不稳定, # 我们采用每句话只取一个实体来进行训练,其他的实体可能会在其他的epoch中得到训练 sg_ent_pos = random.choice(ent_pos) cdm, cdw = get_ent_weight(max_len, sg_ent_pos) batch_ent_cdm.append(cdm) batch_ent_cdw.append(cdw) # 实体第一个字的情感极性 pola = pola_label[sg_ent_pos[0]] batch_pola_label.append(pola) return ( torch.tensor(batch_input_ids), torch.tensor(batch_mask).bool(), torch.tensor(batch_bio_label), torch.tensor(batch_ent_cdm), torch.tensor(batch_ent_cdw), torch.tensor(batch_pola_label), batch_pairs, ) def get_ent_weight(max_len, ent_pos): cdm = [] cdw = [] for i in range(max_len): dst = min(abs(i - ent_pos[0]), abs(i - ent_pos[-1])) if dst <= SRD: cdm.append(1) cdw.append(1) else: cdm.append(0) cdw.append(1 - (dst - SRD + 1) / max_len) return cdm, cdw
由于项目有两个任务,对实体的提取和对情感的提取,所以可以分两块来进行。在进行实体识别时,在原论文的基础上增加了CRF层,可以使实体识别更加准确在转为bert词向量后直接进入linear层得到实体的预测位置,再经过CRF层进行校正得到实体的预测输出。 在进行情感识别时,根据得到的词向量生成cdm和cdw,将词向量、cdw、cdm在特征维度进行拼接,经过自注意力层最后输入到线性层中进行预测输出。
config = BertConfig.from_pretrained(BERT_MODEL_NAME) class Model(nn.Module): def __init__(self): super().__init__() self.bert = BertModel.from_pretrained(BERT_MODEL_NAME) # 冻结Bert参数 for name, param in self.bert.named_parameters(): param.requires_grad = False self.lstm = nn.LSTM(BERT_DIM, HIDDEN_SIZE, batch_first=True, bidirectional=True) self.ent_linear = nn.Linear(HIDDEN_SIZE * 2, ENT_SIZE) self.crf = CRF(ENT_SIZE, batch_first=True) self.pola_linear = nn.Linear(BERT_DIM * 3, BERT_DIM) self.attention = BertAttention(config) self.pooler = BertPooler(config) self.dropout = nn.Dropout() def get_text_encoded(self, input_ids, mask): return self.bert(input_ids, attention_mask=mask)[0] def get_entity_fc(self, text_encoded): return self.ent_linear(text_encoded) def get_entity_crf(self, entity_fc, mask): return self.crf.decode(entity_fc, mask) def get_entity(self, input_ids, mask): text_encoded = self.get_text_encoded(input_ids, mask) lstm_out = self.lstm(text_encoded) entity_fc = self.get_entity_fc(lstm_out ) pred_ent_label = self.get_entity_crf(entity_fc, mask) return pred_ent_label def get_pola(self, input_ids, mask, ent_cdm, ent_cdw): text_encoded = self.get_text_encoded(input_ids, mask) # shape [b, c] -> [b, c, 768] ent_cdm_weight = ent_cdm.unsqueeze(-1).repeat(1, 1, BERT_DIM) ent_cdw_weight = ent_cdw.unsqueeze(-1).repeat(1, 1, BERT_DIM) cdm_feature = torch.mul(text_encoded, ent_cdm_weight) cdw_feature = torch.mul(text_encoded, ent_cdw_weight) out = torch.cat([text_encoded, cdm_feature, cdw_feature], dim=-1) out = self.pola_linear(out) # self-attension 结合上下文信息,增强语义 out = self.attention(out, None) # pooler 取[CLS]标记位作为整个句子的特征 out = torch.sigmoid(self.pooler(torch.tanh(out[0]))) return self.pola_linear(out) def ent_loss_fn(self, input_ids, ent_label, mask): text_encoded = self.get_text_encoded(input_ids, mask) entity_fc = self.get_entity_fc(text_encoded) return -self.crf.forward(entity_fc, ent_label, mask, reduction='mean') def pola_loss_fn(self, pred_pola, pola_label): return F.cross_entropy(pred_pola, pola_label) def loss_fn(self, input_ids, ent_label, mask, pred_pola, pola_label): return self.ent_loss_fn(input_ids, ent_label, mask) + self.pola_loss_fn(pred_pola, pola_label)
定义好model和dataste之后,就可以在kaggle云服务器上进行网络模型的训练了,一批取两条数据进行模型训练。预测实体标签和情感标签来得到总损失,进行梯度更新。每100次计算下模型的准确率(实体位置和情感标签都预测正确才算对),每五次计算准确率之后保存一次模型。
model = Model().to(DEVICE) optimizer = torch.optim.Adam(model.parameters(), lr=LR) dataset = Dataset() loader = data.DataLoader(dataset, batch_size=2, shuffle=False, collate_fn=dataset.collate_fn) for e in range(EPOCH): for b, batch in enumerate(loader): input_ids, mask, ent_label, ent_cdm, ent_cdw, pola_label, pairs = batch input_ids = input_ids.to(DEVICE) mask = mask.to(DEVICE) ent_label = ent_label.to(DEVICE) ent_cdm = ent_cdm.to(DEVICE) ent_cdw = ent_cdw.to(DEVICE) pola_label = pola_label.to(DEVICE) # 实体部分 pred_ent_label = model.get_entity(input_ids, mask) # 情感部分 pred_pola = model.get_pola(input_ids, mask, ent_cdm, ent_cdw) # 损失计算 loss = model.loss_fn(input_ids, ent_label, mask, pred_pola, pola_label) optimizer.zero_grad() loss.backward() optimizer.step() if b % 10 == 0: print('>> epoch', e, 'batch:', b, 'loss:', loss.item()) if b % 100 != 0: continue # 计算准确率 correct_cnt = pred_cnt = gold_cnt = 0 for i in range(len(input_ids)): # 累加真实值数量 gold_cnt += len(pairs[i]) # 根据预测的实体label,解析出实体位置,并预测情感分类 b_ent_pos, b_ent_pola = get_pola(model, input_ids[i], mask[i], pred_ent_label[i]) if not b_ent_pos: continue # 解析实体和情感,并和真实值对比 pred_pair = [] cnt = 0 for ent, pola in zip(b_ent_pos, torch.argmax(b_ent_pola, dim=1)): pair_item = (ent, pola.item()) pred_pair.append(pair_item) # 判断正确,正确数量加1 if pair_item in pairs[i]: cnt += 1 # 累加数值 correct_cnt += cnt pred_cnt += len(pred_pair) # 指标计算 precision = round(correct_cnt / (pred_cnt + EPS), 3) recall = round(correct_cnt / (gold_cnt + EPS), 3) f1_score = round(2 / (1 / (precision + EPS) + 1 / (recall + EPS)), 3) print('\tcorrect_cnt:', correct_cnt, 'pred_cnt:', pred_cnt, 'gold_cnt:', gold_cnt) print('\tprecision:', precision, 'recall:', recall, 'f1_score:', f1_score) if e % 5 == 0: torch.save(model, MODEL_DIR + f'model_{e}.pth')
将训练好的模型从kaggle下载到本地。读取模型,由于预测的文本是字符串的形式,故当我们转化为list时就自动完成了分词。再将分开的字用bert tokenizer转化为id值,将input_id和mask都进行升维操作来添加批量维度。再计算好实体位置之后,反向提取出文本中对应的信息。由于bert转为词向量之后添加了开头的(cls)和句尾的(sep),所以要减一来得到对应文本中的位置。最终返回三个值:实体的文本、实体的情感标签、实体的位置。
model = torch.load(MODEL_DIR+'model_9.pth', map_location=DEVICE) with torch.no_grad(): text =TEXT tokenizer = BertTokenizer.from_pretrained(BERT_MODEL_NAME) tokens = list(text) input_ids = tokenizer.encode(tokens) mask = [1] * len(input_ids) # 实体部分 input_ids = torch.tensor(input_ids).unsqueeze(0) mask = torch.tensor(mask).unsqueeze(0).bool() pred_ent_label = model.get_entity(input_ids, mask) # 情感分类 b_ent_pos, b_ent_pola = get_pola(model, input_ids[0], mask[0], pred_ent_label[0]) if not b_ent_pos: print('\t', 'no result.') else: pred_pair = [] for ent_pos, pola in zip(b_ent_pos, torch.argmax(b_ent_pola, dim=1)): aspect = text[ent_pos[0] - 1:ent_pos[-1]] pred_pair.append({'aspect': aspect, 'sentiment': POLA_MAP[pola], 'position': ent_pos}) print('\t', text) print('\t', pred_pair)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。