系列项目(正在更新中哦_

『NLU学习』(一)教机器识字

Abstract(摘要)

哈咯,大家好,俺是生而为弟。

在上一期,我们学习了Mikolov的经典,word2vec,这可谓是一石激起千层浪,打开了使用神经网络学习词表征的大门。当然了,word2vec还是有不足的。大家都知道,在深度学习兴起前,主流的是统计机器学习,对于NLP任务,统计信息(statistical information)是相当重要的,而word2vec虽然使用了神经网络,但是抛弃了语料库(corpus)的统计信息,因此就有了本次项目要讲解的一篇论文,Glove(Global Vector),看这个算法的名字就知道它弥补了word2vec忽视了统计信息的缺陷,下面让我们开始今天的学习吧

Prior Knowledge(前置知识)

首先,我们要了解一个概念,共现矩阵(co-occurrence matrix),这是对语料库中单词出现次数的统计数据。我们会划定一个上下文的窗口大小,以此来统计在一个词的上下窗口内,字典中的其余词出现的频率。当然,这里又会牵扯出另一个问题,在一个窗口中,其余词距离中心词的距离是不同的,对于语义而言,无疑是距离越近,两个词的关系越紧密,因此,在实作中,我们会将频率转为距离的倒数。那么,现在我们有了共现矩阵,设为 X X X

共现矩阵 X X X的任一条目(entry) X i j X_{ij} Xij代表单词i的上下文窗口中,单词j出现的频率。最后,设 P i j = P ( j ∣ i ) = X i j / X i P_{ij}=P(j|i)=X_{ij}/X_i Pij=P(ji)=Xij/Xi,代表单词 j j j出现在 i i i的上下文的概率

Glove(算法讲解)

我们从一个简单的例子开始,展示如何直接从共现概率中提取意义的某些方面。

对于一个文本集,不同词之间 w 1 w_1 w1, w 2 w_2 w2与同一个词 w ^ \hat{w} w^之间的共现关系满足规律:如果 w 1 w_1 w1 w 2 w_2 w2语义相近,则 P ( w 1 ∣ w ^ ) P ( w 2 ∣ w ^ ) \frac{P(w_1|\hat{w})}{P(w_2|\hat{w})} P(w2w^)P(w1w^)趋近1。否则, w 1 w_1 w1 w ^ \hat{w} w^越相似, P ( w , w ^ ) P(w,\hat{w}) P(w,w^)越大

下面我们给出满足上述关系的一个一般公式,这里的 F F F便是类似激活函数的非线性映射

F ( w i , w j , w k ^ ) = P i k P j k F(w_i,w_j,\hat{w_k})=\frac{P_{ik}}{P_{jk}} F(wi,wj,wk^)=PjkPik

其中 w w w是词向量, w ^ \hat{w} w^是一个独立的上下文词向量

因为词向量空间本质上是线性结构,因此我们将式子转化为

F ( w i − w j , w k ^ ) = P i k P j k F(w_i-w_j,\hat{w_k})=\frac{P_{ik}}{P_{jk}} F(wiwj,wk^)=PjkPik

然而,我们注意到,上式左端为矢量(vector),右端为标量(scalar),这显然破坏了词向量空间的线性结构,因此我们继续优化式子:

F ( ( w i − w j ) T w k ^ ) = P i k P j k F((w_i-w_j)^T\hat{w_k})=\frac{P_{ik}}{P_{jk}} F((wiwj)Twk^)=PjkPik

下面,要保证式子的对称性,我们要让 w w w w ^ \hat{w} w^是对称的,即 w w ^ = w ^ w w\hat{w}=\hat{w}w ww^=w^w,我们进行如下的操作:

F ( ( w i − w j ) T w k ^ ) = F ( w i T w k ^ − w j T w k ^ ) = P i k P j k F((w_i-w_j)^T\hat{w_k})=F(w_i^T\hat{w_k}-w_j^T\hat{w_k})=\frac{P_{ik}}{P_{jk}} F((wiwj)Twk^)=F(wiTwk^wjTwk^)=PjkPik

式子里减法变为里除法,这不是对数的运算嘛,因此我们令 F F F等于 e x p exp exp

e x p ( w i T w k ^ − w j T w k ^ ) = e x p ( w i T w k ^ ) e x p ( w j T w k ^ ) = P i k P j k exp(w_i^T\hat{w_k}-w_j^T\hat{w_k})=\frac{exp(w_i^T\hat{w_k})}{exp(w_j^T\hat{w_k})}=\frac{P_{ik}}{P_{jk}} exp(wiTwk^wjTwk^)=exp(wjTwk^)exp(wiTwk^)=PjkPik

令分子分母相等,得到:

w i T w k ^ = l o g ( P i k ) = l o g ( X i k X i ) = l o g ( X i k ) − l o g ( x i ) w_i^T\hat{w_k}=log(P_{ik})=log(\frac{X_{ik}}{X_i})=log(X_{ik})-log(x_i) wiTwk^=log(Pik)=log(XiXik)=log(Xik)log(xi)

由于 l o g ( x i ) log(x_i) log(xi)只与 x i x_i xi有关,我们将其改为偏执项 b i b_i bi,同时为了保留对称性,给 w k ^ \hat{w_k} wk^也加上了偏执 b k ^ \hat{b_k} bk^

由此我们得到 w i T w k ^ + b i + b k ^ = l o g ( X i k ) w_i^T\hat{w_k}+b_i+\hat{b_k}=log(X_{ik}) wiTwk^+bi+bk^=log(Xik)

最后,对式子进行变换,乘上权重,就得到了我们的损失函数:

J = ∑ i , j = 1 V f ( X i j ) ( w i T w j ^ + b i + b j ^ − l o g X i j ) 2 J=\sum^V_{i,j=1}f(X_{ij})(w_i^T\hat{w_j}+b_i+\hat{b_j}-log{X_{ij}})^2 J=i,j=1Vf(Xij)(wiTwj^+bi+bj^logXij)2

权重函数图像长这样:

Implement(实作)

解压punkt包

!unzip -oq /home/aistudio/data/data101749/punkt.zip -d data/

安装第三方库

!pip install nltk
!pip install --upgrade visualdl

导入所需库

import nltk
import paddle
import numpy as np
import paddle.nn as nn
import paddle.nn.functional as F
from string import punctuation, digits
from visualdl import LogWriter

定义超参数

# 数据集位置
TEXT_PATH = "data/data86365/chat.txt"
# nltk工具包参数位置
NLTK_PATH = "data/punkt/english.pickle"
# 上下文窗口大小
CONTEXT_SIZE = 10
# 权重函数的参数
ALPHA = 0.75
# 嵌入维度
EMB_DIM = 512
# 批大小
BATCH_SIZE = 8112
# 神经网络初始化范围
INIT_SCALE = 0.01
# 权重函数的参数
X_MAX = 100
# 迭代次数
EPOCH_NUM = 100
# 学习率
LEARNING_RATE = 3e-5
# 神经网络参数保存路径
GLOVE_PATH = "./glove.pdparams"
# 可视化路径
LOG_DIR = "./log"

数据预处理

# 读取数据
with open(TEXT_PATH, "r", encoding="utf8") as f:
    text = f.read().lower()
# 去除标点符号和数字
for i in punctuation + digits:
    text = text.replace(i, " ")
# 导入分词器参数
tokenizer = nltk.data.load(NLTK_PATH)
treebank_tokenize = nltk.TreebankWordTokenizer().tokenize
# 分词
word_lst = treebank_tokenize(text)
print(word_lst[:20])

构造token与id的映射

# 去除重复词,构造字典
vocab = list(set(word_lst))
# 字典大小
vocab_size = len(vocab)
# 词列表的长度
word_lst_size = len(word_lst)
# 词->id的映射
word2id = {word:index for index, word in enumerate(vocab)}

构造共现矩阵

# 构造共现矩阵co-occurrence matrix
co_matrix = np.zeros((vocab_size, vocab_size))
for i in range(word_lst_size):
    for j in range(1, CONTEXT_SIZE+1):
        # 中心词
        index = word2id[word_lst[i]]
        if i-j>0:
            # 上下文
            left_index = word2id[word_lst[i-j]]
            co_matrix[index][left_index] += 1/j
        if i+j<word_lst_size:
            # 上下文
            right_index = word2id[word_lst[i+j]]
            co_matrix[index][right_index] += 1/j

构造数据读取器

class Pairset(object):
    def __init__(self,vocab):
        self.vocab = vocab
    
    # 返回一个batch的数据
    def get_pairs(self):
        pairs = []
        count = 0
        for pair in self.loop():
            w1, w2 = pair[0], pair[1]
            i, j = word2id[w1], word2id[w2]
            if co_matrix[i][j] > 0:
                pairs.append(pair)
                count += 1
            if (count + 1) % BATCH_SIZE == 0:
                yield pairs
                pairs.clear()
                count = 0
    
    # 返回词对
    def loop(self):
        for w1 in self.vocab:
            for w2 in self.vocab:
                yield (w1, w2)

dataset = Pairset(vocab)
data = next(dataset.get_pairs())

定义Glove

class PaddleGlove(nn.Layer):
    def __init__(self):
        super(PaddleGlove, self).__init__()
        # 目标嵌入层w
        self.target_embedding = nn.Embedding(vocab_size, EMB_DIM, sparse=True)
        # 上下文嵌入层w~
        self.context_embedding = nn.Embedding(vocab_size, EMB_DIM, sparse=True)
        # 偏置
        self.bias_i = nn.Embedding(vocab_size, 1, sparse=True)
        self.bias_j = nn.Embedding(vocab_size, 1, sparse=True)
        self._init_emb()
    
    # 参数初始化
    def _init_emb(self):
        self.target_embedding.weight.set_value(paddle.uniform(min=-INIT_SCALE, max=INIT_SCALE, shape=[vocab_size,EMB_DIM]))
        self.context_embedding.weight.set_value(paddle.uniform(min=-INIT_SCALE, max=INIT_SCALE, shape=[vocab_size,EMB_DIM]))
        self.bias_i.weight.set_value(paddle.uniform(min=-1e-6, max=1e-6, shape=[vocab_size,1]))
        self.bias_j.weight.set_value(paddle.uniform(min=-1e-6, max=1e-6, shape=[vocab_size,1]))
    
    # 前向传播
    def forward(self, w1, w2):
        # 获取id
        w1, w2 = [word2id[i] for i in w1], [word2id[i] for i in w2]
        # 获取频率
        freq = [co_matrix[i][j] for i, j in zip(w1, w2)]
        # 获取权重
        f_freq = list(map(self.f, freq))
        # 转化为张量
        w1, w2, freq, f_freq = list(map(paddle.to_tensor, [w1, w2, freq, f_freq]))
        
        # 取出对应的词向量
        bias_1, bias_2 = self.bias_i(w1), self.bias_j(w2)
        emb_1, emb_2 = self.target_embedding(w1), self.context_embedding(w2)
        mat = paddle.matmul(emb_1, emb_2, transpose_y=True)
        mat = paddle.sum(mat, axis=-1)
        # 求损失
        loss = paddle.mean(f_freq * (mat + bias_1 + bias_2 - paddle.log(1 + freq)) ** 2)
        return loss
    
    # 权重函数
    def f(self, freq):
        if freq < X_MAX:
            return (freq / X_MAX) ** ALPHA
        else:
            return 1.

model = PaddleGlove()
data = np.array(data)
y = model(data[:,0], data[:,1])
print(y)

开启训练

'''
开始训练时用AdamW加余弦退火,可快速收敛
最后切换为Momentum加多项式衰减进行调优
'''
schedular = paddle.optimizer.lr.CosineAnnealingDecay(learning_rate=LEARNING_RATE, T_max=2, eta_min=LEARNING_RATE/10)
opt = paddle.optimizer.AdamW(learning_rate=schedular, parameters=model.parameters())
# schedular = paddle.optimizer.lr.PolynomialDecay(learning_rate=LEARNING_RATE * 10, decay_steps=1000, end_lr=LEARNING_RATE)
# opt = paddle.optimizer.Momentum(learning_rate=schedular, parameters=model.parameters())
min_loss = float("inf")
for epoch in range(EPOCH_NUM):
    avg_loss = []
    for i, data in enumerate(dataset.get_pairs()):
        data = np.array(data)
        # 前向计算
        loss = model(data[:,0], data[:,1])
        # 反向传播
        loss.backward()
        # 调整参数
        opt.step()
        # 清除梯度
        opt.clear_grad()
        avg_loss.append(loss.numpy())

        if i % 100 == 0:
            print("epoch:%d,i:%d,loss:%f"%(epoch,i,loss))
    
    print("epoch:%d,avg_loss:%f" % (epoch, sum(avg_loss) / len(avg_loss)))
    
    if sum(avg_loss) / len(avg_loss) < min_loss:
        min_loss = sum(avg_loss) / len(avg_loss)
        print("epoch:%d,min_loss:%f" % (epoch, min_loss))
        paddle.save(model.state_dict(), GLOVE_PATH)

预测

导入参数

state_dict = paddle.load("glove.pdparams")
model.set_dict(state_dict)

构造预测模型

class GloveEval(object):
    def __init__(self, model):
        self.model = model
        # 词向量合并
        self.embedding = self.model.target_embedding.weight + self.model.context_embedding.weight
    
    # 计算单词相似度
    def get_similarity(self, w1, w2):
        emb_1 = self.embedding[word2id[w1]]
        emb_2 = self.embedding[word2id[w2]]
        return F.cosine_similarity(emb_1, emb_2, axis=0)
    
    # 寻找与目标词最相似的前k个词
    def get_similar_tokens(self, query_token, k):
        topk, cos = self.knn(self.embedding, self.embedding[word2id[query_token]], k+1)
        for i, c in zip(topk[1:], cos[1:]):  # 除去输入词
            print('cosine sim = %.3f: %s' % (c, (vocab[i])))
    
    def knn(self, W, x, k):
        # 添加的1e-9是为了数值稳定性
        cos = paddle.matmul(W, x.reshape((-1,))) / (
            (paddle.sum(W * W, axis=1) + 1e-9).sqrt() * paddle.sum(x * x).sqrt())
        _, topk = paddle.topk(cos, k=k)
        topk = topk.numpy()
        obj = [cos[i.tolist()] for i in topk]
        return topk, [cos[i.tolist()] for i in topk]
glove_eval = GloveEval(model)
glove_eval.get_similar_tokens("food", 3)
cosine sim = 0.194: dollars
cosine sim = 0.191: pumps
cosine sim = 0.181: details

词向量可视化

id_lst = list(range(1000))
token_lst = [vocab[i] for i in id_lst]
embedding_lst = [glove_eval.embedding[i] for i in id_lst]
with LogWriter(logdir=LOG_DIR) as writer:
    writer.add_embeddings(tag="domain", mat=embedding_lst, metadata=token_lst)

结果展示

总结

本节我们学习了:

  1. word2id的不足
  2. 共现矩阵的意义
  3. Glove的损失函数的推导

参考文献

关于作者

我在AI Studio上获得钻石等级,点亮8个徽章,来互关呀~

Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐