参考

感谢以上工作者对我学习的帮助。

If I have seen further, it is by standing on the shoulders of giants.

前言

关于我最近用于记录自己测试模型的文章,一概不会对其原理进行介绍,并非不想,实在是本人所学甚浅,怕自己误人子弟的原因。我自己在学习的时候也会去网上进行查找资料,发现经常出现专业名词误译、用词不当和概念误解等情况。

我会在系统学习完NLP领域的知识后,再单独写一个解说版,并大概率会附上一个解说视频(坑)。

简易中文数据集生成

所需数据格式

我们目标的格式是用空格分割词的长文本,英文数据的处理非常简单,毕竟本来就是用空格来分割词的,如下图:

数据实例1

而中文需要人工去分词,我找了很久都没有所需格式的中小型中文数据集,所以需要自己用分词库简易生成一个。

数据源

下面将使用从网上下载的《剑来》小说的TXT文本来进行处理,读者可用其他的TXT文本进行处理,但不能保证数据的清洁度足够,可能需要读者自行处理。

由于涉及到版权问题,这里将不会提供完整的数据,请读者自行下载,这里是下载地址:《剑来》

数据生成

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
# utf-8
# thulac vrsion = 0.2.1
# pytorch vrsion = 1.8.0
# pyecharts vrsion = 1.9.0
# code by Angel Hair

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.utils.data as tud
# print(torch.__version__)

from collections import Counter
import numpy as np
import random

import pyecharts.options as opts
from pyecharts.charts import Scatter

from sklearn.metrics.pairwise import cosine_similarity
from sklearn.manifold import TSNE

import scipy
import thulac


# 读入文本
lines = []
with open("剑来.txt", "r", encoding="gbk") as f:
lines = f.readlines()

# 清理数据
data = []
for line in lines:
line = line.strip()
if line != "":
data.append(line)

# 分词
with open("p_input.txt", "w", encoding="gbk") as f:
f.write("\n".join(data))

thu1 = thulac.thulac(seg_only=True)
thu1.cut_f("p_input.txt", "p_output.txt")

# 标点符号集
stopwords = '''~!@#$%^&*()_+`1234567890-={}[]::";'<>,.?/|\、·!()¥“”‘’《》,。?/—-【】….'''
stopwords_set = set([i for i in stopwords])
stopwords_set.add("br") # 异常词也加入此集,方便去除

with open("p_output.txt", "r", encoding="gbk") as f:
lines = f.readlines()

# 数据清理
data = []
for line in lines:
for s in stopwords_set:
line = line.strip().replace(s, "")
line = line.replace(" "," ").replace(" "," ")
if line != "" and line != " ":
data.append(line)

# 保存数据
with open("all.txt", "w", encoding="gbk") as f:
f.write(" ".join(data))

处理较慢,建议去听一两首歌(‾◡◝) —— Evelonda - SNKS

生成的数据如下图:

数据实例2

数据集生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 分割数据为训练集、测试集和验证集,并保存
all_text = ""
with open("all.txt", "r", encoding="gbk") as f:
all_text = f.readline()

all_len = len(all_text)
train_text = all_text[:int(all_len*0.9)]
dev_text = all_text[int(all_len*0.9):int(all_len*0.95)]
test_text = all_text[int(all_len*0.95):]

with open("dev.txt", "w", encoding="gbk") as f:
f.write(dev_text)
with open("test.txt", "w", encoding="gbk") as f:
f.write(test_text)
with open("train.txt", "w", encoding="gbk") as f:
f.write(train_text)

参数设置

优先设置参数可以方便我们之后Dataloader的创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 设置所有参数

# 对每个库设置随机种子
random.seed(1)
np.random.seed(1)
torch.manual_seed(1)

C = 3 # 窗口大小,注意这里的窗口大小为分别向左边和右边取C个词
K = 15 # 负采样样本数(噪声词)
epochs = 2
MAX_VOCAB_SIZE = 10000
EMBEDDING_SIZE = 100
batch_size = 32
lr = 0.2

数据预处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 读取并分割
text = ""
with open("train.txt", "r", encoding="gbk") as f:
text = f.read()

text = text.lower().split() # 分割成单词列表
vocab_dict = dict(Counter(text).most_common(MAX_VOCAB_SIZE - 1)) # 得到单词字典表,key是单词,value是次数
vocab_dict['<UNK>'] = len(text) - np.sum(list(vocab_dict.values())) # 把不常用的单词都编码为"<UNK>"

# 构建词值对
word2idx = {word:i for i, word in enumerate(vocab_dict.keys())}
idx2word = {i:word for i, word in enumerate(vocab_dict.keys())}

# 计算和处理频率
word_counts = np.array(list(vocab_dict.values()), dtype=np.float32)
word_freqs = (word_counts / np.sum(word_counts))** (3./4.) # 所有的频率为原来的 0.75 次方, 论文中的推荐方法,由图像分析可推测这样可以一定程度上提高频率低的权重,降低频率高的权重

函数图像1

构建Dataloader

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
class WordEmbeddingDataset(tud.Dataset):
def __init__(self, text, word2idx, word_freqs):
'''
:text: a list of words, all text from the training dataset
:word2idx: the dictionary from word to index
:word_freqs: the frequency of each word
'''
super(WordEmbeddingDataset, self).__init__()
# 注意下面重写的方法
self.text_encoded = [word2idx.get(word, word2idx['<UNK>']) for word in text] # 把单词数字化表示。如果不在词典中,也表示为unk
self.text_encoded = torch.LongTensor(self.text_encoded) # nn.Embedding需要传入LongTensor类型
self.word2idx = word2idx
self.word_freqs = torch.Tensor(word_freqs)


def __len__(self):
return len(self.text_encoded) # 返回所有单词的总数,即item的总数

def __getitem__(self, idx):
''' 这个function用于返回:中心词(center_words),周围词(pos_words),负采样词(neg_words)
:idx: 中心词索引
'''
center_words = self.text_encoded[idx] # 取得中心词
pos_indices = list(range(idx - C, idx)) + list(range(idx + 1, idx + C + 1)) # 取出所有周围词索引,用于下面的tensor(list)操作
pos_indices = [i % len(self.text_encoded) for i in pos_indices] # 为了避免索引越界,所以进行取余处理,如:设总词数为100,则[1]取余为[1],而[101]取余为[1]
pos_words = self.text_encoded[pos_indices] # tensor(list)操作,取出所有周围词
neg_words = torch.multinomial(self.word_freqs, K * pos_words.shape[0], True)
# torch.multinomial作用是对self.word_freqs做K * pos_words.shape[0]次取值,输出的是self.word_freqs对应的下标
# 取样方式采用有放回的采样,并且self.word_freqs数值越大,取样概率越大
# 实际上从这里就可以看出,这里用的是skip-gram方法,并且采用负采样(Negative Sampling)进行优化

# while 循环是为了保证 neg_words中不能包含周围词
# Angel Hair:实际上不需要这么处理,因为我们遇到的都是非常大的数据,会导致取到周围词的概率非常非常小,这里之所以这么做是因为本文和参考文所提供的数据太小,导致这个概率变大了,会影响模型
while len(set(pos_indices) & set(neg_words.numpy().tolist())) > 0:
neg_words = torch.multinomial(self.word_freqs, K * pos_words.shape[0], True)

return center_words, pos_words, neg_words


dataset = WordEmbeddingDataset(text, word2idx, word_freqs)
dataloader = tud.DataLoader(dataset, batch_size, shuffle=True)

模型构建

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
class EmbeddingModel(nn.Module):
def __init__(self, vocab_size, embed_size):
super(EmbeddingModel, self).__init__()

self.vocab_size = vocab_size
self.embed_size = embed_size

self.in_embed = nn.Embedding(self.vocab_size, self.embed_size)
self.out_embed = nn.Embedding(self.vocab_size, self.embed_size)


def forward(self, input_labels, pos_labels, neg_labels):
'''return: loss, [batch_size]
:input_labels: center words, [batch_size]
:pos_labels: positive words, [batch_size, (C * 2)]
:neg_labels:negative words, [batch_size, (C * 2 * K)]
'''

input_embedding = self.in_embed(input_labels) # [batch_size, embed_size]

pos_embedding = self.out_embed(pos_labels)# [batch_size, (window * 2), embed_size]

neg_embedding = self.out_embed(neg_labels) # [batch_size, (window * 2 * K), embed_size]

input_embedding = input_embedding.unsqueeze(2) # [batch_size, embed_size, 1]

pos_dot = torch.bmm(pos_embedding, input_embedding) # [batch_size, (window * 2), 1]
pos_dot = pos_dot.squeeze(2) # [batch_size, (window * 2)]

neg_dot = torch.bmm(neg_embedding, -input_embedding) # [batch_size, (window * 2 * K), 1]
# 注意负号,参考公式可以知负样本的概率越小越好,所以位负号
neg_dot = neg_dot.squeeze(2) # batch_size, (window * 2 * K)]

log_pos = F.logsigmoid(pos_dot).sum(1) # .sum()结果只为一个数,.sum(1)结果是一维的张量
log_neg = F.logsigmoid(neg_dot).sum(1)

loss = log_pos + log_neg # 理论上应该除2,实际除不除一样

return -loss # 我们希望概率迭代越大越好,加一个负值,变成越小越好,使之可以正确迭代


def input_embedding(self):
return self.in_embed.weight.detach().numpy()


model = EmbeddingModel(MAX_VOCAB_SIZE, EMBEDDING_SIZE)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

模型训练和保存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for e in range(1):
for i, (input_labels, pos_labels, neg_labels) in enumerate(dataloader):
input_labels = input_labels.long()
pos_labels = pos_labels.long()
neg_labels = neg_labels.long()

optimizer.zero_grad()
loss = model(input_labels, pos_labels, neg_labels).mean() #.mean()默认不设置dim的时候,返回的是所有元素的平均值
loss.backward()
optimizer.step()

if i % 100 == 0:
print('epoch', e, 'iteration', i, loss.item())


embedding_weights = model.input_embedding()
torch.save(model.state_dict(), "embedding-{}.th".format(EMBEDDING_SIZE))

简单应用

在测试完代码后,我刚好到答辩的时间段了,事情变得多起来了,关于怎么应用和模型评估的问题暂时被搁置。下面随便举了两个例子(其中一个来自参考),等我有空再来补全。

相近词性查找

以下函数用于找出词性相近的词:

1
2
3
4
5
6
7
8
def find_nearest(word):
index = word2idx[word]
embedding = embedding_weights[index]
cos_dis = np.array([scipy.spatial.distance.cosine(e, embedding) for e in embedding_weights])
print(cos_dis.shape)
return [idx2word[i] for i in cos_dis.argsort()[:10]]

# 注意这里必须计算余弦相似度,而不是欧氏距离,所以不应该画散点图来展示词分类

如我们输入:

1
> find_nearest("陈平安")

可以得到其他小说中人物的名字,因为它们词性相近。

降维可视化

如果想要生成一张展示词性分类的图,可以参考以下的代码,但结果一般不会太理想。

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
# 降维并绘制前300个词的关联散点图

# TSNE降维
tsne = TSNE(n_components=2, learning_rate=100).fit_transform(embedding_weights)

x_data =[]
y_data =[]
index = 300 # 注意我们这里为了防止数据太过密集,只取前300个词来进行绘制
for i, label in enumerate(list(word2idx.keys())[:index]):
x, y = float(tsne[i][0]), float(tsne[i][1])
x_data.append(x)
y_data.append((y, label))

(
Scatter(init_opts=opts.InitOpts(width="16000px", height="10000px"))
.add_xaxis(xaxis_data=x_data)
.add_yaxis(
series_name="",
y_axis=y_data,
symbol_size=50,
label_opts=opts.LabelOpts(
font_size=50,
formatter=JsCode(
"function(params){return params.value[2];}"
)
),
)
.set_series_opts()
.set_global_opts(
xaxis_opts=opts.AxisOpts(type_="value"),
yaxis_opts=opts.AxisOpts(
type_="value",
axistick_opts=opts.AxisTickOpts(is_show=True),
),
tooltip_opts=opts.TooltipOpts(is_show=False),
)
.render("scatter.html")
)

生成的图像保存在本地文件scatter.html内,原图超大,请适当进行缩放来查看。

以下为我选取的比较好的部分截图:

示例1

示例2

由此可以简单看出来词之间的一些相关性,虽然效果比较差,但我实在没空调参和优化了,暂时先这样吧_(:зゝ∠)_

后记

已经放弃找工作了,因为被导师和辅导员骂去考研了,说实话,总觉得他们高看我了,我平时成绩很差的……