Índice
1. Compreensão da competição e programa
Alterações de dimensão de dados
Em segundo lugar, a implementação do código
Em Zhihu, vi o compartilhamento e o compartilhamento do plano de análise de sentimentos em primeiro lugar na Competição de Algoritmo de PNL do Campus Sohu 2022. Senti que o plano era muito simples e elegante e, ao mesmo tempo, tinha uma pitada de aprendizado imediato ( estritamente falando, não foi um aprendizado imediato), e o efeito foi muito bom. Embora eles também tenham dado idéias e códigos mais detalhados baseados em pytorch-lightning em seu compartilhamento de soluções, mas alguns detalhes não são claros o suficiente, e o código não é fácil de entender, então faça uma explicação mais clara no blog E compartilhe de forma mais concisa (melhor entender o código baseado em tocha).
1. Compreensão da competição e programa
A tarefa desta competição é - Polaridade de sentimento de descrição de texto orientada a entidade e análise de intensidade de cor. A polaridade e a intensidade emocional são divididas em cinco situações: extremamente positiva, positiva, neutra, negativa e extremamente negativa. O concorrente precisa analisar a polaridade emocional e a intensidade da entidade a partir da perspectiva da descrição do texto para cada objeto da entidade.
Os dados são os seguintes:
{"id": 7410, "content": "Com um Nets tão incrível, os fãs e especialistas podem ter grandes expectativas para ele nos playoffs? Portanto, ao longo da temporada, as previsões de todos são razoáveis. A temporada deste ano As finais da Conferência Leste de os playoffs ainda devem ser os mesmos do ano passado, e os Nets e Bucks ainda devem se encontrar conforme programado. Os Nets Big Three de hoje não são o máximo, mas também são fáceis de "cozinhar o Ding Jie Niu", o caminho para os Bulls ainda devem ser Por muito tempo, a NBA ainda é o palco onde a superestrela fala!", "entity": {"Nets": 1, "Playoffs": 0}}{"id": 88679, "content": "2014.09 Membro do Comitê Permanente do Comitê do Partido da Província de Hainan, Secretário do Comitê do Partido Municipal de Danzhou e Vice-Secretário do Comitê de Trabalho da Zona de Desenvolvimento Econômico de Yangpu 2014.10 Membro do Comitê Permanente do Comitê do Partido Provincial de Hainan e Secretário do Comitê do Partido Municipal de Sanya 2016.11 Membro do Comitê Permanente do Comitê do Partido Provincial de Hainan e Secretário do Comitê do Partido Municipal de Haikou inspecionado em setembro de 2019.", "entity": {"Secretário do Comitê do Partido Municipal": 0, "Comitê do Partido da Província de Hainan": 0}}
Para o texto do conteúdo e a entidade fornecida nos dados acima, analise as cores emocionais contidas no conteúdo, respectivamente. Obviamente, esta é uma tarefa de classificação. Quando vi essa pergunta, a solução que passou pela minha mente foi exatamente a mesma que a linha de base que eles deram:
[CLS]conteúdo[SEP]entidade_0[SEP]
[CLS]conteúdo[SEP]entidade_1[SEP]
[CLS]conteúdo[SEP]entidade_2[SEP]
......
[CLS]conteúdo[SEP]entity_n[SEP]
Depois de splicing o conteúdo e cada entidade de acordo com o acima, ele é enviado para o modelo bert para extrair o vetor de sentença, e depois passa pelo classificador. Isso completa a tarefa. Esse esquema também é usado na competição. Diz-se que o efeito não é muito satisfatório. Confira o plano para o primeiro lugar na competição:
Defeitos da linha de base
Conforme mostrado na figura abaixo (citando a figura no compartilhamento do plano do autor do concurso)
Como os dados de entidade de cada parte dos dados não são iguais, um esquema de splicing como a linha de base fará com que o modelo veja o texto do conteúdo de forma diferente, o que pode ter um impacto no efeito final; ao mesmo tempo, cada parte dos dados é copiou o número de entidades, resultando em muitos dados de treinamento e baixa eficiência. Outro problema é que a seleção do vetor de sentença obtido pelo modelo também terá um certo erro.No esquema de linha de base, são usados cls ou todos os tokens embeddings para o meanPooling, o que também terá um certo impacto no resultado final; o por último é que cada entidade é emendada individualmente, parece um pouco enfraquecendo a conexão entre cada entidade, o que terá um certo impacto no resultado final.
plano de primeiro lugar
Conforme mostrado na figura acima (referente à imagem compartilhada pelo autor do concurso), as entidades em cada dado são emendadas com [MASK], e depois emendadas com o texto do conteúdo usando [SEP], para que uma classificação possa ser construída com eficiência em um dado A tarefa não precisa ser repetida várias vezes para cada dado como a linha de base. Ao mesmo tempo, a escolha do vetor da última frase também é evitada aqui, e a incorporação correspondente a [MASK] é usada diretamente como a incorporação de classificação de cada emoção da entidade. A introdução de [MASK] neste esquema também tem uma sugestão de aprendizado imediato, e o autor disse que o efeito é melhor. Por outro lado, não é um aprendizado estrito de prompt, não precisa prever qual é o token específico em [Mask] e depois fazer o mapeamento da classe, ou seja, não precisa fazer a construção do Prompt answer mapeamento de espaço (Verbalizer), basta fazer uma construção de modelo de prompt (Template).
Em geral, esse esquema é realmente mais elegante e, claro, o efeito é melhor, o que faz com que as pessoas se sintam um pouco refrescantes à primeira vista. É claro que, se você ler mais artigos (aprendizagem rápida), poderá pensar em soluções semelhantes. Alguns detalhes implementados no código - a transformação da dimensão da matriz, fornecem uma descrição mais clara e é mais fácil entender todo o esquema.
Alterações de dimensão de dados
um lote de dados
[CLS]content_0[SEP]entity_0_0[MASK]entity_0_1[MASK]entity_0_2[MASK][SEP]
[CLS]content_1[SEP]entity_1_0[MASK]entity_1_1[MASK][SEP]
[CLS]content_2[SEP]entity_2_0[MASK][SEP]
[CLS]content_3[SEP]entity_3_0[MASK]entity_3_1[MASK][SEP]
[CLS]content_4[SEP]entity_4_0[MASK]entity_4_1[MASK]entity_4_2[MASK][SEP]
......
[CLS]content_(batch_size-1)[SEP]entity_(batch_size-1)_0[MASK]entity_(batch_size-1)_1[MASK][SEP]
Após o tokenizer, o token é mapeado para o id correspondente ao dicionário. É necessário registrar os input_ids, Attention_mask, mask_tokens, entity_count e label de cada dado. A dimensão correspondente muda da seguinte forma
input_ids:[lote, seq_length]
[
[101,******,102,**,103,**,103,102,,0,0,0,0,0],
[101,*******,102,**,103,**,103,102],
[101,******,102,**,103,102,0,0,0,0,0],
......
[101,*****,102,**,103,**,103,**,103,**,103,0,0]
]
atenção_mask:[lote, seq_length]
[
[1,******,1,**,1,**,1,1,,0,0,0,0,0],
[1,******,1,**,1,**,1,1],
[1,******,1,**,1,1,0,0,0,0,0],
......
[1,******,1,**,1,**,1,**,1,**,1,0,0]
]
mask_tokens:[lote,seq_length]
[
[0,******,0,**,1,**,1,0,,0,0,0,0,0],
[0,******,0,**,1,**,1,0],
[0,********,0,**,1,0,0,0,0,0,0],
......
[0,*****,0,**,1,**,1,**,1,**,1,0,0]
]
A etiqueta é mantida por lista
[
[-2,2],
[1,2],
[-2],
......
[2, -2, 0, -1]
]
Se o número de entidades no lote for m, então a matriz do rótulo é [m]
[-2,2,1,2,...,2,-2,0,-1]
O resultado obtido após input_ids+attention_mask passa por bert:
# m indica que existem m entidades no lote is_masked = inputs['is_masked'].bool() inputs = {k: v para k, v em inputs.items() se k em ["input_ids", "attention_mask"]} outputs = self.bert(**inputs,return_dict=True, output_hidden_states=True) # [lote, comprimento_seq, 768] outputs = outputs.last_hidden_state # [m,768] masked_outputs = outputs[is_masked] # [m,5] logits = self.classifier(masked_outputs)
Em segundo lugar, a implementação do código
código número um
O autor deu o código baseado em pytorch-lightning. Acho que o encapsulamento é relativamente alto e não é fácil de entender. Com base nisso, implementei uma versão do código baseada em torch:
Código de modelo
from transformers import BertPreTrainedModel,BertModel
import torch.nn as nn
class SentiClassifyBertPrompt(BertPreTrainedModel):
def __init__(self,config):
super(SentiClassifyBertPrompt,self).__init__(config)
self.bert = BertModel(config=config)
self.classifier = nn.Sequential(
nn.Linear(config.hidden_size, config.hidden_size),
nn.LayerNorm(config.hidden_size),
nn.LeakyReLU(),
nn.Dropout(p=config.dropout),
nn.Linear(config.hidden_size, config.output_dim),
)
def forward(self,inputs):
# m表示batch内有m个实体
is_masked = inputs['is_masked'].bool()
inputs = {k: v for k, v in inputs.items() if k in ["input_ids", "attention_mask"]}
outputs = self.bert(**inputs,return_dict=True, output_hidden_states=True)
# [batch, seq_length, 768]
outputs = outputs.last_hidden_state
# [m,768]
masked_outputs = outputs[is_masked]
# [m,5]
logits = self.classifier(masked_outputs)
return logits
código de carregamento de dados
import torch
from torch.utils.data import Dataset
from tqdm import tqdm
import json
class DataReader(Dataset):
def __init__(self,file_path,tokenizer,max_langth):
self.file_path = file_path
self.tokenizer = tokenizer
self.max_length = max_langth
self.data_list = self.texts_tokeniztion()
self.allLength = len(self.data_list)
def texts_tokeniztion(self):
with open(self.file_path,'r',encoding='utf-8') as f:
lines = f.readlines()
res = []
for line in tqdm(lines,desc='texts tokenization'):
line_dic = json.loads(line.strip('\n'))
content = line_dic['content']
entity = line_dic['entity']
prompt_length = 0
prompts = ""
label = []
en_count = len(entity)
for k,v in entity.items():
prompt_length += len(k) + 1
#标签化为 0-4的整数
label.append(v+2)
prompts += k +"[MASK]"
#直接最大长度拼接
content = content[0:self.max_length-prompt_length-1-10]
text = content + "[SEP]" + prompts
input_ids,attention_mask,masks = self.text2ids(text)
input_ids = torch.tensor(input_ids,dtype=torch.long)
attention_mask = torch.tensor(attention_mask,dtype=torch.long)
masks = torch.tensor(masks, dtype=torch.long)
#记录每条数据有多少个实体,方便推理的时候batch推理
en_count = torch.tensor(en_count,dtype=torch.long)
temp = []
temp.append(input_ids)
temp.append(attention_mask)
temp.append(masks)
temp.append(label)
temp.append(en_count)
res.append(temp)
return res
def text2ids(self,text):
inputs = self.tokenizer(text)
input_ids = inputs['input_ids']
attention_mask = inputs['attention_mask']
masks = [ int(id==self.tokenizer.mask_token_id) for id in input_ids]
return input_ids, attention_mask, masks
def __getitem__(self, item):
input_ids = self.data_list[item][0]
attention_mask = self.data_list[item][1]
masks = self.data_list[item][2]
label = self.data_list[item][3]
en_count = self.data_list[item][4]
return input_ids, attention_mask, masks, label, en_count
def __len__(self):
return self.allLength
Código de treinamento do modelo
from data_reader.reader import DataReader
import torch
from torch.utils.data import DataLoader
from transformers import BertTokenizer,BertConfig
from torch.optim import AdamW
from model import SentiClassifyBertPrompt
from torch.optim.swa_utils import AveragedModel, SWALR
from torch.nn.utils.rnn import pad_sequence
from log.log import Logger
from tqdm import tqdm
import torch.nn.functional as F
import os
os.environ['CUDA_VISIBLE_DEVICES'] = "1"
def collate_fn(batch):
input_ids, attention_mask, masks, label, en_count = zip(*batch)
input_ids = pad_sequence(input_ids,batch_first=True,padding_value=0)
attention_mask = pad_sequence(attention_mask,batch_first=True,padding_value=0)
masks = pad_sequence(masks, batch_first=True, padding_value=0)
labels = []
for ele in label:
labels.extend(ele)
labels = torch.tensor(labels,dtype=torch.long)
en_count = torch.stack(en_count,dim=0)
return input_ids, attention_mask, masks, labels, en_count
def dev_validation(dev_loader,device,model):
total_correct = 0
total = 0
model.eval()
with torch.no_grad():
for step, batch in enumerate(tqdm(dev_loader, desc="dev_validation")):
batch = [t.to(device) for t in batch]
inputs = {"input_ids": batch[0], "attention_mask": batch[1], "is_masked": batch[2]}
label = batch[3]
logits = model(inputs)
preds = torch.argmax(logits,dim=1)
correct = (preds==label).sum()
total_correct += correct
total += label.size()[0]
acc = total_correct/total
return acc
def set_seed(seed = 1):
torch.cuda.manual_seed_all(seed)
torch.manual_seed(seed)
torch.backends.cudnn.deterministic = True
if __name__ == '__main__':
set_seed()
log_level = 10
log_path = "logs/train_bert_prompt_AdamW_swa.log"
logger = Logger(log_name='train_bert_prompt', log_level=log_level, log_path=log_path).logger
pretrain_model_path = "./pretrained_models/chinese-bert-wwm-ext"
batch_size = 16
epochs = 10
tokenizer = BertTokenizer.from_pretrained(pretrain_model_path)
config = BertConfig.from_pretrained(pretrain_model_path)
config.dropout = 0.2
config.output_dim = 5
config.batch_size = batch_size
device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentiClassifyBertPrompt.from_pretrained(config=config,pretrained_model_name_or_path = pretrain_model_path)
model.to(device)
optimizer = AdamW(params=model.parameters(),lr=1e-6)
# 随机权重平均SWA,实现更好的泛化
swa_model = AveragedModel(model=model,device=device)
# SWA调整学习率
swa_scheduler = SWALR(optimizer, swa_lr=1e-6)
train_dataset = DataReader(tokenizer=tokenizer, max_langth=512, file_path='./data/train_split.txt')
train_loader = DataLoader(dataset=train_dataset, shuffle=True, batch_size=batch_size, collate_fn=collate_fn)
dev_dataset = DataReader(tokenizer=tokenizer, max_langth=512, file_path='./data/dev_split.txt')
dev_loader = DataLoader(dataset=dev_dataset, shuffle=True, batch_size=batch_size, collate_fn=collate_fn)
for epoch in range(epochs):
model.train()
for step,batch in enumerate(tqdm(train_loader,desc="training")):
batch = [ t.to(device) for t in batch]
inputs = {"input_ids":batch[0],"attention_mask":batch[1],"is_masked":batch[2]}
label = batch[3]
logits = model(inputs)
loss = F.cross_entropy(logits,label)
loss.backward()
optimizer.step()
optimizer.zero_grad()
swa_model.update_parameters(model)
swa_scheduler.step()
acc = dev_validation(dev_loader,device,model)
swa_acc = dev_validation(dev_loader,device,swa_model)
logger.info('Epoch %d acc is %.6f'%(epoch,acc))
logger.info('Epoch %d swa_acc is %.6f' % (epoch, swa_acc))
O diretório do projeto é o seguinte
swa - peso médio
Existe um truque de treinamento - swa - peso médio no código de treinamento acima, que eu não vi e usei antes. É necessário mencionar que sua ideia central é o último modelo retido no processo de treinamento, não a verificação. O modelo com o melhor efeito no conjunto é o peso médio dos modelos treinados por todas as épocas, de modo que o modelo treinado tenha a melhor capacidade de generalização e o melhor efeito . Não precisamos implementar como calcular a média de pesos por nós mesmos. O Torch já possui um processo e código padronizados. O efeito específico precisa ser verificado por experimentos (algumas pessoas disseram que sgd+swa é eficaz).
......
optimizer = AdamW(params=model.parameters(),lr=1e-6)
# 随机权重平均SWA,实现更好的泛化
swa_model = AveragedModel(model=model,device=device)
# SWA调整学习率
swa_scheduler = SWALR(optimizer, swa_lr=1e-6)
for epoch in range(epochs):
model.train()
for step,batch in enumerate(tqdm(train_loader,desc="training")):
......
#正常训练
logits = model(inputs)
loss = F.cross_entropy(logits,label)
loss.backward()
optimizer.step()
optimizer.zero_grad()
#每个epoch后swa_model模型更新参数
swa_model.update_parameters(model)
#调整学习率
swa_scheduler.step()
código de linha de base
Para simplesmente verificar o efeito, também executei o esquema de linha de base, o código é o seguinte:
import torch
from torch.utils.data import Dataset
from tqdm import tqdm
import json
from transformers import BertPreTrainedModel,BertModel
import torch.nn as nn
class SentiClassifyBert(BertPreTrainedModel):
def __init__(self,config):
super(SentiClassifyBert,self).__init__(config)
self.bert = BertModel(config=config)
self.classifier = nn.Sequential(
nn.Linear(config.hidden_size, config.hidden_size),
nn.LayerNorm(config.hidden_size),
nn.LeakyReLU(),
nn.Dropout(p=config.dropout),
nn.Linear(config.hidden_size, config.output_dim),
)
def forward(self,inputs):
inputs = {k: v for k, v in inputs.items() if k in ["input_ids", "attention_mask"]}
outputs = self.bert(**inputs,return_dict=True, output_hidden_states=True)
outputs = outputs.last_hidden_state
cls_output = outputs[:,0:1,:].squeeze()
logits = self.classifier(cls_output)
return logits
class DataReader(Dataset):
def __init__(self,file_path,tokenizer,max_langth):
self.file_path = file_path
self.tokenizer = tokenizer
self.max_length = max_langth
self.data_list = self.texts_tokeniztion()
self.allLength = len(self.data_list)
def texts_tokeniztion(self):
with open(self.file_path,'r',encoding='utf-8') as f:
lines = f.readlines()
res = []
for line in tqdm(lines,desc='texts tokenization'):
line_dic = json.loads(line.strip('\n'))
content = line_dic['content']
entity = line_dic['entity']
for k,v in entity.items():
# 直接最大长度拼接
content = content[0:self.max_length - len(k) - 1 - 10]
text = content + "[SEP]" + k
input_ids, attention_mask, masks = self.text2ids(text)
input_ids = torch.tensor(input_ids, dtype=torch.long)
attention_mask = torch.tensor(attention_mask, dtype=torch.long)
label = torch.tensor(v+2, dtype=torch.long)
temp = []
temp.append(input_ids)
temp.append(attention_mask)
temp.append(label)
res.append(temp)
return res
def text2ids(self,text):
inputs = self.tokenizer(text)
input_ids = inputs['input_ids']
attention_mask = inputs['attention_mask']
masks = [ int(id==self.tokenizer.mask_token_id) for id in input_ids]
return input_ids, attention_mask, masks
def __getitem__(self, item):
input_ids = self.data_list[item][0]
attention_mask = self.data_list[item][1]
label = self.data_list[item][2]
return input_ids, attention_mask, label
from data_reader.reader import DataReader
import torch
from torch.utils.data import DataLoader
from transformers import BertTokenizer,BertConfig
from torch.optim import AdamW,SGD
from model import SentiClassifyBert
from torch.optim.swa_utils import AveragedModel, SWALR
from torch.nn.utils.rnn import pad_sequence
from log.log import Logger
from tqdm import tqdm
import torch.nn.functional as F
import os
os.environ['CUDA_VISIBLE_DEVICES'] = "0"
def collate_fn(batch):
input_ids, attention_mask, label = zip(*batch)
input_ids = pad_sequence(input_ids,batch_first=True,padding_value=0)
attention_mask = pad_sequence(attention_mask,batch_first=True,padding_value=0)
label = torch.stack(label,dim=0)
return input_ids, attention_mask, label
def dev_validation(dev_loader,device,model):
total_correct = 0
total = 0
model.eval()
with torch.no_grad():
for step, batch in enumerate(tqdm(dev_loader, desc="dev_validation")):
batch = [t.to(device) for t in batch]
inputs = {"input_ids": batch[0], "attention_mask": batch[1]}
label = batch[2]
logits = model(inputs)
preds = torch.argmax(logits,dim=1)
correct = (preds==label).sum()
total_correct += correct
total += label.size()[0]
acc = total_correct/total
return acc
def set_seed(seed = 1):
torch.cuda.manual_seed_all(seed)
torch.manual_seed(seed)
torch.backends.cudnn.deterministic = True
if __name__ == '__main__':
set_seed()
log_level = 10
log_path = "logs/train_bert_adamW_swa_20220718.log"
logger = Logger(log_name='train_bert', log_level=log_level, log_path=log_path).logger
pretrain_model_path = "./pretrained_models/chinese-bert-wwm-ext"
batch_size = 16
epochs = 20
tokenizer = BertTokenizer.from_pretrained(pretrain_model_path)
config = BertConfig.from_pretrained(pretrain_model_path)
config.dropout = 0.2
config.output_dim = 5
config.batch_size = batch_size
device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentiClassifyBert.from_pretrained(config=config,pretrained_model_name_or_path = pretrain_model_path)
model.to(device)
optimizer = AdamW(params=model.parameters(),lr=1e-6)
# optimizer = SGD(params=model.parameters(), lr=1e-5,momentum=0.9)
# 随机权重平均SWA,实现更好的泛化
swa_model = AveragedModel(model=model,device=device)
# SWA调整学习率
swa_scheduler = SWALR(optimizer, swa_lr=1e-6)
train_dataset = DataReader(tokenizer=tokenizer, max_langth=512, file_path='./data/train_split.txt')
train_loader = DataLoader(dataset=train_dataset, shuffle=True, batch_size=batch_size, collate_fn=collate_fn)
dev_dataset = DataReader(tokenizer=tokenizer, max_langth=512, file_path='./data/dev_split.txt')
dev_loader = DataLoader(dataset=dev_dataset, shuffle=True, batch_size=batch_size, collate_fn=collate_fn)
for epoch in range(epochs):
model.train()
for step,batch in enumerate(tqdm(train_loader,desc="training")):
batch = [ t.to(device) for t in batch]
inputs = {"input_ids":batch[0],"attention_mask":batch[1]}
label = batch[2]
logits = model(inputs)
loss = F.cross_entropy(logits,label)
loss.backward()
optimizer.step()
optimizer.zero_grad()
swa_model.update_parameters(model)
swa_scheduler.step()
acc = dev_validation(dev_loader,device,model)
swa_acc = dev_validation(dev_loader,device,swa_model)
logger.info('Epoch %d acc is %.6f'%(epoch,acc))
logger.info('Epoch %d swa_acc is %.6f' % (epoch, swa_acc))
No treinamento, o conjunto de treinamento de cerca de 9W de dados é dividido em 1W de dados como o conjunto de verificação, e o chinês-bert-wwm-ext é usado como modelo de pré-treinamento para treinar 20 épocas; os efeitos do SGD e AdamW otimizadores são comparados; Os efeitos da linha de base e do esquema de primeiro lugar são comparados; é claro, se o efeito do swa é bom ou não, não pode ser concluído porque não há um conjunto de testes.
3. Exibição do efeito
O primeiro plano:
a、adamW + swa
A precisão no conjunto de validação foi de 0,929579 em 20 épocas usando o otimizador AdamW; swa foi de 0,928673 - é menos claro como ele funciona no conjunto de teste.
b、sgd + swa
Em termos de precisão, a convergência de sgd é relativamente lenta. A taxa de precisão de 19 epcohs é menor para atingir o valor mais alto, e a taxa de precisão não é tão alta quanto AdamW, que é apenas 89,7. No entanto, parece que tem não convergiu totalmente. Demorou muito, parece que um otimizador inteligente como o AdamW é mais adequado para pessoas como eu, que não são muito boas em ajustar os parâmetros do otimizador.
esquema de linha de base
Em comparação, o efeito da linha de base é um pouco pior. A solução do primeiro lugar é realmente eficaz. Há dois pontos principais. Primeiro, não há splicing repetido, o que causa mudanças na distribuição dos dados. Ao mesmo tempo, o modelo pode ser melhor para aprender a relação direta entre entidades; A segunda é que a escolha do vetor de sentença é mais apropriada. Nem cls nem meanPooling são selecionados para incorporação, mas a incorporação correspondente a [MASK] é mais precisa. Esta é essencialmente a mudança de aprendizado imediato e aplicado aqui. A diferença entre o pré-treinamento e o ajuste fino é menor, a incorporação extraída é mais precisa e todos os efeitos são bons.
O programa é elegante e vale a pena aprender e aprender!
Artigo de referência
Competição de Algoritmo de Análise de Sentimento do Campus Sohu 2022