Word2Vec

什么是 Word2vec?

NLP 里面,最细粒度的是 词语,词语组成句子,句子再组成段落、篇章、文档。所以处理 NLP 的问题,首先就要拿词语开刀。

NLP 里的词语,是人类的抽象总结,是符号形式的(比如中文、英文、拉丁文等等),所以需要把他们转换成数值形式,或者说——嵌入到一个数学空间里,这种嵌入方式,就叫词嵌入(word embedding),而 Word2vec,就是词嵌入( word embedding) 的一种

在 NLP 中,把 x 看做一个句子里的一个词语,y 是这个词语的上下文词语,那么这里的 f,便是 NLP 中经常出现的『语言模型』(language model),这个模型的目的,就是判断 (x,y) 这个样本,是否符合自然语言的法则,更通俗点说就是:词语x和词语y放在一起,是不是人话。

Word2vec 正是来源于这个思想,但它的最终目的,不是要把 f 训练得多么完美,而是只关心模型训练完后的副产物——模型参数(这里特指神经网络的权重),并将这些参数,作为输入 x 的某种向量化的表示,这个向量便叫做——词向量

我们来看个例子,如何用 Word2vec 寻找相似词:

  • 对于一句话:『她们 夸 吴彦祖 帅 到 没朋友』,如果输入 x 是『吴彦祖』,那么 y 可以是『她们』、『夸』、『帅』、『没朋友』这些词
  • 现有另一句话:『她们 夸 我 帅 到 没朋友』,如果输入 x 是『我』,那么不难发现,这里的上下文 y 跟上面一句话一样
  • 从而 f(吴彦祖) = f(我) = y,所以大数据告诉我们:我 = 吴彦祖(完美的结论

Skip-gramCBOW 模型

上面我们提到了语言模型

  • 如果是用一个词语作为输入,来预测它周围的上下文,那这个模型叫做『Skip-gram 模型』
  • 而如果是拿一个词语的上下文作为输入,来预测这个词语本身,则是 『CBOW 模型』

我们先来看个最简单的例子。上面说到, y 是 x 的上下文,所以 y 只取上下文里一个词语的时候,语言模型就变成:

用当前词 x 预测它的下一个词 y

但如上面所说,一般的数学模型只接受数值型输入,这里的 x 该怎么表示呢? 显然不能用 Word2vec,因为这是我们训练完模型的产物,现在我们想要的是 x 的一个原始输入形式。

答案是:one-hot encoder

当模型训练完后,最后得到的其实是神经网络的权重,比如现在输入一个 x 的 one-hot encoder: [1,0,0,…,0],对应刚说的那个词语『吴彦祖』,则在输入层到隐含层的权重里,只有对应 1 这个位置的权重被激活,这些权重的个数,跟隐含层节点数是一致的,从而这些权重组成一个向量 vx 来表示x,而因为每个词语的 one-hot encoder 里面 1 的位置是不同的,所以,这个向量 vx 就可以用来唯一表示 x。

Skip-gram 更一般的情形

一对多

CBOW 更一般的情形

更为常用,跟 Skip-gram 相似,只不过:

Skip-gram 是预测一个词的上下文,而 CBOW 是用上下文预测这个词

多对一

网络结构如下:

更 Skip-gram 的模型并联不同,这里是输入变成了多个单词,所以要对输入处理下(一般是求和然后平均),输出的 cost function 不变

最终训练好的N-dim的向量(是一个密集向量)就是表示当前输出词语的词向量。相当于多维特征去表征一个词的词义,可以发现,相近的词向量之间的距离是对应相等的。比如:国王与皇后的距离与男人与女人的距离是相同的。

Embedding

Embedding(词嵌入):word2vec是Embedding的一种,

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from tensorflow import keras  #这里的波浪线不用管

#用karas有的数据集imdb,电影分类,分电影是积极的,还是消极的
imdb = keras.datasets.imdb
#载入数据使用下面两个参数
vocab_size = 10000 #词典大小,仅保留训练数据中前10000个最经常出现的单词,低频单词被舍弃
index_from = 3 #0,1,2,3空出来做别的事
#前一万个词出现词频最高的会保留下来进行处理,后面的作为特殊字符处理,
# 小于3的id都是特殊字符,下面代码有写
# 需要注意的一点是取出来的词表还是从1开始的,需要做处理
(train_data, train_labels), (test_data, test_labels) = imdb.load_data(
num_words=vocab_size, index_from=index_from)

#载入词表,看下词表长度,词表就像英语字典
word_index = imdb.get_word_index()
print(len(word_index))
print(type(word_index))
#词表虽然有8万多,但是我们只载入了最高频的1万词!!!!

构造 word2idx 和 idx2word

1
2
3
4
5
6
7
8
9
word2idx = {word: idx + 3 for word, idx in word_index.items()}  # 0,1,2,3空出来做别的事,这里的idx是从1开始的,所以加3
word2idx.update({
"[PAD]": 0, # 填充 token
"[BOS]": 1, # begin of sentence
"[UNK]": 2, # 未知 token
"[EOS]": 3, # end of sentence
})

idx2word = {idx: word for word, idx in word2idx.items()} # 反向词典,id变为单词(键值反转)

Tokenizer

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
class Tokenizer:
def __init__(self, word2idx, idx2word, max_length=500, pad_idx=0, bos_idx=1, eos_idx=3, unk_idx=2):
self.word2idx = word2idx #词表,单词到id
self.idx2word = idx2word #词表,id到单词
self.max_length = max_length
self.pad_idx = pad_idx #填充
self.bos_idx = bos_idx #开始
self.eos_idx = eos_idx #结束
self.unk_idx = unk_idx #未知,未出现在最高频词表中的词

def encode(self, text_list):
"""
将文本列表转化为索引列表
:param text_list:当前批次的文本列表
:return:
"""
max_length = min(self.max_length, 2 + max(
[len(text) for text in text_list])) #最大长度,最大长度是500,但是如果句子长度小于500,就取句子长度(句子长度是本组句子中最长的),2是为了留出开始和结束的位置
indices = []
for text in text_list:
index = [self.word2idx.get(word, self.unk_idx) for word in text] #单词转化为id,未知的词用unk_idx代替
index = [self.bos_idx] + index + [self.eos_idx] #添加开始和结束
if len(index) < max_length:
index = index + [self.pad_idx] * (max_length - len(index)) #填充0
else:
index = index[:max_length] #如果句子长度大于500,就截断
indices.append(index)
return torch.tensor(indices) #二维列表转化为tensor

def decode(self, indices_list, remove_bos=True, remove_eos=True, remove_pad=True, split=False):
"""
将索引列表转化为文本列表
:param indices_list:某批次的索引列表
:param remove_bos:
:param remove_eos:
:param remove_pad:
:param split:
:return:
"""
text_list = []
for indices in indices_list:
text = []
for index in indices:
word = self.idx2word.get(index, "[UNK]")
if remove_bos and word == "[BOS]":
continue
if remove_eos and word == "[EOS]":
break
if remove_pad and word == "[PAD]":
break
text.append(word)
text_list.append(" ".join(text) if not split else text)
return text_list


tokenizer = Tokenizer(word2idx=word2idx, idx2word=idx2word)
raw_text = ["hello world".split(), "tokenize text datas with batch".split(), "this is a test".split()]
indices = tokenizer.encode(raw_text) #encode支持批量处理
decode_text = tokenizer.decode(indices.tolist(), remove_bos=False, remove_eos=False, remove_pad=False)
print("raw text")
for raw in raw_text:
print(raw)
print("indices")
for index in indices:
print(index)
print("decode text")
for decode in decode_text:
print(decode)

对应效果:

数据集与 DataLoader

由于是tf的数据集,dataset需要重写:

  • init
  • getitem
  • Len

并且注意:由于刚导入train_data是包含ids的一系列数据(其中包含词表大小和get_word_index()),每一个样本是不包含结束符的,所以需要先进行decode然后encode为其补上结束符。并且需要对batch进行encode,统一长度

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
from torch.utils.data import Dataset, DataLoader


class IMDBDataset(Dataset):
def __init__(self, data, labels, remain_length=True):
if remain_length: #字符串输出样本中,是否含有【BOS】和【EOS】,【PAD】
self.data = tokenizer.decode(data, remove_bos=False, remove_eos=False, remove_pad=False)
else:
# 缩减一下数据
self.data = tokenizer.decode(data)
self.labels = labels

def __getitem__(self, index):
text = self.data[index]
label = self.labels[index]
return text, label

def __len__(self):
return len(self.data)


def collate_fct(batch):
"""
回调函数:
将batch(一批)数据处理成tensor形式
:param batch:
:return:
"""
text_list = [item[0].split() for item in batch] #batch是128样本,每个样本类型是元组,第一个元素是文本,第二个元素是标签
label_list = [item[1] for item in batch]
text_list = tokenizer.encode(text_list).to(dtype=torch.int) # 文本转化为索引
return text_list, torch.tensor(label_list).reshape(-1, 1).to(dtype=torch.float)


train_ds = IMDBDataset(train_data, train_labels)
test_ds = IMDBDataset(test_data, test_labels)

batch_size = 128
train_dl = DataLoader(train_ds, batch_size=batch_size, shuffle=True, collate_fn=collate_fct) #调用回调函数:collate_fn是处理batch的函数!!
test_dl = DataLoader(test_ds, batch_size=batch_size, shuffle=False, collate_fn=collate_fct)

此时的train_dl为:[128,500],为了进一步将其转变为密集向量,将对其转换为one-hot编码:[128,500,10000],相当于将原本的每条数据中的每个id改为对应的one-hot编码(10000为vocab_size)

选择10000*16的矩阵(需要训练,包含权值w),这样通过矩阵相乘,最后可以得到500x16的矩阵,这样500个词每一个词都又一个16的密集向量所表示。

定义模型

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
# target output size of 5
m = nn.AdaptiveAvgPool1d(1) # 自适应平均池化
input = torch.randn(1, 3, 6)
output = m(input)
output.size() #可以看到最后一维变成了1

class AddingModel(nn.Module):
def __init__(self, embedding_dim=16, hidden_dim=64, vocab_size=vocab_size):
super(AddingModel, self).__init__()
self.embeding = nn.Embedding(vocab_size, embedding_dim) # 词嵌入
self.pool = nn.AdaptiveAvgPool1d(1) # 自适应平均池化,对应tf是全局平均值池化
self.layer = nn.Linear(embedding_dim, hidden_dim) # 全连接层
self.fc = nn.Linear(hidden_dim, 1) # 全连接层

def forward(self, x):
# [bs, seq length] [128, 500] [128,500,10000]--->[128,500,16]
x = self.embeding(x)
# [bs, seq length, embedding_dim]-->[bs, embedding_dim, seq length],尺寸[128,500,16]--》[128,16,500]
x = x.permute(0, 2, 1)
x = self.pool(x) # 每个样本变为一个密集向量,在seq_length维度上进行平均池化,[128,16,500]-->[128,16,1]
x=x.squeeze(2) # [bs, embedding_dim, 1] ->[bs, embedding_dim]
# [bs, embedding_dim] -> [bs, hidden_dim]
x = self.layer(x)
x = self.fc(x) # [bs, hidden_dim] -> [bs, 1]

return x


for key, value in AddingModel().named_parameters():
print(f"{key:^40}paramerters num: {np.prod(value.shape)}")

训练

  • 损失函数使用的是二进制交叉熵损失:torch.nn.functional.binary_cross_entropy_with_logits, 先sigmoid再计算交叉熵
  • 二进制交叉熵损失又叫对数似然损失:用于使用一个神经元,做二分类,也即逻辑回归所使用的分类。
  • 而多个神经元做多分类的时候,则使用的是交叉熵损失(只有前面一半)
  • binary_cross_entropy_with_logits与.binary_cross_entropy的区别:在于with_logits会先进行sigmoid,再计算交叉熵。
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
def training(
model,
train_loader,
val_loader,
epoch,
loss_fct,
optimizer,
tensorboard_callback=None,
save_ckpt_callback=None,
early_stop_callback=None,
eval_step=500,
):
record_dict = {
"train": [],
"val": []
}

global_step = 0
model.train()
with tqdm(total=epoch * len(train_loader)) as pbar:
for epoch_id in range(epoch):
# training
for datas, labels in train_loader:
datas = datas.to(device)
labels = labels.to(device)
# 梯度清空
optimizer.zero_grad()
# 模型前向计算
logits = model(datas)
# 计算损失
loss = loss_fct(logits, labels)
# 梯度回传
loss.backward()
# 调整优化器,包括学习率的变动等
optimizer.step()
preds = logits > 0 #当sigmoid输出大于0.5时,预测为1,否则预测为0,这里大于0,刚好sigmoid的值是0.5,预测为1

acc = accuracy_score(labels.cpu().numpy(), preds.cpu().numpy())
loss = loss.cpu().item()
# record

record_dict["train"].append({
"loss": loss, "acc": acc, "step": global_step
})

# evaluating
if global_step % eval_step == 0:
model.eval()
val_loss, val_acc = evaluating(model, val_loader, loss_fct)
record_dict["val"].append({
"loss": val_loss, "acc": val_acc, "step": global_step
})
model.train()

# 1. 使用 tensorboard 可视化
if tensorboard_callback is not None:
tensorboard_callback(
global_step,
loss=loss, val_loss=val_loss,
acc=acc, val_acc=val_acc,
lr=optimizer.param_groups[0]["lr"],
)

# 2. 保存模型权重 save model checkpoint
if save_ckpt_callback is not None:
save_ckpt_callback(global_step, model.state_dict(), metric=val_acc)

# 3. 早停 Early Stop
if early_stop_callback is not None:
early_stop_callback(val_acc)
if early_stop_callback.early_stop:
print(f"Early stop at epoch {epoch_id} / global_step {global_step}")
return record_dict

# udate step
global_step += 1
pbar.update(1)
pbar.set_postfix({"epoch": epoch_id})

return record_dict


epoch = 20

model = AddingModel()

# 1. 定义损失函数 采用二进制交叉熵损失, 先sigmoid再计算交叉熵
loss_fct = F.binary_cross_entropy_with_logits
# loss_fct =nn.BCEWithLogitsLoss()
# 2. 定义优化器 采用 adam
# Optimizers specified in the torch.optim package
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# 1. tensorboard 可视化
if not os.path.exists("runs"):
os.mkdir("runs")
tensorboard_callback = TensorBoardCallback("runs/imdb-adding")
# tensorboard_callback.draw_model(model, [1, MAX_LENGTH])
# 2. save best
if not os.path.exists("checkpoints"):
os.makedirs("checkpoints")
save_ckpt_callback = SaveCheckpointsCallback("checkpoints/imdb-adding", save_step=len(train_dl), save_best_only=True)
# 3. early stop
early_stop_callback = EarlyStopCallback(patience=5)

model = model.to(device)
record = training(
model,
train_dl,
test_dl,
epoch,
loss_fct,
optimizer,
tensorboard_callback=tensorboard_callback,
save_ckpt_callback=save_ckpt_callback,
early_stop_callback=early_stop_callback,
eval_step=len(train_dl)
)