比赛链接:https://www.biendata.xyz/competition/sohu_2022/
完整代码 可关注ChallengeHub 回复“搜狐”即可获取
赛题背景
在工业界,推荐算法和自然语言处理是结合非常紧密的两个技术环节。
本次大赛我们推出创新赛制——NLP 和推荐算法双赛道:探究文本情感对推荐转化的影响。情感分析是NLP领域的经典任务,本次赛事在经典任务上再度加码,研究文本对指定对象的情感极性及色彩强度,难度升级,挑战加倍。
同时拥有将算法成果研究落地实际场景的绝佳机会,接触在校园难以体验到的工业实践,体验与用户博弈的真实推荐场景。
比赛任务
比赛分为两部分:
- 第一部分:面向实体对象的文本描述情感极性及色彩强度分析。情感极性和强度分为五种情况:极正向、正向、中立、负向、极负向。选手需要针对给定的每一个实体对象,从文本描述的角度,分析出对该实体的情感极性和强度。
- 第二部分:利用给出的用户文章点击序列数据及用户相关特征,结合第一部分做出的情感分析模型,对给定的文章做出是否会形成点击转化的预测判别。用户点击序列中涉及的文章,及待预测的文章,我们都会给出其详细内容。
任务1:面向实体对象的文本情感分类
第一部分:面向实体对象的文本描述情感极性及色彩强度分析。情感极性和强度分为五种情况:极正向、正向、中立、负向、极负向。选手需要针对给定的每一个实体对象,从文本描述的角度,分析出对该实体的情感极性和强度。
数据加载
train_file = 'data/Sohu2022_data/nlp_data/train.txt' test_file = 'data/Sohu2022_data/nlp_data/test.txt' sub_file = 'data/submission/section1.txt' train = pd.read_json(train_file, lines=True) test = pd.read_json(test_file, lines=True) sub= pd.read_table(sub_file)
文本长度统计
train['text_len'].quantile([0.5,0.8,0.9,0.96]) 0.50 426.0 0.80 479.0 0.90 515.0 0.96 562.0 Name: text_len, dtype: float64
大部分文本长度在562以内,在迭代过程中发现,输入到模型的文本越完整效果越好,所以可以尝试文档级的模型,比如ernie-doc或者xlnet等。
实体情感标签统计
sns.countplot(sentiment_df.sentiment) plt.xlabel('sentiment value count')
可以看出中性情感占到了绝大部分,极端情感最少。因为数据量比较大,大家可以使用一些采样策略:
- 中立情感负采样 ,但是有过拟合风险
- 保证情感比例采样:加快模型迭代速度
- 对同一个样本的重复情感可以负采样,ent1和ent2:1 text|ent1+ent2
数据预处理
同一样本的标签有多个,然后按照多个实体情感对样本进行复制,得到每个文本以及标签,处理代码如下:
lst_col = 'sentiment' train = pd.DataFrame({ col: np.repeat(train[col].values, train[lst_col].str.len()) for col in train.columns.difference([lst_col]) }).assign(**{lst_col: np.concatenate(train[lst_col].values)})[train.columns.tolist()]
由于文本过长,对于资源紧缺的同学可以尝试文本滑窗构造、关键句筛选等方式,参考处理代码如下:
def get_text_label(row): content = row['content'] # print(row['sentiment'].keys()) entity, sentiment = list(row['sentiment'].keys())[0], list(row['sentiment'].values())[0] # 构造文本方式1: # line_split = split_sentences(content) # contain_indexs=[index for index,sent in enumerate(line_split) if entity in sent] # 1 和6 # tmp=[] # for index in contain_indexs: # if index==0: # tmp.extend(line_split[index:index+2]) # elif index==len(line_split)-1: # tmp.extend(line_split[index-2:]) # else: # tmp.extend(line_split[index-2:index+1]) # # print(tmp) # text = ','.join(tmp) ## textrank 构造文本 # 160-200 0.633 return entity, content, sentiment
模型定义
class LastHiddenModel(nn.Module): def __init__(self, model_name, n_classes): super().__init__() config = AutoConfig.from_pretrained(model_name) self.model = AutoModel.from_pretrained(model_name, config=config) self.linear = nn.Linear(config.hidden_size, n_classes) def forward(self, input_ids, attention_mask, token_type_ids): outputs = self.model(input_ids, attention_mask, token_type_ids)# last_hidden_state和pooler out last_hidden_state = outputs[0] # 所有字符最后一层hidden state # 32 400 768 ,但是PAD PAD input_mask_expanded = attention_mask.unsqueeze(-1).expand(last_hidden_state.size()).float() sum_embeddings = torch.sum(last_hidden_state * input_mask_expanded, 1) sum_mask = input_mask_expanded.sum(1) sum_mask = torch.clamp(sum_mask, min=1e-9) mean_embeddings = sum_embeddings / sum_mask logits = self.linear(mean_embeddings) return logits
扩展思路:
- 长文本处理:模型输入/模型预测:TTA
- doc级文本模型:longformer
(xlnet) https://huggingface.co/hfl/chinese-xlnet-base (longformer_zh) https://huggingface.co/ValkyriaLenneth/longformer_zh (longformer-chinese-base-4096) https://huggingface.co/schen/longformer-chinese-base-4096 transform-xl:
- 轻量级模型:LSTM、GRU/Transformer等网络 600 word 300
- 选择使用不同预训练模型进行微调,chinese-roberta-wwm/nezha/xlnet/ernie/ernie-gram,其中ernie或者ernie-gram效果可能会好些
- 预训练模型输出的利用:CLS/PoolerOut/LastHiddenState/+(Bi)LSTM/LastFourConcat/etc...
- 训练优化:对抗训练(FGM/PGD/AWP)/EMA/MultiDropout/Rdrop
import torch.nn.functional as F # define your task model, which outputs the classifier logits model = TaskModel() def compute_kl_loss(self, p, q, pad_mask=None): p_loss = F.kl_div(F.log_softmax(p, dim=-1), F.softmax(q, dim=-1), reduction='none') q_loss = F.kl_div(F.log_softmax(q, dim=-1), F.softmax(p, dim=-1), reduction='none') # pad_mask is for seq-level tasks if pad_mask is not None: p_loss.masked_fill_(pad_mask, 0.) q_loss.masked_fill_(pad_mask, 0.) # You can choose whether to use function "sum" and "mean" depending on your task p_loss = p_loss.sum() q_loss = q_loss.sum() loss = (p_loss + q_loss) / 2 return loss # keep dropout and forward twice logits = model(x) logits2 = model(x) # cross entropy loss for classifier ce_loss = 0.5 * (cross_entropy_loss(logits, label) + cross_entropy_loss(logits2, label)) kl_loss = compute_kl_loss(logits, logits2) # carefully choose hyper-parameters loss = ce_loss + α * kl_loss
改进1 Last 4 Layers Concatenating
class LastFourModel(nn.Module): def __init__(self): super().__init__() config = AutoConfig.from_pretrained(PRE_TRAINED_MODEL_NAME) config.update({'output_hidden_states':True}) self.model = AutoModel.from_pretrained(PRE_TRAINED_MODEL_NAME, config=config) self.linear = nn.Linear(4*768, n_classes) def forward(self, input_ids, attention_mask): outputs = self.model(input_ids, attention_mask) all_hidden_states = torch.stack(outputs[2]) concatenate_pooling = torch.cat( (all_hidden_states[-1], all_hidden_states[-2], all_hidden_states[-3], all_hidden_states[-4]), -1 ) concatenate_pooling = concatenate_pooling[:,0] output = self.linear(concatenate_pooling) return soutput
改进2 模型层间差分学习率
对不同的网络层数使用不同的学习率,这样可以防止过拟合,有利于加速学习。
def get_parameters(model, model_init_lr, multiplier, classifier_lr): parameters = [] lr = model_init_lr for layer in range(12,-1,-1): layer_params = { 'params': [p for n,p in model.named_parameters() if f'encoder.layer.{layer}.' in n], 'lr': lr } parameters.append(layer_params) lr *= multiplier classifier_params = { 'params': [p for n,p in model.named_parameters() if 'layer_norm' in n or 'linear' in n or 'pooling' in n], 'lr': classifier_lr } parameters.append(classifier_params) return parameters parameters=get_parameters(model,2e-5,0.95, 1e-4) optimizer=AdamW(parameters)
- BERT长文本处理:《CogLTX: Applying BERT to Long Texts》
COGLTX采用的策略是将每个子句从原句中移除判断其是否是必不可少的(t是一个阈值):
分类实例:https://github.com/Sleepychord/CogLTX/blob/main/run_20news.py
- XLNET分类模型
import torch import torch.nn as nn import torch.nn.functional as F import numpy as np from transformers import XLNetModel class MyXLNet(nn.Module): def __init__(self, num_classes=35, alpha=0.5): self.alpha = alpha super(MyXLNet, self).__init__() self.net = XLNetModel.from_pretrained(xlnet_cfg.xlnet_path).cuda() for name, param in self.net.named_parameters(): if 'layer.11' in name or 'layer.10' in name or 'layer.9' in name or 'layer.8' in name or 'pooler.dense' in name: param.requires_grad = True else: param.requires_grad = False self.MLP = nn.Sequential( nn.Linear(768, num_classes, bias=True), ).cuda() def forward(self, x): x = x.long() x = self.net(x, output_all_encoded_layers=False).last_hidden_state x = F.dropout(x, self.alpha, training=self.training) x = torch.max(x, dim=1)[0] x = self.MLP(x) return torch.sigmoid(x)
- 长文本理解模型 ERNIE-Doc
ERNIE-DOC,是一个基于Recurrence Transformers(Dai et al., 2019) 的文档级语言预训练模型。 本模型用了两种技术:回溯式feed机制和增强的循环机制,使模型 具有更长的有效上下文长度,以获取整个文档的相关信息。
https://github.com/PaddlePaddle/ERNIE
import paddle from paddlenlp.transformers import * tokenizer = AutoTokenizer.from_pretrained('ernie-doc-base-zh') text = tokenizer('自然语言处理') # 语义表示 model = AutoModel.from_pretrained('ernie-doc-base-zh') sequence_output, pooled_output = model(input_ids=paddle.to_tensor([text['input_ids']])) # 文本分类 & 句对匹配 model = AutoModelForSequenceClassification.from_pretrained('ernie-doc-base-zh')
任务2:文章点击预测
第二部分:利用给出的用户文章点击序列数据及用户相关特征,结合第一部分做出的情感分析模型,对给定的文章做出是否会形成点击转化的预测判别。用户点击序列中涉及的文章,及待预测的文章,我们都会给出其详细内容。
数据加载
train = pd.read_csv('data/Sohu2022_data/rec_data/train-dataset.csv') test = pd.read_csv('data/Sohu2022_data/rec_data/test-dataset.csv') print("train_data.shape",train.shape) print("test_data.shape",test.shape)
训练集中每条样本包含pvId,用户id,点击序列(序列中的每次点击都包含文章id和浏览时间),用户特征(包含但不限于操作系统、浏览器、设备、运营商、省份、城市等),待预测文章id和当前时间戳,以及用户的行为(1为有点击,0为未点击)。
- smapleId:样本的唯一id
- label:点击标签
- pvId:将每次曝光给用户的展示结果列表称为一个Group(每个Group都有唯一的pvId)
- suv:用户id
- itemId:文章id
- userSeq:点击序列
- logTs:当前时间戳
- operator:操作系统
- browserType:浏览器
- deviceType:设备
- osType:运营商
- province:省份
- city:城市
数据分析
def statics(data): stats = [] for col in data.columns: stats.append((col, data[col].nunique(), data[col].isnull().sum() * 100 / data.shape[0], data[col].value_counts(normalize=True, dropna=False).values[0] * 100, data[col].dtype)) stats_df = pd.DataFrame(stats, columns=['Feature', 'Unique_values', 'Percentage_of_missing_values', 'Percentage_of_values_in_the_biggest category', 'type']) stats_df.sort_values('Percentage_of_missing_values', ascending=False, inplace=True) return stats_df
我们使用这个函数可以直接对训练集进行初步统计分析
# 字段基本统计 stats_df=statics(train) stats_df
标签分布如下
sns.countplot(train.label) plt.xlabel('train label count')
初步特征工程
- 情感特征
amount_feas = ['prob_0', 'prob_1', 'prob_2', 'prob_3','prob_4' ] category_fea = ['id'] for f in tqdm(amount_feas, desc="amount_feas 基本聚合特征"): for cate in category_fea: if f != cate: rec_item_sentiment['{}_{}_medi'.format('senti', f)] = rec_item_sentiment.groupby(cate)[f].transform('median') rec_item_sentiment['{}_{}_mean'.format('senti', f)] = rec_item_sentiment.groupby(cate)[f].transform('mean') rec_item_sentiment['{}_{}_max'.format('senti', f)] = rec_item_sentiment.groupby(cate)[f].transform('max') rec_item_sentiment['{}_{}_min'.format('senti', f)] = rec_item_sentiment.groupby(cate)[f].transform('min') rec_item_sentiment['{}_{}_std'.format('senti', f)] = rec_item_sentiment.groupby(cate)[f].transform('std')
类别特征count特征
# count特征 for col in tqdm(sparse_features): data[col + '_count'] = data.groupby(col)['sampleId'].transform('count') dense_features.append(col + '_count')
用户特征:
# count特征 for col in tqdm(['pvId','itemId' ]): data[f'group_suv_{col}_nunique'] = \ data[['suv', col]].groupby('suv')[col].transform('nunique') dense_features.append(f'group_suv_{col}_nunique')
物料特征
# pvId nunique特征 select_cols = ['suv', 'itemId'] for col in tqdm(select_cols): data[f'group_pvId_{col}_nunique'] = \ data[['pvId', col]].groupby('pvId')[col].transform('nunique') dense_features.append(f'group_pvId_{col}_nunique') # itemId nunique特征 select_cols = ['pvId', 'suv', 'operator', 'browserType', 'deviceType', 'osType', 'province', 'city'] for col in tqdm(select_cols): data[f'group_itemId_{col}_nunique'] = \ data[['itemId', col]].groupby('itemId')[col].transform('nunique') dense_features.append(f'group_itemId_{col}_nunique')
NN模型-DeepFM
基于deepctr实现DeepFM训练
train_model_input = {name: train[name] for name in feature_names} valid_model_input = {name: valid[name] for name in feature_names} test_model_input = {name: test[name] for name in feature_names} model = DeepFM(linear_feature_columns, dnn_feature_columns, task='binary') model.compile("adam", "binary_crossentropy", metrics=['binary_crossentropy', 'accuracy']) history = model.fit(train_model_input, train[target].values, batch_size=1024, epochs=3, verbose=1, validation_data=(valid_model_input, valid[target].values)) pred_ans = model.predict(valid_model_input, batch_size=1024) print("valid AUC", round(roc_auc_score(valid[target].values, pred_ans), 4)) pred_ans = model.predict(test_model_input, batch_size=1024)
树模型-Catboost
train_model = CatBoostClassifier(iterations=15000, depth=5, learning_rate=0.05, loss_function='Logloss', logging_level='Verbose', eval_metric='AUC', task_type="GPU", devices='0:1') train_model.fit(train_dataset, eval_set=eval_dataset, early_stopping_rounds=30, verbose=40)
特征工程思路扩展
- 高阶特征:类别特征组合、高阶聚合特征,比例特征
- 点击序列统计特征:当前用户|全局: item 众数当做类别特征;统计量 count或者nunique
- 序列 Embedding特征:word2vec,tfidf(词袋)+SVD、graph embedding(deepwalk)
- 点击转化率特征:itemid、pvId,类别组合 ..(提分) Kfold