【推荐系统基础】正负样本采样和构造
一、回顾word2vec中的负采样
- word2vec中的负采样:CBOW或者skip-gram这类模型的训练,在当词表规模较大且计算资源有限时,这类多分类模型会因为输出层(softmax)概率的归一化计算效率的影响,以免训练龟速。
- 而负采样提供了另一个角度:给定当前词与上下文,任务是最大化两者的共现概率。
也即将多分类问题简化为:针对(w, c)的二分类问题(即共现or不共现),从而避免了大词表上的归一化复杂计算量。
如 P ( D = 1 ∣ w , c ) P(D=1 \mid w, c) P(D=1∣w,c)表示c和w共现的概率 P ( D = 1 ∣ w , c ) = σ ( v w ⋅ v c ′ ) P(D=1 \mid w, c)=\sigma\left(v_{w} \cdot v_{c}^{\prime}\right) P(D=1∣w,c)=σ(vw⋅vc′)
1.1 滑动窗口
为了得到每个单词的高质量稠密embedding(相似上下文的单词的vector应该相似),word2vec是通过一个滑动窗口的滑动,同时计算 P ( w t + j ∣ w t ) P\left(w_{t+j} \mid w_{t}\right) P(wt+j∣wt)。下面就是一个栗子,window_size=2
。
1.2 目标函数
(1)一开始我们将刚才得到的一坨 P ( w t + j ∣ w t ) P\left(w_{t+j} \mid w_{t}\right) P(wt+j∣wt)相乘,并且是对于每个t,所以有2个累乘:
(2)因为一般我们是最小化目标函数,所以进行了取log和负平均的操作,修改后的目标函数:
为了求出上面损失函数最里面的概率 P ( w t + j ∣ w t ; θ ) P\left(w_{t+j} \mid w_{t} ; \theta\right) P(wt+j∣wt;θ),对于每个单词都用2个vector表示:
- 当w是中心词时,表示为 v w v_w vw
- 当w是上下文词时,表示为 u w u_w uw
但是为啥要用两个vector表示每个单词呢——更容易optimization。
1.3 预测函数
所以对于一个中心词c和一个上下文次c有: P ( o ∣ c ) = exp ( u o T v c ) ∑ w ∈ V exp ( u w T v c ) P(o \mid c)=\frac{\exp \left(u_{o}^{T} v_{c}\right)}{\sum_{w \in V} \exp \left(u_{w}^{T} v_{c}\right)} P(o∣c)=∑w∈Vexp(uwTvc)exp(uoTvc)将任意值 x i x_i xi映射到概率分布中,即如下:
分子的点积用来表示o和c之间相似程度,分母这坨东西就是基于整个词表,给出归一化后的概率分布。
二、word2vec中的负采样实现
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset
from torch.nn.utils.rnn import pad_sequence
from tqdm.auto import tqdm
from utils import BOS_TOKEN, EOS_TOKEN, PAD_TOKEN
from utils import load_reuters, save_pretrained, get_loader, init_weights
class SGNSDataset(Dataset):
def __init__(self, corpus, vocab, context_size=2, n_negatives=5, ns_dist=None):
self.data = []
self.bos = vocab[BOS_TOKEN]
self.eos = vocab[EOS_TOKEN]
self.pad = vocab[PAD_TOKEN]
for sentence in tqdm(corpus, desc="Dataset Construction"):
sentence = [self.bos] + sentence + [self.eos]
for i in range(1, len(sentence)-1):
# 模型输入:(w, context) ;输出为0/1,表示context是否为负样本
w = sentence[i]
left_context_index = max(0, i - context_size)
right_context_index = min(len(sentence), i + context_size)
context = sentence[left_context_index:i] + sentence[i+1:right_context_index+1]
context += [self.pad] * (2 * context_size - len(context))
self.data.append((w, context))
# 负样本数量
self.n_negatives = n_negatives
# 负采样分布:若参数ns_dist为None,则使用uniform分布
self.ns_dist = ns_dist if ns_dist is not None else torch.ones(len(vocab))
def __len__(self):
return len(self.data)
def __getitem__(self, i):
return self.data[i]
def collate_fn(self, examples):
words = torch.tensor([ex[0] for ex in examples], dtype=torch.long)
contexts = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
batch_size, context_size = contexts.shape
neg_contexts = []
# 对batch内的样本分别进行负采样
for i in range(batch_size):
# 保证负样本不包含当前样本中的context
ns_dist = self.ns_dist.index_fill(0, contexts[i], .0)
neg_contexts.append(torch.multinomial(ns_dist, self.n_negatives * context_size, replacement=True))
neg_contexts = torch.stack(neg_contexts, dim=0)
return words, contexts, neg_contexts
class SGNSModel(nn.Module):
def __init__(self, vocab_size, embedding_dim):
super(SGNSModel, self).__init__()
# 词嵌入
self.w_embeddings = nn.Embedding(vocab_size, embedding_dim)
# 上下文嵌入
self.c_embeddings = nn.Embedding(vocab_size, embedding_dim)
def forward_w(self, words):
w_embeds = self.w_embeddings(words)
return w_embeds
def forward_c(self, contexts):
c_embeds = self.c_embeddings(contexts)
return c_embeds
def get_unigram_distribution(corpus, vocab_size):
# 从给定语料中统计unigram概率分布
token_counts = torch.tensor([0] * vocab_size)
total_count = 0
for sentence in corpus:
total_count += len(sentence)
for token in sentence:
token_counts[token] += 1
unigram_dist = torch.div(token_counts.float(), total_count)
return unigram_dist
embedding_dim = 64
context_size = 2
hidden_dim = 128
batch_size = 1024
num_epoch = 10
n_negatives = 10
# 读取文本数据
corpus, vocab = load_reuters()
# 计算unigram概率分布
unigram_dist = get_unigram_distribution(corpus, len(vocab))
# 根据unigram分布计算负采样分布: p(w)**0.75
negative_sampling_dist = unigram_dist ** 0.75
negative_sampling_dist /= negative_sampling_dist.sum()
# 构建SGNS训练数据集
dataset = SGNSDataset(
corpus,
vocab,
context_size=context_size,
n_negatives=n_negatives,
ns_dist=negative_sampling_dist
)
data_loader = get_loader(dataset, batch_size)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SGNSModel(len(vocab), embedding_dim)
model.to(device)
optimizer = optim.Adam(model.parameters(), lr=0.001)
model.train()
for epoch in range(num_epoch):
total_loss = 0
for batch in tqdm(data_loader, desc=f"Training Epoch {epoch}"):
words, contexts, neg_contexts = [x.to(device) for x in batch]
optimizer.zero_grad()
batch_size = words.shape[0]
# 提取batch内词、上下文以及负样本的向量表示
word_embeds = model.forward_w(words).unsqueeze(dim=2)
context_embeds = model.forward_c(contexts)
neg_context_embeds = model.forward_c(neg_contexts)
# 正样本的分类(对数)似然
context_loss = F.logsigmoid(torch.bmm(context_embeds, word_embeds).squeeze(dim=2))
context_loss = context_loss.mean(dim=1)
# 负样本的分类(对数)似然
neg_context_loss = F.logsigmoid(torch.bmm(neg_context_embeds, word_embeds).squeeze(dim=2).neg())
neg_context_loss = neg_context_loss.view(batch_size, -1, n_negatives).sum(dim=2)
neg_context_loss = neg_context_loss.mean(dim=1)
# 损失:负对数似然
loss = -(context_loss + neg_context_loss).mean()
loss.backward()
optimizer.step()
total_loss += loss.item()
print(f"Loss: {total_loss:.2f}")
# 合并词嵌入矩阵与上下文嵌入矩阵,作为最终的预训练词向量
combined_embeds = model.w_embeddings.weight + model.c_embeddings.weight
save_pretrained(vocab, combined_embeds.data, "sgns.vec")
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
- 124
- 125
- 126
- 127
- 128
- 129
- 130
- 131
- 132
- 133
- 134
- 135
- 136
三、推荐系统中召回相关基础
召回模型训练与评估(对应的损失函数)
- Point-wise样本构造:BCE Loss
- Pair-wise样本构造:BPR Hinge Loss
- List-wise样本构造:softmax Loss
- 向量化召回:使用annoy
3.1 召回中的三种训练方式
召回中,一般的训练方式分为三种:point-wise、pair-wise、list-wise。在datawhale的RecHub中,用参数mode
来指定训练方式,每一种不同的训练方式也对应不同的Loss。
对应的三种训练方式可以参考下图(3种),其中a表示user的embedding,b+表示正样本的embedding,b-表示负样本的embedding。
(1)Point wise (mode = 0)
思想:将召回视作二分类,独立看待每个正样本、。
对于一个召回模型:
- 输入二元组<User, Item>,
- 输出 P ( U s e r P(U s e r P(User, Item ) ) ), 表示 User 对 Item 的感兴趣程度。
- 训练目标为: 若物品为正样本, 输出应尽可能接近 1 , 负样本则输出尽可能接近 0 。 采用的 Loss 最常见的就是 BCELoss(Binary Cross Entropy Loss)。
(2)Pair wise (mode = 1)
思想:用户对正样本感兴趣的程度应该大于负样本。
对于一个召回模型:
- 输入三元组<User, ItemPositive, ItemNegative>,
- 输出兴趣得分 P ( P( P( User, ItemPositive ) , P ( ), P( ),P( User, ItemNegative ) ) ), 表示用户对正样本物品和负样 本物品的兴趣得分。
- 训练目标为:正样本的兴趣得分应尽可能大于负样本的兴趣得分。
torch-rechub框架中采用的 Loss 为 BPRLoss(Bayes Personalized Ranking Loss)。Loss 的公式这里放 一个公式, 详细可以参考【贝叶斯个性化排序(BPR)算法小结】(链接里的内容和下面的公式有些细微的差别, 但是思想是一 样的)
L o s s = 1 N ∑ N i i = 1 − log ( L o s s=\frac{1}{N} \sum^{N} i_{i=1}-\log ( Loss=N1∑Nii=1−log( sigmoid ( ( ( pos_score − - − neg_score ) ) )) ))
(3)List wise(mode = 2)
思想:思想同Pair wise,但是实现上不同。
对于一个召回模型:
- 输入 N + 2 \boldsymbol{N}+2 N+2 元 组 ⟨ \langle ⟨ User, ItemPositive, ItemNeg_1,…, ItemNeg_N ⟩ \rangle ⟩;
- 输出用户对 1 个正样本和 N \mathrm{N} N 个负样本的兴趣得分。
- 训练目标为:对正样本的兴趣得分应该尽可能大于其他所有负样本的兴趣得分。
torch rechub框架中采用的 Loss 为 torch.nn.CrossEntropyLoss
, 即对输出进行 Softmax 处理后取交叉熵。
PS: 这里的 List wise 方式容易和 Ranking 中的 List wise 混淆, 虽然二者名字一样, 但 ranking 的 List wise 考虑了样本之间的顺序关系。例如 ranking 中会考虑 MAP、NDCP 等考虑顺序的指标作为评价指标, 而 Matching 中的 List wise 没有考虑顺序。
四、推荐系统中的负采样
在模型训练中,需要提供正例(用户喜欢商品)和负例(用户不喜欢的商品)给模型,但是由于在实际推荐场景中数据收集的难度,一般很难获得非用户的显式反馈行为(如用户对item的评分等),但用户的隐式反馈信息(用户消费或者有交互过的item)是较容易得到的。
一般假设用户交互过的商品都是正例,并通过采样的方式,从用户未交互过的商品集中选择一部分作为负例。
负采样(Negative Sampling):从用户未交互商品集中基于一定策略进行负例选择的这一过程。
- DSSM召回的样本中:
- 正样本就是曝光给用户并且用户点击的item;
- 负样本:其实常见错误是直接使用曝光并且没被user点击的item,但是会导致SSB(sample selection bias)样本选择偏差问题——因为召回在线时时从全量候选item中召回,而不是从有曝光的item中召回。
DSSM原始论文里的做法:只有正样本, 记为 D + D^{+} D+, 对于用户 u 1 u_{1} u1, 其正样本就是其点击过的 item, 负样本则是随机从 D + D^{+} D+(不包含 u 1 u_{1} u1 点击过的item) 中随机选择4个item作为负样本。
4.1 负样本构造的6个常用方法
(1)曝光未点击数据
如果只用这个,会导致BBS问题,也要看场景。
(2)全局随机选择负例
从原始的全局物料库中,随机抽取作为召回或者粗排的负样本。
(3)Batch内随机选择负例
训练时在同一个batch中,在除了正例之外的其他item,选择构造负例,一定程度解决SSB问题。
(4)曝光数据中随机选择负例
(5)基于Popularity随机选择负例
越流行的item,如果没被用户点击看过,则更可能对于该用户来说是一个真实的负例。
(6)基于Hard选择负例
作为easy negative 的一种补充,hard negative是比较难的负样本,即匹配度适中的,用户可能喜欢也可能不喜欢——但实际上是用户不喜欢的!可以参考Airbnb筛选Hard负例的尝试(hard例给模型带来的loss和信息多)。
-
业务逻辑选取(以airbnb为例)
- i 增加与正样本同城的房间作为负样本,增强了正负样本在地域上的相似性,加大了模型的学习难度
- ii 增加“被房主拒绝”作为负样本,增强了正负样本在“匹配用户兴趣爱好”上的相似性,加大了模型的学习难度
-
模型挖掘
- EBR与百度Mobius的做法极其相似,都是用上一版本的召回模型筛选出"没那么相似"的<user,doc>对,作为额外负样本,训练下一版本召回模型。
- EBR的做法是:采用上一版模型召回位置在101~500上的item作为hard negative(负样本还是以easy negative为主,文章中经验值是easy:hard=100:1)
Reference
[1] Word2Vec中为什么使用负采样?
[2] 一文读懂推荐系统负采样
[3] 负采样Negative Sampling
[4] 【机器学习】推荐算法正负样本选择方法调研总结
[5] CTR预估模型中的正负样本定义、选择和比例控制
[6] 关于推荐系统中召回模块建模采样方式的讨论
[7] 推荐系统(四)—— 负采样
文章来源: andyguo.blog.csdn.net,作者:山顶夕景,版权归原作者所有,如需转载,请联系作者。
原文链接:andyguo.blog.csdn.net/article/details/125471553
- 点赞
- 收藏
- 关注作者
评论(0)