词嵌入word2vec、w2v进阶、文本处理

词嵌入word2vec

word2vec

在学习w2v之前,我们都是使用one-hot向量表示单词,one-hot构造起来非常容易,但是其实有个缺点,就是无法准确表达不同词语之间的相似度,而w2v的提出,就是为了解决这个问题。

word2vec的定义是将每个单词表示成一个定长的向量,通过在语料库上的预训练使得向量能够较好地表达出不同词语之间的相似和类比关系,以引入一定的语义信息.

常用的两种w2v模型:

1.Skip-Gram 跳字模型

假设背景词由中心词生成,即建模 P ( w o ∣ w c ) P(w_o\mid w_c) P(wowc),其中 w c w_c wc 为中心词, w o w_o wo 为任一背景词;

Image Name

2. CBOW (continuous bag-of-words) 连续词袋模型:假设中心词由背景词生成,即建模 P ( w c ∣ W o ) P(w_c\mid \mathcal{W}_o) P(wcWo),其中 W o \mathcal{W}_o Wo 为背景词的集合。

Image Name

Skip-Gram 跳字模型原理讲述

在跳字模型中,每个词被表示成两个 d d d 维向量,用来计算条件概率。假设这个词在词典中索引为 i i i ,当它为中心词时向量表示为 v i ∈ R d \boldsymbol{v}_i\in\mathbb{R}^d viRd,而为背景词时向量表示为 u i ∈ R d \boldsymbol{u}_i\in\mathbb{R}^d uiRd 。设中心词 w c w_c wc 在词典中索引为 c c c,背景词 w o w_o wo 在词典中索引为 o o o,我们假设给定中心词生成背景词的条件概率满足下式:

P ( w o ∣ w c ) = exp ⁡ ( u o ⊤ v c ) ∑ i ∈ V exp ⁡ ( u i ⊤ v c ) P(w_o\mid w_c)=\frac{\exp(\boldsymbol{u}_o^\top \boldsymbol{v}_c)}{\sum_{i\in\mathcal{V}}\exp(\boldsymbol{u}_i^\top \boldsymbol{v}_c)} P(wowc)=iVexp(uivc)exp(uovc)
进行的是词向量内积和softmax计算,在这里插入图片描述,在训练中不断优化词向量u和v使得预测值逐渐接近真实值

使用W2V建立单词向量预训练模型

1. 首先载入数据集

这里使用的是PTB数据集为例子,数据集大多数都有相同的形式,大家理解怎么去读取数据就可以了

with open('/ptb.train.txt', 'r') as f:
    lines = f.readlines() # 该数据集中句子以换行符为分割
    raw_dataset = [st.split() for st in lines] # st是sentence的缩写,单词以空格为分割
print('# sentences: %d' % len(raw_dataset))

# 对于数据集的前3个句子,打印每个句子的词数和前5个词

for st in raw_dataset[:3]:
    print('# tokens:', len(st), st[:5])

输出结果:

 sentences: 42068
 tokens: 24 ['aer', 'banknote', 'berlitz', 'calloway', 'centrust']
 tokens: 15 ['pierre', '<unk>', 'N', 'years', 'old']
 tokens: 11 ['mr.', '<unk>', 'is', 'chairman', 'of']

由输出结果可知有42068个句子,通过split函数已经把句子分割成一个个list,单词也被分割好了。其中句尾符为 ‘<eos>’ ,生僻词全用 ‘<unk>’ 表示,数字则被替换成了 ‘N’

  • 在词典中去掉出现频率极低的词,或将其在文本中替换为 等特殊字符,因为大语料库中通常含有非常多的低频词,若不对其进行处理,将会严重损害模型的泛化能力,甚至降低高频词词向量的质量,同时,更大的词典也会意味着更大的存储和计算开销
2. 建立词语索引
counter = collections.Counter([tk for st in raw_dataset for tk in st]) # tk是token的缩写
counter = dict(filter(lambda x: x[1] >= 5, counter.items())) # 只保留在数据集中至少出现5次的词,词频

Counter函数的结果是构造词典dict,key对应的是词典的元素(词语orToken),value对应的是该元素在集合出现的次数,例如{‘apple’: 3,‘years’: 1241, ‘old’: 268…},可以看作一个大字典

idx_to_token = [tk for tk, _ in counter.items()] #得到单词的列表
token_to_idx = {tk: idx for idx, tk in enumerate(idx_to_token)}   #得到key为单词列表,value为整数下标的dict
dataset = [[token_to_idx[tk] for tk in st if tk in token_to_idx]
           for st in raw_dataset] # raw_dataset中的单词在这一步被转换为对应的idx
#在rawdata中对应的是所有句子列表中,把单词换成下标列表.
num_tokens = sum([len(st) for st in dataset])
print('# tokens: %d' % num_tokens)  #总共有887100个单词

输出idx_to_token :[‘pierre’, ‘’, ‘N’…] 你的单词列表
输出token_to_idx :{‘pierre’: 0, ‘’: 1, ‘N’: 2,…} 带有单词对应下标的dict
输出dataset :[[0,1,5,12,54,25,5],[6,2,5,4,8,6,9,4,]…] 对应每个句子的下标

3. 二次采样

通常文本数据会有一些高频词,例如the、and、in,我们认为在一个背景窗口中,一个词与低频词一起出现,比与较高词频的词语同时出现对训练次嵌入模型是有益的,因此我们打算训练模型的时候可以对单词进行二次采样。
二次采样就是数据集中每个被索引词 w i w_i wi 将有一定概率被丢弃,该丢弃概率为
P ( w i ) = max ⁡ ( 1 − t f ( w i ) , 0 ) P(w_i)=\max(1-\sqrt{\frac{t}{f(w_i)}},0) P(wi)=max(1f(wi)t ,0)

其中 f ( w i ) f(w_i) f(wi) 是数据集中词 w i w_i wi 的个数与总词数之比,常数 t t t 是一个超参数(实验中设为 1 0 − 4 10^{−4} 104)。可见,只有当 f ( w i ) > t f(w_i)>t f(wi)>t 时,我们才有可能在二次采样中丢弃词 w i w_i wi,并且越高频的词被丢弃的概率越大。具体的代码如下:

def discard(idx):
    '''
    @params:
        idx: 单词的下标
    @return: True/False 表示是否丢弃该单词
    '''
    return random.uniform(0, 1) < 1 - math.sqrt(
        1e-4 / counter[idx_to_token[idx]] * num_tokens)
#上面这个函数就是我们的计算公式

subsampled_dataset = [[tk for tk in st if not discard(tk)] for st in dataset]
print('# tokens: %d' % sum([len(st) for st in subsampled_dataset]))
#输出为tokens: 375083,单词量足足少了不止一半,之前是88w

#这是个比较两个单词做二次采样的数目前后对比
def compare_counts(token):
    return '# %s: before=%d, after=%d' % (token, sum(
        [st.count(token_to_idx[token]) for st in dataset]), sum(
        [st.count(token_to_idx[token]) for st in subsampled_dataset]))

print(compare_counts('the'))
print(compare_counts('join'))
# the: before=50770, after=2140 
# join: before=45, after=45
4.提取背景词和中心词

背景窗口大小我们采用随机的方式

def get_centers_and_contexts(dataset, max_window_size):
    '''
    @params:
        dataset: 数据集为句子的集合,每个句子则为单词的集合,此时单词已经被转换为相应数字下标
        max_window_size: 背景词的词窗大小的最大值
    @return:
        centers: 中心词的集合
        contexts: 背景词窗的集合,与中心词对应,每个背景词窗则为背景词的集合
    '''
    centers, contexts = [], []
    for st in dataset:
        if len(st) < 2:  # 每个句子至少要有2个词才可能组成一对“中心词-背景词”
            continue
        centers += st
        for center_i in range(len(st)):
            window_size = random.randint(1, max_window_size) # 随机选取背景词窗大小
            indices = list(range(max(0, center_i - window_size),
                                 min(len(st), center_i + 1 + window_size)))
            indices.remove(center_i)  # 将中心词排除在背景词之外
            contexts.append([st[idx] for idx in indices])
    return centers, contexts

all_centers, all_contexts = get_centers_and_contexts(subsampled_dataset, 5)

##测试功能的代码:
tiny_dataset = [list(range(7)), list(range(7, 10))]
print('dataset', tiny_dataset)
for center, context in zip(*get_centers_and_contexts(tiny_dataset, 2)):
    print('center', center, 'has contexts', context)
5. 负采样近似

由于 softmax 运算考虑了背景词可能是词典 V \mathcal{V} V 中的任一词,对于含几十万或上百万词的较大词典,就可能导致计算的开销过大。我们将以 skip-gram 模型为例,介绍负采样 (negative sampling) 的实现来尝试解决这个问题。

负采样方法用以下公式来近似条件概率 P ( w o ∣ w c ) = exp ⁡ ( u o ⊤ v c ) ∑ i ∈ V exp ⁡ ( u i ⊤ v c ) P(w_o\mid w_c)=\frac{\exp(\boldsymbol{u}_o^\top \boldsymbol{v}_c)}{\sum_{i\in\mathcal{V}}\exp(\boldsymbol{u}_i^\top \boldsymbol{v}_c)} P(wowc)=iVexp(uivc)exp(uovc)

P ( w o ∣ w c ) = P ( D = 1 ∣ w c , w o ) ∏ k = 1 , w k ∼ P ( w ) K P ( D = 0 ∣ w c , w k ) P(w_o\mid w_c)=P(D=1\mid w_c,w_o)\prod_{k=1,w_k\sim P(w)}^K P(D=0\mid w_c,w_k) P(wowc)=P(D=1wc,wo)k=1,wkP(w)KP(D=0wc,wk)

其中 P ( D = 1 ∣ w c , w o ) = σ ( u o ⊤ v c ) P(D=1\mid w_c,w_o)=\sigma(\boldsymbol{u}_o^\top\boldsymbol{v}_c) P(D=1wc,wo)=σ(uovc) σ ( ⋅ ) \sigma(\cdot) σ() 为 sigmoid 函数。对于一对中心词和背景词,我们从词典中随机采样 K K K 个噪声词(实验中设 K = 5 K=5 K=5)。根据 Word2Vec 论文的建议,噪声词采样概率 P ( w ) P(w) P(w) 设为 w w w 词频与总词频之比的 0.75 0.75 0.75 次方。

每个背景词取K个噪音词,噪声词不能是背景词,使用random.choices选取噪声词

def get_negatives(all_contexts, sampling_weights, K):
    '''
    @params:
        all_contexts: [[w_o1, w_o2, ...], [...], ... ]   所有的背景词窗口
        sampling_weights: 每个单词的噪声词采样概率
        K: 随机采样个数
    @return:
        all_negatives: [[w_n1, w_n2, ...], [...], ...]
    '''
    all_negatives, neg_candidates, i = [], [], 0
    population = list(range(len(sampling_weights)))
    for contexts in all_contexts:
        negatives = []
        while len(negatives) < len(contexts) * K:
            if i == len(neg_candidates):
                # 根据每个词的权重(sampling_weights)随机生成k个词的索引作为噪声词。
                # 为了高效计算,可以将k设得稍大一点
                i, neg_candidates = 0, random.choices(
                    population, sampling_weights, k=int(1e5))
            neg, i = neg_candidates[i], i + 1
            # 噪声词不能是背景词
            if neg not in set(contexts):
                negatives.append(neg)
        all_negatives.append(negatives)
    return all_negatives

sampling_weights = [counter[w]**0.75 for w in idx_to_token]
all_negatives = get_negatives(all_contexts, sampling_weights, 5)

除负采样方法外,还有层序 softmax (hiererarchical softmax) 方法也可以用来解决计算量过大的问题,请参考原书10.2.2节。*

6. 批量读取数据

创建MyDataset类,加载处理后的数据,集成torch的Dataset类,有getitem和len的方法供我们使用

class MyDataset(torch.utils.data.Dataset):
    def __init__(self, centers, contexts, negatives):
        assert len(centers) == len(contexts) == len(negatives)
        self.centers = centers
        self.contexts = contexts
        self.negatives = negatives
        
    def __getitem__(self, index):
        return (self.centers[index], self.contexts[index], self.negatives[index])

    def __len__(self):
        return len(self.centers)
  • 因为我们需要通过小批量的方式输入到模型,可以加速训练过程,所以要把数据分成一个个batch。
  • 我们之前已经知道每个样本的背景窗口大小可能不一样,是随机生成的,所以我们对比较小的size的窗口进行填充,用“01掩码mask“来识别是否为真值;
  • 我们会把每个样本的背景词和噪声词连接在一起为contexts_negatives,为了区分正类和负类,需要生成标签label,去分辨哪些是背景词,哪些是噪声词,按照掩码变量的思路,可以创建与contexts_negatives形状相同的标签变量labels,并将与背景词(正类)对应的元素设1,其余清0。
def batchify(data):
    '''
    用作DataLoader的参数collate_fn
    @params:
        data: 长为batch_size的列表,列表中的每个元素都是__getitem__得到的结果
    @outputs:
        batch: 批量化后得到 (centers, contexts_negatives, masks, labels) 元组
            centers: 中心词下标,形状为 (n, 1) 的整数张量
            contexts_negatives: 背景词和噪声词的下标,形状为 (n, m) 的整数张量
            masks: 与补齐相对应的掩码,形状为 (n, m) 的0/1整数张量
            labels: 指示中心词的标签,形状为 (n, m) 的0/1整数张量
    '''
    max_len = max(len(c) + len(n) for _, c, n in data)
    centers, contexts_negatives, masks, labels = [], [], [], []
    for center, context, negative in data:
        cur_len = len(context) + len(negative)
        centers += [center]
        contexts_negatives += [context + negative + [0] * (max_len - cur_len)]
        masks += [[1] * cur_len + [0] * (max_len - cur_len)] # 使用掩码变量mask来避免填充项对损失函数计算的影响
        labels += [[1] * len(context) + [0] * (max_len - len(context))]
        batch = (torch.tensor(centers).view(-1, 1), torch.tensor(contexts_negatives),
            torch.tensor(masks), torch.tensor(labels))
    return batch

测试代码:

batch_size = 512
num_workers = 0 if sys.platform.startswith('win32') else 4

dataset = MyDataset(all_centers, all_contexts, all_negatives)
data_iter = Data.DataLoader(dataset, batch_size, shuffle=True,
                            collate_fn=batchify, 
                            num_workers=num_workers)
for batch in data_iter:
    for name, data in zip(['centers', 'contexts_negatives', 'masks',
                           'labels'], batch):
        print(name, 'shape:', data.shape)
    break
查看第一个batch:
centers shape: torch.Size([512, 1])    512个中心词,60个背景词和噪声词的连接大小
contexts_negatives shape: torch.Size([512, 24])
masks shape: torch.Size([512, 24])
labels shape: torch.Size([512, 24])
7. 训练模型

定义损失函数
应用负采样方法后,我们可利用最大似然估计的对数等价形式将损失函数定义为如下

∑ t = 1 T ∑ − m ≤ j ≤ m , j ≠ 0 [ − log ⁡ P ( D = 1 ∣ w ( t ) , w ( t + j ) ) − ∑ k = 1 , w k ∼ P ( w ) K log ⁡ P ( D = 0 ∣ w ( t ) , w k ) ] \sum_{t=1}^T\sum_{-m\le j\le m,j\ne 0} [-\log P(D=1\mid w^{(t)},w^{(t+j)})-\sum_{k=1,w_k\sim P(w)^K}\log P(D=0\mid w^{(t)},w_k)] t=1Tmjm,j=0[logP(D=1w(t),w(t+j))k=1,wkP(w)KlogP(D=0w(t),wk)]

根据这个损失函数的定义,我们可以直接使用二元交叉熵损失函数进行计算:

class SigmoidBinaryCrossEntropyLoss(nn.Module):
    def __init__(self):
        super(SigmoidBinaryCrossEntropyLoss, self).__init__()
    def forward(self, inputs, targets, mask=None):
        '''
        @params:
            inputs: 经过sigmoid层后为预测D=1的概率
            targets: 0/1向量,1代表背景词,0代表噪音词
        @return:
            res: 平均到每个label的loss
        '''
        inputs, targets, mask = inputs.float(), targets.float(), mask.float()
        res = nn.functional.binary_cross_entropy_with_logits(inputs, targets, reduction="none", weight=mask)
        res = res.sum(dim=1) / mask.float().sum(dim=1)
        return res

loss = SigmoidBinaryCrossEntropyLoss()

pred = torch.tensor([[1.5, 0.3, -1, 2], [1.1, -0.6, 2.2, 0.4]])
label = torch.tensor([[1, 0, 0, 0], [1, 1, 0, 0]]) # 标签变量label中的1和0分别代表背景词和噪声词
mask = torch.tensor([[1, 1, 1, 1], [1, 1, 1, 0]])  # 掩码变量
print(loss(pred, label, mask))

## 第二种方法:直接写入函数
def sigmd(x):
    return - math.log(1 / (1 + math.exp(-x)))
print('%.4f' % ((sigmd(1.5) + sigmd(-0.3) + sigmd(1) + sigmd(-2)) / 4)) # 注意1-sigmoid(x) = sigmoid(-x)
print('%.4f' % ((sigmd(1.1) + sigmd(-0.6) + sigmd(-2.2)) / 3))

两种方法都可以,第一个是调用pytorch里面的方法;第二种方法:直接写入函数
值得一提的是,我们可以通过掩码变量指定小批量中参与损失函数计算的部分预测值和标签:当掩码为1时,相应位置的预测值和标签将参与损失函数的计算;当掩码为0时,相应位置的预测值和标签则不参与损失函数的计算。我们之前提到,掩码变量可用于避免填充项对损失函数计算的影响。

8.模型初始化

我们需要初始化中心词和背景词的嵌入层,所以需要两层embedding层
由于 skip-gram 模型(或 CBOW 模型)的假设中,中心词和背景词都处于一种不对称的关系,而模型的数学表达式里,向量的点积项 u ⊤ v u^\top v uv却又是对称的,所以只能通过引入两个词嵌入层来保留假设中的非对称关系

embed_size = 100
net = nn.Sequential(nn.Embedding(num_embeddings=len(idx_to_token), embedding_dim=embed_size),
                    nn.Embedding(num_embeddings=len(idx_to_token), embedding_dim=embed_size))

希望输出的向量是100维度,而输入的是词语的数量

9.训练模型
def train(net, lr, num_epochs):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    print("train on", device)
    net = net.to(device)
    optimizer = torch.optim.Adam(net.parameters(), lr=lr)
    for epoch in range(num_epochs):
        start, l_sum, n = time.time(), 0.0, 0
        for batch in data_iter:
            center, context_negative, mask, label = [d.to(device) for d in batch]
            
            pred = skip_gram(center, context_negative, net[0], net[1])
            
            l = loss(pred.view(label.shape), label, mask).mean() # 一个batch的平均loss
            optimizer.zero_grad()
            l.backward()
            optimizer.step()
            l_sum += l.cpu().item()
            n += 1
        print('epoch %d, loss %.2f, time %.2fs'
              % (epoch + 1, l_sum / n, time.time() - start))

train(net, 0.01, 5)

train on cpu
epoch 1, loss 0.61, time 221.30s
epoch 2, loss 0.42, time 227.70s
epoch 3, loss 0.38, time 240.50s
epoch 4, loss 0.36, time 253.79s
epoch 5, loss 0.34, time 238.51s
10. 测试模型
def get_similar_tokens(query_token, k, embed):
    '''
    @params:
        query_token: 给定的词语
        k: 近义词的个数
        embed: 预训练词向量
    '''
    W = embed.weight.data
    x = W[token_to_idx[query_token]]
    # 添加的1e-9是为了数值稳定性
    cos = torch.matmul(W, x) / (torch.sum(W * W, dim=1) * torch.sum(x * x) + 1e-9).sqrt()
    _, topk = torch.topk(cos, k=k+1)
    topk = topk.cpu().numpy()
    for i in topk[1:]:  # 除去输入词
        print('cosine sim=%.3f: %s' % (cos[i], (idx_to_token[i])))
        
get_similar_tokens('chip', 3, net[0])


cosine sim=0.446: intel
cosine sim=0.427: computer
cosine sim=0.427: computers

pytorch工具的使用

embed = nn.Embedding(num_embeddings=10, embedding_dim=4)
print(embed.weight)

x = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.long)
print(embed(x))

输出:
tensor([[-0.8425,  2.2062, -1.1831,  1.9467],
        [-0.4735,  0.0312, -0.5686,  0.5994],
        [-0.0989,  0.3709,  0.0144, -0.1143],
        [-2.6188, -0.3930, -0.2735,  0.6159],
        [ 0.9416,  0.9269,  0.1549,  0.0290],
        [-0.3029,  0.1575, -0.7693,  0.3872],
        [ 0.5778, -0.1491, -0.3243,  0.5788],
        [-0.3606,  0.3941,  0.2623, -0.9169],
        [ 0.1449,  1.0764, -1.2652,  0.8308],
        [ 0.7786, -0.1781,  0.0840, -0.3101]], requires_grad=True)
tensor([[[-0.4735,  0.0312, -0.5686,  0.5994],
         [-0.0989,  0.3709,  0.0144, -0.1143],
         [-2.6188, -0.3930, -0.2735,  0.6159]],

        [[ 0.9416,  0.9269,  0.1549,  0.0290],
         [-0.3029,  0.1575, -0.7693,  0.3872],
         [ 0.5778, -0.1491, -0.3243,  0.5788]]], grad_fn=<EmbeddingBackward>)

pytorch自带有embedding层,num_embeddings是单词的数量(我觉得是经过处理后的包括重复的总共单词数量),embedding_dim是输出的向量维度,这一层的参数竖着看是单词个数,横着看为单词的维度数

上面的例子中,有一个2x3的向量,放入embedding层,计算得到2x3x4的张量,每个3维的单词被展示成4维的向量

批量乘法

2为批量大小,1x4和4x6是两个矩阵的相乘

X = torch.ones((2, 1, 4))
Y = torch.ones((2, 4, 6))
print(torch.bmm(X, Y).shape)
torch.Size([2, 1, 6])

w2v进阶

任何方法都会有其一些需要改进的地方,虽然w2v能成功把离散的单词转换为连续的词向量,一定程度上保存词与词之间的近似关系,但是有一些更好地方法去做出了改进:

  1. subword embedding就是一种,FastText属于这种方法的范畴里,以固定大小的n-gram形式将单词更细致地表示为子词的集合; 而BPE(byte pair encoding)方法能根据语料库的统计信息,自动且动态地生成高频子词的集合;
  2. GloVe 全局向量的词嵌入通过等价转换 Word2Vec 模型的条件概率公式,我们可以得到一个全局的损失函数表达,并在此基础上进一步优化模型。

使用GloVe模型预训练词向量求近义词和类比词

在skip-gram模型中,损失函数为:
− ∑ t = 1 T ∑ − m ≤ j ≤ m , j ≠ 0 log ⁡ P ( w ( t + j ) ∣ w ( t ) ) -\sum_{t=1}^T\sum_{-m\le j\le m,j\ne 0} \log P(w^{(t+j)}\mid w^{(t)}) t=1Tmjm,j=0logP(w(t+j)w(t))
其中包含两个求和符号,它们分别枚举了语料库中的每个中心词和其对应的每个背景词.
我们知道有
P ( w j ∣ w i ) = exp ⁡ ( u j ⊤ v i ) ∑ k ∈ V exp ⁡ ( u k ⊤ v i ) P(w_j\mid w_i) = \frac{\exp(\boldsymbol{u}_j^\top\boldsymbol{v}_i)}{\sum_{k\in\mathcal{V}}\exp(\boldsymbol{u}_k^\top\boldsymbol{v}_i)} P(wjwi)=kVexp(ukvi)exp(ujvi)

w i w_i wi 为中心词, w j w_j wj 为背景词时 Skip-Gram 模型所假设的条件概率计算公式,我们将其简写为 q i j q_{ij} qij。实际上我们还可以采用另一种计数方式,那就是直接枚举每个词分别作为中心词和背景词的情况:
− ∑ i ∈ V ∑ j ∈ V x i j log ⁡ q i j -\sum_{i\in\mathcal{V}}\sum_{j\in\mathcal{V}} x_{ij}\log q_{ij} iVjVxijlogqij
其中 x i j x_{ij} xij 表示整个数据集中 w j w_j wj 作为 w i w_i wi 的背景词的次数总和。
将该式进一步地改写为交叉熵 (cross-entropy) 的形式如下:
− ∑ i ∈ V x i ∑ j ∈ V p i j log ⁡ q i j -\sum_{i\in\mathcal{V}}x_i\sum_{j\in\mathcal{V}}p_{ij} \log q_{ij} iVxijVpijlogqij
其中 x i x_i xi w i w_i wi 的背景词窗大小总和, p i j = x i j / x i p_{ij}=x_{ij}/x_i pij=xij/xi w j w_j wj w i w_i wi 的背景词窗中所占的比例。

从这里可以看出,我们的词嵌入方法实际上就是想让模型学出 w j w_j wj 有多大概率是 w i w_i wi 的背景词,而真实的标签则是语料库上的统计数据。同时,语料库中的每个词根据 x i x_i xi 的不同,在损失函数中所占的比重也不同。

Glove模型在w2v基础上的改动
  1. 使用非概率分布的变量 p i j ′ = x i j p'_{ij}=x_{ij} pij=xij q ′ i j = exp ⁡ ( u j ⊤ v i ) q′_{ij}=\exp(\boldsymbol{u}^\top_j\boldsymbol{v}_i) qij=exp(ujvi),并对它们取对数;
  2. 为每个词 w i w_i wi 增加两个标量模型参数:中心词偏差项 b i b_i bi 和背景词偏差项 c i c_i ci,松弛了概率定义中的规范性;
  3. 将每个损失项的权重 x i x_i xi 替换成函数 h ( x i j ) h(x_{ij}) h(xij),权重函数 h ( x ) h(x) h(x) 是值域在 [ 0 , 1 ] [0,1] [0,1] 上的单调递增函数,松弛了中心词重要性与 x i x_i xi 线性相关的隐含假设;
  4. 用平方损失函数替代了交叉熵损失函数。

综上,我们获得了 GloVe 模型的损失函数表达式:

∑ i ∈ V ∑ j ∈ V h ( x i j ) ( u j ⊤ v i + b i + c j − log ⁡ x i j ) 2 \sum_{i\in\mathcal{V}}\sum_{j\in\mathcal{V}} h(x_{ij}) (\boldsymbol{u}^\top_j\boldsymbol{v}_i+b_i+c_j-\log x_{ij})^2 iVjVh(xij)(ujvi+bi+cjlogxij)2

由于这些非零 x i j x_{ij} xij 是预先基于整个数据集计算得到的,包含了数据集的全局统计信息,因此 GloVe 模型的命名取“全局向量”(Global Vectors)之意。

载入预训练的Glove模型

Pytorch中有调用官方提供的多种规格的预训练向量,语料库多数来自维基百科、Twitter等,词语数量也达到了60亿到8千亿.

使用torchtext.vocab,就可以获得Glove、FastText、cahrNGram等常用预训练词向量

import torch
import torchtext.vocab as vocab

print([key for key in vocab.pretrained_aliases.keys() if "glove" in key])
cache_dir = "/home/kesci/input/GloVe6B5429"
glove = vocab.GloVe(name='6B', dim=50, cache=cache_dir)
print("一共包含%d个词。" % len(glove.stoi))
print(glove.stoi['beautiful'], glove.itos[3366])

['glove.42B.300d', 'glove.840B.300d', 'glove.twitter.27B.25d', 'glove.twitter.27B.50d',
 'glove.twitter.27B.100d', 'glove.twitter.27B.200d', 'glove.6B.50d', 'glove.6B.100d', 
 'glove.6B.200d', 'glove.6B.300d']
一共包含400000个词。
3366 beautiful

glove模型也有很多种体量的,B是billion,代表词语数量;d是词语维度
stoi是string to index,相反的是itos,index to string
3366为beautiful的索引号

求近义词

由于词向量空间中的余弦相似性可以衡量词语含义的相似性,我们可以通过寻找空间中的 k 近邻,来查询单词的近义词。

那么为什么余弦相似性可以衡量词语含义的相似性?
从损失函数表达式中看, u j ⊤ v i u_j\top v_i ujvi,在梯度下降的过程中, u j u_j uj被拉向 v i v_i vi v i v_i vi被拉向 u j u_j uj,所以,可以说中心词的向量很大程度上是由词的背景词向量决定的,而同义词和近义词的性质就是互相替代使用的作用,所以使得它们的背景词集合非常相似,所以训练出来的词向量也相似

def knn(W, x, k):
    '''
    @params:
        W: 所有向量的集合
        x: 给定向量
        k: 查询的数量
    @outputs:
        topk: 余弦相似性最大k个的下标
        [...]: 余弦相似度
    '''
    cos = torch.matmul(W, x.view((-1,))) / (
        (torch.sum(W * W, dim=1) + 1e-9).sqrt() * torch.sum(x * x).sqrt())
    _, topk = torch.topk(cos, k=k)
    topk = topk.cpu().numpy()
    return topk, [cos[i].item() for i in topk]

使用余弦公式计算相似度,pytorch中有内置函数topk得到余弦相似性最大k个的下标

def get_similar_tokens(query_token, k, embed):
    '''
    @params:
        query_token: 给定的单词
        k: 所需近义词的个数
        embed: 预训练词向量
    '''
    topk, cos = knn(embed.vectors,
                    embed.vectors[embed.stoi[query_token]], k+1)
    for i, c in zip(topk[1:], cos[1:]):  # 除去输入词
        print('cosine sim=%.3f: %s' % (c, (embed.itos[i])))

get_similar_tokens('chip', 3, glove)

cosine sim=0.839: babies
cosine sim=0.800: boy
cosine sim=0.792: girl

求类比词

除了求近义词以外,我们还可以使用预训练词向量求词与词之间的类比关系,例如“man”之于“woman”相当于“son”之于“daughter”。求类比词问题可以定义为:对于类比关系中的4个词“ a a a 之于 b b b 相当于 c c c 之于 d d d”,给定前3个词 a , b , c a,b,c a,b,c d d d。求类比词的思路是,搜索与 vec ( c ) + vec ( b ) − vec ( a ) \text{vec}(c)+\text{vec}(b)−\text{vec}(a) vec(c)+vec(b)vec(a) 的结果向量最相似的词向量,其中 vec ( w ) \text{vec}(w) vec(w) w w w 的词向量。

def get_analogy(token_a, token_b, token_c, embed):
    '''
    @params:
        token_a: 词a
        token_b: 词b
        token_c: 词c
        embed: 预训练词向量
    @outputs:
        res: 类比词d
    '''
    vecs = [embed.vectors[embed.stoi[t]] 
                for t in [token_a, token_b, token_c]]
    x = vecs[1] - vecs[0] + vecs[2]  #与上面说的公式类似
    topk, cos = knn(embed.vectors, x, 1)
    res = embed.itos[topk[0]]
    return res

get_analogy('man', 'woman', 'son', glove)
'daughter'
get_analogy('beijing', 'china', 'tokyo', glove)
'japan'
get_analogy('bad', 'worst', 'big', glove)
'biggest'
get_analogy('do', 'did', 'go', glove)
'went'

文本处理

这是一个实战任务,文本分类算是一个NLP最常见的任务了,输入一段不定长文本序列,输出文本的类别,情感分析呢,就是其中的一个子问题。,我们将应用预训练的词向量和含多个隐藏层的双向循环神经网络卷积神经网络,来判断一段不定长的文本序列中包含的是正面还是负面的情绪。

使用Bi-RNN with LSTM 进行情感分类

1. 引入需要的包
import collections
import os
import random
import time
from tqdm import tqdm
import torch
from torch import nn
import torchtext.vocab as Vocab
import torch.utils.data as Data
import torch.nn.functional as F
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
2.读取数据(顺带做些简单的处理)
def read_imdb(folder='train', data_root="input/IMDB2578/aclImdb_v1/aclImdb"): 
    data = []
    for label in ['pos', 'neg']:
        folder_name = os.path.join(data_root, folder, label)  #连接路径名称
        for file in tqdm(os.listdir(folder_name)):  #tqdm是一个快速且可扩展的python进度条,里面放迭代器
            with open(os.path.join(folder_name, file), 'rb') as f:
                review = f.read().decode('utf-8').replace('\n', '').lower()  #读取、解码、替换换行,小写
                data.append([review, 1 if label == 'pos' else 0])   #建立data:label
    random.shuffle(data)  #打乱顺序
    return data

DATA_ROOT = "/input/IMDB2578/aclImdb_v1/"
data_root = os.path.join(DATA_ROOT, "aclImdb")
train_data, test_data = read_imdb('train', data_root), read_imdb('test', data_root)

# 打印训练数据中的前五个sample
for sample in train_data[:5]:
    print(sample[1], '\t', sample[0][:10])  #打印label和对应的句子的前10个单词
3. 预处理数据

3.1 单词划分和创建词典

def get_tokenized_imdb(data):
    '''
    @params:
        data: 数据的列表,列表中的每个元素为 [文本字符串,0/1标签] 二元组
    @return: 切分词后的文本的列表,列表中的每个元素为切分后的词序列
    '''
    def tokenizer(text):
        return [tok.lower() for tok in text.split(' ')]   #按照单词之间的空格切分单词为列表元素,并转为小写
    
    return [tokenizer(review) for review, _ in data]
    #得到二维数组,第一维度是所有句子,第二维度是一个句子中的所有单词
	#_符号是占位符,占位那个label元素的位置,无实际意义
def get_vocab_imdb(data):
    '''
    @params:
        data: 同上,数据的列表,一个二元组
    @return: 数据集上的词典,Vocab 的实例(freqs, stoi, itos)
    '''
    tokenized_data = get_tokenized_imdb(data)
    counter = collections.Counter([tk for st in tokenized_data for tk in st])
    #counter是一个词典dict,单词:词频
    return Vocab.Vocab(counter, min_freq=5)  
#建立vocab对象,双向标注,stoi:词对应下标,itos:所有词的列表形式,一个大的list,不重复的所有词语集合

vocab = get_vocab_imdb(train_data)
print('# words in vocab:', len(vocab))

3.2 文本从字符串的形式转换为单词下标序列的形式(Tensor张量形式)

def preprocess_imdb(data, vocab):
    '''
    @params:
        data: 同上,原始的读入数据
        vocab: 训练集上生成的词典
    @return:
        features: 单词下标序列,形状为 (n, max_l) 的整数张量
        labels: 情感标签,形状为 (n,) 的0/1整数张量,n为句子个数
    '''
    max_l = 500  # 将每条评论通过截断或者补0,使得长度变成500

    def pad(x):
        return x[:max_l] if len(x) > max_l else x + [0] * (max_l - len(x))

    tokenized_data = get_tokenized_imdb(data)   #切分词后的文本的列表
    features = torch.tensor([pad([vocab.stoi[word] for word in words]) for words in tokenized_data])  
    #文本中的句子,句子中的词语形成一个下标列表,然后截断或者填充
    labels = torch.tensor([score for _, score in data])
    return features, labels
4.创建数据迭代器

使用torch.utils.data.TensorDataset,创建pytorch形式的数据集,从而创建数据迭代器
*符号将可迭代序列拆开,作为函数的实参
使用DataLoader创建迭代器,train_data记得要shuffle哦~

train_set = Data.TensorDataset(*preprocess_imdb(train_data, vocab))
test_set = Data.TensorDataset(*preprocess_imdb(test_data, vocab))

# 上面的代码等价于下面的注释代码
# train_features, train_labels = preprocess_imdb(train_data, vocab)
# test_features, test_labels = preprocess_imdb(test_data, vocab)
# train_set = Data.TensorDataset(train_features, train_labels)
# test_set = Data.TensorDataset(test_features, test_labels)

#访问训练集长度,通常是访问张量的第一维度,也就是上面的n=25000,traindata有25000个句子
# len(train_set) = features.shape[0] or labels.shape[0]
#访问下标,返回二元组 feature:labels
# train_set[index] = (features[index], labels[index])

batch_size = 64
train_iter = Data.DataLoader(train_set, batch_size, shuffle=True)
test_iter = Data.DataLoader(test_set, batch_size)

for X, y in train_iter:
    print('X', X.shape, 'y', y.shape)
    break
print('#batches:', len(train_iter))

result:
X torch.Size([64, 500]) y torch.Size([64])
batches: 391
5.Bi-RNN的模型架构搭建

在这里插入图片描述
给定输入序列 { X 1 , X 2 , … , X T } \{\boldsymbol{X}_1,\boldsymbol{X}_2,\dots,\boldsymbol{X}_T\} {X1,X2,,XT},其中 X t ∈ R n × d \boldsymbol{X}_t\in\mathbb{R}^{n\times d} XtRn×d 为时间步(批量大小为 n n n,输入维度为 d d d)。在双向循环神经网络的架构中,设时间步 t t t 上的正向隐藏状态为 H → t ∈ R n × h \overrightarrow{\boldsymbol{H}}_{t} \in \mathbb{R}^{n \times h} H tRn×h (正向隐藏状态维度为 h h h),反向隐藏状态为 H ← t ∈ R n × h \overleftarrow{\boldsymbol{H}}_{t} \in \mathbb{R}^{n \times h} H tRn×h (反向隐藏状态维度为 h h h)。我们可以分别计算正向隐藏状态和反向隐藏状态:

H → t = ϕ ( X t W x h ( f ) + H → t − 1 W h h ( f ) + b h ( f ) ) H ← t = ϕ ( X t W x h ( b ) + H ← t + 1 W h h ( b ) + b h ( b ) ) \begin{aligned} &\overrightarrow{\boldsymbol{H}}_{t}=\phi\left(\boldsymbol{X}_{t} \boldsymbol{W}_{x h}^{(f)}+\overrightarrow{\boldsymbol{H}}_{t-1} \boldsymbol{W}_{h h}^{(f)}+\boldsymbol{b}_{h}^{(f)}\right)\\ &\overleftarrow{\boldsymbol{H}}_{t}=\phi\left(\boldsymbol{X}_{t} \boldsymbol{W}_{x h}^{(b)}+\overleftarrow{\boldsymbol{H}}_{t+1} \boldsymbol{W}_{h h}^{(b)}+\boldsymbol{b}_{h}^{(b)}\right) \end{aligned} H t=ϕ(XtWxh(f)+H t1Whh(f)+bh(f))H t=ϕ(XtWxh(b)+H t+1Whh(b)+bh(b))

其中权重 W x h ( f ) ∈ R d × h , W h h ( f ) ∈ R h × h , W x h ( b ) ∈ R d × h , W h h ( b ) ∈ R h × h \boldsymbol{W}_{x h}^{(f)} \in \mathbb{R}^{d \times h}, \boldsymbol{W}_{h h}^{(f)} \in \mathbb{R}^{h \times h}, \boldsymbol{W}_{x h}^{(b)} \in \mathbb{R}^{d \times h}, \boldsymbol{W}_{h h}^{(b)} \in \mathbb{R}^{h \times h} Wxh(f)Rd×h,Whh(f)Rh×h,Wxh(b)Rd×h,Whh(b)Rh×h 和偏差 b h ( f ) ∈ R 1 × h , b h ( b ) ∈ R 1 × h \boldsymbol{b}_{h}^{(f)} \in \mathbb{R}^{1 \times h}, \boldsymbol{b}_{h}^{(b)} \in \mathbb{R}^{1 \times h} bh(f)R1×h,bh(b)R1×h 均为模型参数, ϕ \phi ϕ 为隐藏层激活函数。

然后我们连结两个方向的隐藏状态 H → t \overrightarrow{\boldsymbol{H}}_{t} H t H ← t \overleftarrow{\boldsymbol{H}}_{t} H t 来得到隐藏状态 H t ∈ R n × 2 h \boldsymbol{H}_{t} \in \mathbb{R}^{n \times 2 h} HtRn×2h,并将其输入到输出层。输出层计算输出 O t ∈ R n × q \boldsymbol{O}_{t} \in \mathbb{R}^{n \times q} OtRn×q(输出维度为 q q q):

O t = H t W h q + b q \boldsymbol{O}_{t}=\boldsymbol{H}_{t} \boldsymbol{W}_{h q}+\boldsymbol{b}_{q} Ot=HtWhq+bq

其中权重 W h q ∈ R 2 h × q \boldsymbol{W}_{h q} \in \mathbb{R}^{2 h \times q} WhqR2h×q 和偏差 b q ∈ R 1 × q \boldsymbol{b}_{q} \in \mathbb{R}^{1 \times q} bqR1×q 为输出层的模型参数。不同方向上的隐藏单元维度也可以不同。

利用 torch.nn.RNNtorch.nn.LSTM 模组,我们可以很方便地实现双向循环神经网络,下面是以 LSTM 为例的代码。

class BiRNN(nn.Module):
    def __init__(self, vocab, embed_size, num_hiddens, num_layers):
        '''
        @params:
            vocab: 在数据集上创建的词典,用于获取词典大小
            embed_size: 嵌入维度大小
            num_hiddens: 隐藏状态维度大小
            num_layers: 隐藏层个数
        '''
        #转换为词向量,成为输入X
        super(BiRNN, self).__init__()
        self.embedding = nn.Embedding(len(vocab), embed_size)
        
        # encoder-decoder framework
        # bidirectional设为True即得到双向循环神经网络
        self.encoder = nn.LSTM(input_size=embed_size,   #输入维度
                                hidden_size=num_hiddens, 	#隐藏状态维度
                                num_layers=num_layers,    #隐藏层数
                                bidirectional=True)			#设置为双向
        self.decoder = nn.Linear(4*num_hiddens, 2) # 初始时间步和最终时间步的隐藏状态作为全连接层输入
        
    def forward(self, inputs):
        '''
        @params:
            inputs: 词语下标序列,形状为 (batch_size, seq_len句子长度) 的整数张量
        @return:
            outs: 对文本情感的预测,形状为 (batch_size, 2) 的张量
        '''
        # 因为LSTM需要将序列长度(seq_len)作为第一维,所以需要将输入转置
        embeddings = self.embedding(inputs.permute(1, 0)) # (seq_len, batch_size, d)
        # rnn.LSTM 返回输出、隐藏状态和记忆单元,格式如 outputs, (h, c)
        outputs, _ = self.encoder(embeddings) # (seq_len, batch_size, 2*h)
        encoding = torch.cat((outputs[0], outputs[-1]), -1) # (batch_size, 4*h)
        outs = self.decoder(encoding) # (batch_size, 2)
        return outs

embed_size, num_hiddens, num_layers = 100, 100, 2
net = BiRNN(vocab, embed_size, num_hiddens, num_layers)
print(net)

init是构造函数,主要是设计模型的结构,而forward函数是用于前向传播计算,进行模型预测.

  • 构造函数:
  1. 首先使用nn.Embbeding把数据转换成词向量形式,此时还没有调用预训练好的词向量,只是使用默认的随机提供参数的方式来转换词向量。
  2. 其次,我们使用LSTM作为单元,设置好内部的参数,即可形成网络,得到encoder
  3. 最后,因为要做二分类预测,所以decoder使用线性网络,输出为2,输入则是 [ O 1 , O T ] [O_1,O_T] [O1,OT],因为RNN网络最后一个隐藏状态包含了前面所有的信息,所以把第一时刻和最后一个时刻的输出拼接起来作为输入,就达到了双向的整体的文本表示.
  • Forward函数: (要注意输入和输出、以及中间过程的形状)
  1. 首先因为为LSTM需要将序列长度(seq_len)作为第一维,所以需要将输入转置。放入embedding层转化为词向量,形状为(seq_len, batch_size, d),d是词向量维度
  2. 把词向量放入encoder,得到返回输出、隐藏状态和记忆单元,格式如 outputs, (h, c);我们仅需要output,最后得到outputs形状(seq_len, batch_size, 2h),2h是因为双向模型
  3. 然后把第一维和最后一维在隐藏状态的维度上进行拼接,2h+2h=4h
  4. 最后把拼接出来的outputs放入decoder中,得到(batch_size, 2)形状的预测向量

做好网络的构建,我们就可以实例化这个net,初始化嵌入层大小,隐藏层数目和隐藏状态维度,就可以使用这个模型了

6.载入预训练好的词向量

预训练模型训练也是比较消耗时间的,所以我们打算加载训练好的词向量模型Glove来使用,但是由于预训练词向量的词典及词语索引与我们使用的数据集并不相同,所以需要根据目前的词典及索引的顺序来加载预训练词向量

cache_dir = "/input/GloVe6B5429"
glove_vocab = Vocab.GloVe(name='6B', dim=100, cache=cache_dir) #60亿个词,词维度为100

def load_pretrained_embedding(words, pretrained_vocab):
    '''
    @params:
        words: 需要加载词向量的词语列表,以 itos (index to string) 的词典形式给出
        pretrained_vocab: 预训练词向量
    @return:
        embed: 加载到的词向量
    '''
    embed = torch.zeros(len(words), pretrained_vocab.vectors[0].shape[0]) # 初始化为0
    oov_count = 0 # out of vocabulary
    for i, word in enumerate(words):
        try:
            idx = pretrained_vocab.stoi[word]
            embed[i, :] = pretrained_vocab.vectors[idx]
        except KeyError:
            oov_count += 1
    if oov_count > 0:
        print("There are %d oov words." % oov_count)
    return embed

net.embedding.weight.data.copy_(load_pretrained_embedding(vocab.itos, glove_vocab))
net.embedding.weight.requires_grad = False # 直接加载预训练好的, 所以不需要更新它

输入:需要加载词向量的词语列表(就是我们前面搭建的词表)和预训练词向量
输出:加载到的词向量
创建一个全零的张量矩阵,用于存放我们加载到的词向量,长度为词语的个数(词表的size),宽度为预训练模型的词向量的维度,即100.
循环所有的词语,在预训练模型中找到对应词语,找到就放进矩阵,找不到就oov加一

net.embedding.weight.data.copy_可以直接把词向量权重加载到模型中,因为我们已经训练好词向量,不希望再更新,所以可以通过net.embedding.weight.requires_grad=False取消梯度.

7. 训练模型
def evaluate_accuracy(data_iter, net, device=None):
    if device is None and isinstance(net, torch.nn.Module):
        device = list(net.parameters())[0].device 
    acc_sum, n = 0.0, 0
    with torch.no_grad():
        for X, y in data_iter:
            if isinstance(net, torch.nn.Module):
                net.eval()
                acc_sum += (net(X.to(device)).argmax(dim=1) == y.to(device)).float().sum().cpu().item()
                net.train()
            else:
                if('is_training' in net.__code__.co_varnames):
                    acc_sum += (net(X, is_training=False).argmax(dim=1) == y).float().sum().item() 
                else:
                    acc_sum += (net(X).argmax(dim=1) == y).float().sum().item() 
            n += y.shape[0]
    return acc_sum / n

def train(train_iter, test_iter, net, loss, optimizer, device, num_epochs):
    net = net.to(device)
    print("training on ", device)
    batch_count = 0
    for epoch in range(num_epochs):
        train_l_sum, train_acc_sum, n, start = 0.0, 0.0, 0, time.time()
        for X, y in train_iter:
            X = X.to(device)
            y = y.to(device)
            y_hat = net(X)
            l = loss(y_hat, y) 
            optimizer.zero_grad()
            l.backward()
            optimizer.step()
            train_l_sum += l.cpu().item()
            train_acc_sum += (y_hat.argmax(dim=1) == y).sum().cpu().item()
            n += y.shape[0]
            batch_count += 1
        test_acc = evaluate_accuracy(test_iter, net)
        print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f, time %.1f sec'
              % (epoch + 1, train_l_sum / batch_count, train_acc_sum / n, test_acc, time.time() - start))

由于嵌入层的参数是不需要在训练过程中被更新的,刚说的嵌入层参数不更新,所以我们利用 filter 函数和 lambda 表达式来过滤掉模型中不需要更新参数的部分。

lr, num_epochs = 0.01, 5
optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, net.parameters()), lr=lr)
loss = nn.CrossEntropyLoss()

train(train_iter, test_iter, net, loss, optimizer, device, num_epochs)
8.评价模型
def predict_sentiment(net, vocab, sentence):
    '''
    @params:
        net: 训练好的模型
        vocab: 在该数据集上创建的词典,用于将给定的单词序转换为单词下标的序列,从而输入模型
        sentence: 需要分析情感的文本,以单词序列的形式给出
    @return: 预测的结果,positive 为正面情绪文本,negative 为负面情绪文本
    '''
    device = list(net.parameters())[0].device # 读取模型所在的环境
    sentence = torch.tensor([vocab.stoi[word] for word in sentence], device=device)  #单词序列转换为下标序列 
     #得到一个1x2的向量,分别为0和1的概率
    label = torch.argmax(net(sentence.view((1, -1))), dim=1) 
   #argmax去概率最大的那个label返回
    return 'positive' if label.item() == 1 else 'negative'

x = predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'bad'])
print(x)

negative

使用Text-CNN做情感分析

一维卷积层的计算

在这里插入图片描述
窗口中的输入子数组与核数组按元素相乘并求和,得到输出数组中相应位置的元素
输 出 宽 度 = 输 入 宽 度 − 核 宽 度 + 1 输出宽度 = 输入宽度-核宽度+1 =+1
简单的一维卷积运算代码实现:

def corr1d(X,K):
	'''
	@params:
		X:输入,形状(seq_len,)张量
		K:卷积核,形状(w,) 张量
	@return:
		Y:输出,形状(seq_len-w+1,)张量
	'''
	w = K.shape[0] #卷积窗口宽度
	Y = torch.zeros((X.shape[0]-w+1)) #初始化输出形状
	for i in range(Y.shape[0]):  #滑动窗口
		Y[i] = (X[i:i+w]*K).sum()
	return Y

测试:
X,K = torch.tensor([0,1,2,3,4,5,6]),torch.tensor([1,2])
print(corr1d(X,K))
tensor([ 2.,  5.,  8., 11., 14., 17.])
多输入通道的一维互相关运算

在这里插入图片描述
在每个通道上,将核与相应的输入做一维互相关运算,并将通道之间的结果相加得到输出结果。0×1+1×2+1×3+2×4+2×(−1)+3×(−3)=2。

def corr1d_multi_in(X, K):
    # 首先沿着X和K的通道维遍历并计算一维互相关结果。然后将所有结果堆叠起来沿第0维累加
    return torch.stack([corr1d(x, k) for x, k in zip(X, K)]).sum(dim=0)
    # [corr1d(X[i], K[i]) for i in range(X.shape[0])]

X = torch.tensor([[0, 1, 2, 3, 4, 5, 6],
              [1, 2, 3, 4, 5, 6, 7],
              [2, 3, 4, 5, 6, 7, 8]])
K = torch.tensor([[1, 2], [3, 4], [-1, -3]])
print(corr1d_multi_in(X, K)).
tensor([ 2.,  8., 14., 20., 26., 32.])

由二维互相关运算的定义可知,多输入通道的一维互相关运算可以看作单输入通道的二维互相关运算
在这里插入图片描述
这里核的高等于输入的高才能成立

如果希望得到含多个通道的输出,我们可以为每个输出通道分别创建形状为 c i × k h × k w c_i×k_h×k_w ci×kh×kw 的核数组。将它们在输出通道维上连结,卷积核的形状即 c o × c i × k h × k w c_o×c_i×k_h×k_w co×ci×kh×kw 。在做互相关运算时,每个输出通道上的结果由卷积核在该输出通道上的核数组与整个输入数组计算而来。
【二位卷积做法】下面是输入为3x1x7 ,卷积核为2x3x1x2,输出为 2x1x6,输入通道为3,输出通道为2

def corr2d(X,K):
    h,w = K.shape
    Y = torch.zeros(X.shape[0] - h + 1,X.shape[1] - w + 1)
    for i in range(Y.shape[0]):
        for j in range(Y.shape[1]):
            Y[i,j] = (X[i:i + h, j: j + w]*K).sum()
    return Y   
 
def corr2d_mutil_in(X,K):
    h,w = K.shape[1],K.shape[2]
    value = torch.zeros(X.shape[1] - h + 1,X.shape[2] - w + 1)
    for x,k in zip(X,K):
        value = value + corr2d(x,k)
    return value

def corr2d_multi_in_out(X,K):
    return torch.stack([corr2d_mutil_in(X,k) for k in K]) #把输出通道的个数遍历出来计算

X = torch.tensor([[[0, 1, 2, 3, 4, 5, 6]],
              [[1, 2, 3, 4, 5, 6, 7]],
              [[2, 3, 4, 5, 6, 7, 8]]])
K = torch.tensor([[[[1,2]],
                 [[-1,3]],
                 [[2,5]]],
                        [[[-2,2]],
                         [[6,3]],
                         [[7,1]]]]
                   )
a = corr2d_multi_in_out(X,K)
print(a)

输出
tensor([[[ 26.,  38.,  50.,  62.,  74.,  86.]],

        [[ 31.,  48.,  65.,  82.,  99., 116.]]])

小trick:把X和K看成一个整体,把K的3x1x2和3x1x7互相关运算,运算了 c o c_o co

【一维卷积层做法】

def corr1d(X, K):
    '''
    @params:
        X: 输入,形状为 (seq_len,) 的张量
        K: 卷积核,形状为 (w,) 的张量
    @return:
        Y: 输出,形状为 (seq_len - w + 1,) 的张量
    '''
     
    w = K.shape[0] # 卷积窗口宽度
    Y = torch.zeros((X.shape[0] - w + 1))
    for i in range(Y.shape[0]): # 滑动窗口
        Y[i] = (X[i: i + w] * K).sum()
    return Y
def corr1d_mutil_in(X, K):
    # 首先沿着X和K的通道维遍历并计算一维互相关结果。然后将所有结果堆叠起来沿第0维累加
    return torch.stack([corr1d(x, k) for x, k in zip(X, K)]).sum(dim=0)
    # [corr1d(X[i], K[i]) for i in range(X.shape[0])]
def corr1d_multi_in_out(X,K):
    
    return torch.stack([corr1d_mutil_in(X,k) for k in K])


X = torch.tensor([[0, 1, 2, 3, 4, 5, 6],
              [1, 2, 3, 4, 5, 6, 7],
              [2, 3, 4, 5, 6, 7, 8]])
K = torch.tensor([[[1,2],
                 [-1,3],
                 [2,5]],
                        [[-2,2],
                         [6,3],
                         [7,1]]])
print(corr1d_multi_in_out(X,K))

tensor([[ 26.,  38.,  50.,  62.,  74.,  86.],
        [ 31.,  48.,  65.,  82.,  99., 116.]])
时序池化层

TextCNN 中使用的时序最大池化(max-over-time pooling)层实际上对应一维全局最大池化层:假设输入包含多个通道,各通道由不同时间步上的数值组成,各通道的输出即该通道所有时间步中最大的数值。因此,时序最大池化层的输入在各个通道上的时间步数可以不同
因为NLP中的句子长度是不同的,所以CNN的输入矩阵大小是不确定的,这取决于有多少个字符。
Image Name

注:自然语言中还有一些其他的池化操作,可参考这篇博文

为提升计算性能,我们常常将不同长度的时序样本组成一个小批量,并通过在较短序列后附加特殊字符(如0)令批量中各时序样本长度相同。这些人为添加的特殊字符当然是无意义的。由于时序最大池化的主要目的是抓取时序中最重要的特征,它通常能使模型不受人为添加字符的影响。

时序池化层的实现代码:
class GlobalMaxPool1d(nn.Module):
    def __init__(self):
        super(GlobalMaxPool1d, self).__init__()
    def forward(self, x):
        '''
        @params:
            x: 输入,形状为 (batch_size, n_channels, seq_len) 的张量
        @return: 时序最大池化后的结果,形状为 (batch_size, n_channels, 1) 的张量
        '''
        return F.max_pool1d(x, kernel_size=x.shape[2]) # kenerl_size = seq_len
搭建TextCNN模型

TextCNN 模型主要使用了一维卷积层和时序最大池化层。假设输入的文本序列由 n n n 个词组成,每个词用 d d d 维的词向量表示。那么输入样本的宽为 n n n,输入通道数为 d d d。TextCNN 的计算主要分为以下几步。

  1. 定义一维卷积核,输入与卷积核做卷积运算(卷积核宽度变化可能会捕捉不同个数的相邻词的相关性)
  2. 对输出的所有通道做时序最大池化,把池化输出值连结成向量
  3. 输入向量到全连接层,变换为有关各类别的输出(这里可以使用Dropout应对过拟合)
例子:

在这里插入图片描述
定义了两个一维卷积核,核宽分别为2和4,输出通道分别为4和5,
各通道做时序最大池化,得到9维的向量,经过全连接层,得到情感类别2维输出

代码实现

我们一共需要两层嵌入层,因为有比较多的词是oov,如果我们只有固定的词向量,那么这些oov永远都会是全零的向量,所以我们希望有新的向量去代表它们

class TextCNN(nn.Module):
    def __init__(self, vocab, embed_size, kernel_sizes, num_channels):
        '''
        @params:
            vocab: 在数据集上创建的词典,用于获取词典大小
            embed_size: 嵌入维度大小
            kernel_sizes: 卷积核大小列表     -  两个等长的列表
            num_channels: 卷积通道数列表      -	两个等长的列表
        '''
        super(TextCNN, self).__init__()
        self.embedding = nn.Embedding(len(vocab), embed_size) # 参与训练的嵌入层
        self.constant_embedding = nn.Embedding(len(vocab), embed_size) # 不参与训练的嵌入层
        
        self.pool = GlobalMaxPool1d() # 时序最大池化层没有权重,所以可以共用一个实例
        self.convs = nn.ModuleList()  # 创建多个一维卷积层
        for c, k in zip(num_channels, kernel_sizes):
            self.convs.append(nn.Conv1d(in_channels = 2*embed_size, 
                                        out_channels = c, 
                                        kernel_size = k))
            
        self.decoder = nn.Linear(sum(num_channels), 2)
        self.dropout = nn.Dropout(0.5) # 丢弃层用于防止过拟合

    def forward(self, inputs):
        '''
        @params:
            inputs: 词语下标序列,形状为 (batch_size, seq_len) 的整数张量
        @return:
            outputs: 对文本情感的预测,形状为 (batch_size, 2) 的张量
        '''
        embeddings = torch.cat((
            self.embedding(inputs), 
            self.constant_embedding(inputs)), dim=2) # (batch_size, seq_len, 2*embed_size)
        # 根据一维卷积层要求的输入格式,需要将张量进行转置
        embeddings = embeddings.permute(0, 2, 1) # (batch_size, 2*embed_size, seq_len)
        
        encoding = torch.cat([
            self.pool(F.relu(conv(embeddings))).squeeze(-1) for conv in self.convs], dim=1)
        #以下是上面一句代码的详细解释:
        # encoding = []
        # for conv in self.convs:
        #     out = conv(embeddings) # (batch_size, out_channels, seq_len-kernel_size+1)
        #     out = self.pool(F.relu(out)) # (batch_size, out_channels, 1)
        #     encoding.append(out.squeeze(-1)) # (batch_size, out_channels)
        # encoding = torch.cat(encoding) # (batch_size, out_channels_sum)
        
        # 应用丢弃法后使用全连接层得到输出
        outputs = self.decoder(self.dropout(encoding))
        return outputs

embed_size, kernel_sizes, nums_channels = 100, [3, 4, 5], [100, 100, 100]
net = TextCNN(vocab, embed_size, kernel_sizes, nums_channels)
  • 构造函数
    建立两个嵌入层,一个是固定的,一个是参与训练的
    时序最大池化层不需要更新权重,共用一个即可
    使用nn.ModuleList()而不用python中的List是因为网络继承了Module类,就可以更新梯度,如果不想更新梯度,就可以考虑使用python的List
    decoder的输入为所有卷积核的通道数之和
    为了防止过拟合,添加dropout层

  • 前向计算函数
    关注输入、输出和中间过程的形状即可。
    嵌入层需要两个,然后进行concat拼接,转置输入形状
    encoder中是遍历计算所有的卷积核,把结果放入relu激活函数,再放入池化层,把所有计算出来的结果放到一个列表中拼接起来
    最后放入decoer中,得到输出对文本情感的预测,形状为 (batch_size, 2) 的张量

搭建完模型,我们就可以载入预训练模型得到词向量,这里的代码和上面RNN的一样;load_pretrained_embedding函数

训练和评价模型

训练函数也可以参照RNN上面的

lr, num_epochs = 0.001, 5
optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, net.parameters()), lr=lr)
loss = nn.CrossEntropyLoss()
train(train_iter, test_iter, net, loss, optimizer, device, num_epochs)


epoch 1, loss 0.6262, train acc 0.667, test acc 0.781, time 372.9 sec
epoch 2, loss 0.2398, train acc 0.771, test acc 0.831, time 376.4 sec
epoch 3, loss 0.1344, train acc 0.817, test acc 0.844, time 375.1 sec
epoch 4, loss 0.0823, train acc 0.858, test acc 0.859, time 375.7 sec
epoch 5, loss 0.0481, train acc 0.900, test acc 0.856, time 375.4 sec
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'great'])
'positive'
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'bad'])
'negative'
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值