word2vec 的 RNN 模型 (GRU) 到回归不学习

Posted

技术标签:

【中文标题】word2vec 的 RNN 模型 (GRU) 到回归不学习【英文标题】:RNN model (GRU) of word2vec to regression not learning 【发布时间】:2019-07-16 09:52:05 【问题描述】:

我正在将 Keras 代码转换为 PyTorch,因为我对后者比前者更熟悉。但是,我发现它没有学习(或几乎没有学习)。

下面我提供了我几乎所有的 PyTorch 代码,包括初始化代码,以便您自己尝试。您唯一需要自己提供的是词嵌入(我相信您可以在网上找到许多 word2vec 模型)。第一个输入文件应该是一个带有标记文本的文件,第二个输入文件应该是一个带有浮点数的文件,每行一个。因为我已经提供了所有代码,所以这个问题可能看起来很大而且过于广泛。但是,我认为我的问题足够具体:我的模型或训练循环中有什么问题导致我的模型没有或几乎没有改进。 (结果见下文。)

我已尝试在适用的情况下提供许多 cmets,并且我还提供了形状转换,因此您不必 运行代码来查看发生了什么。检查数据准备方法并不重要。

最重要的部分是RegressorNet 的前向方法和RegressionNN 的训练循环(诚然,这些名称选错了)。我认为错误存在于某个地方。

from pathlib import Path
import time

import numpy as np
import torch
from torch import nn, optim
from torch.utils.data import DataLoader
import gensim

from scipy.stats import pearsonr

from LazyTextDataset import LazyTextDataset


class RegressorNet(nn.Module):
    def __init__(self, hidden_dim, embeddings=None, drop_prob=0.0):
        super(RegressorNet, self).__init__()
        self.hidden_dim = hidden_dim
        self.drop_prob = drop_prob

        # Load pretrained w2v model, but freeze it: don't retrain it.
        self.word_embeddings = nn.Embedding.from_pretrained(embeddings)
        self.word_embeddings.weight.requires_grad = False
        self.w2v_rnode = nn.GRU(embeddings.size(1), hidden_dim, bidirectional=True, dropout=drop_prob)

        self.dropout = nn.Dropout(drop_prob)
        self.linear = nn.Linear(hidden_dim * 2, 1)
        # LeakyReLU rather than ReLU so that we don't get stuck in a dead nodes
        self.lrelu = nn.LeakyReLU()

    def forward(self, batch_size, sentence_input):
        # shape sizes for:
        # * batch_size 128
        # * embeddings of dim 146
        # * hidden dim of 200
        # * sentence length of 20

        # sentence_input: torch.Size([128, 20])
        # Get word2vec vector representation
        embeds = self.word_embeddings(sentence_input)
        # embeds: torch.Size([128, 20, 146])

        # embeds.view(-1, batch_size, embeds.size(2)): torch.Size([20, 128, 146])
        # Input vectors into GRU, only keep track of output
        w2v_out, _ = self.w2v_rnode(embeds.view(-1, batch_size, embeds.size(2)))
        # w2v_out = torch.Size([20, 128, 400])

        # Leaky ReLU it
        w2v_out = self.lrelu(w2v_out)

        # Dropout some nodes
        if self.drop_prob > 0:
            w2v_out = self.dropout(w2v_out)
        # w2v_out: torch.Size([20, 128, 400

        # w2v_out[-1, :, :]: torch.Size([128, 400])
        # Only use the last output of a sequence! Supposedly that cell outputs the final information
        regression = self.linear(w2v_out[-1, :, :])
        regression: torch.Size([128, 1])

        return regression


class RegressionRNN:
    def __init__(self, train_files=None, test_files=None, dev_files=None):
        print('Using torch ' + torch.__version__)

        self.datasets, self.dataloaders = RegressionRNN._set_data_loaders(train_files, test_files, dev_files)
        self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

        self.model = self.w2v_vocab = self.criterion = self.optimizer = self.scheduler = None

    @staticmethod
    def _set_data_loaders(train_files, test_files, dev_files):
        # labels must be the last input file
        datasets = 
            'train': LazyTextDataset(train_files) if train_files is not None else None,
            'test': LazyTextDataset(test_files) if test_files is not None else None,
            'valid': LazyTextDataset(dev_files) if dev_files is not None else None
        
        dataloaders = 
            'train': DataLoader(datasets['train'], batch_size=128, shuffle=True, num_workers=4) if train_files is not None else None,
            'test': DataLoader(datasets['test'], batch_size=128, num_workers=4) if test_files is not None else None,
            'valid': DataLoader(datasets['valid'], batch_size=128, num_workers=4) if dev_files is not None else None
        

        return datasets, dataloaders

    @staticmethod
    def prepare_lines(data, split_on=None, cast_to=None, min_size=None, pad_str=None, max_size=None, to_numpy=False,
                      list_internal=False):
        """ Converts the string input (line) to an applicable format. """
        out = []
        for line in data:
            line = line.strip()
            if split_on:
                line = line.split(split_on)
                line = list(filter(None, line))
            else:
                line = [line]

            if cast_to is not None:
                line = [cast_to(l) for l in line]

            if min_size is not None and len(line) < min_size:
                # pad line up to a number of tokens
                line += (min_size - len(line)) * ['@pad@']
            elif max_size and len(line) > max_size:
                line = line[:max_size]

            if list_internal:
                line = [[item] for item in line]

            if to_numpy:
                line = np.array(line)

            out.append(line)

        if to_numpy:
            out = np.array(out)

        return out

    def prepare_w2v(self, data):
        idxs = []
        for seq in data:
            tok_idxs = []
            for word in seq:
                # For every word, get its index in the w2v model.
                # If it doesn't exist, use @unk@ (available in the model).
                try:
                    tok_idxs.append(self.w2v_vocab[word].index)
                except KeyError:
                    tok_idxs.append(self.w2v_vocab['@unk@'].index)
            idxs.append(tok_idxs)
        idxs = torch.tensor(idxs, dtype=torch.long)

        return idxs

    def train(self, epochs=10):
        valid_loss_min = np.Inf
        train_losses, valid_losses = [], []
        for epoch in range(1, epochs + 1):
            epoch_start = time.time()

            train_loss, train_results = self._train_valid('train')
            valid_loss, valid_results = self._train_valid('valid')

            # Calculate Pearson correlation between prediction and target
            try:
                train_pearson = pearsonr(train_results['predictions'], train_results['targets'])
            except FloatingPointError:
                train_pearson = "Could not calculate Pearsonr"

            try:
                valid_pearson = pearsonr(valid_results['predictions'], valid_results['targets'])
            except FloatingPointError:
                valid_pearson = "Could not calculate Pearsonr"

            # calculate average losses
            train_loss = np.mean(train_loss)
            valid_loss = np.mean(valid_loss)

            train_losses.append(train_loss)
            valid_losses.append(valid_loss)

            # print training/validation statistics
            print(f'----------\n'
                  f'Epoch epoch - completed in (time.time() - epoch_start):.0f seconds\n'
                  f'Training Loss: train_loss:.6f\t Pearson: train_pearson\n'
                  f'Validation loss: valid_loss:.6f\t Pearson: valid_pearson')

            # validation loss has decreased
            if valid_loss <= valid_loss_min and train_loss > valid_loss:
                print(f'!! Validation loss decreased (valid_loss_min:.6f --> valid_loss:.6f).  Saving model ...')
                valid_loss_min = valid_loss

            if train_loss <= valid_loss:
                print('!! Training loss is lte validation loss. Might be overfitting!')

            # Optimise with scheduler
            if self.scheduler is not None:
                self.scheduler.step(valid_loss)

        print('Done training...')

    def _train_valid(self, do):
        """ Do training or validating. """
        if do not in ('train', 'valid'):
            raise ValueError("Use 'train' or 'valid' for 'do'.")

        results = 'predictions': np.array([]), 'targets': np.array([])
        losses = np.array([])

        self.model = self.model.to(self.device)
        if do == 'train':
            self.model.train()
            torch.set_grad_enabled(True)
        else:
            self.model.eval()
            torch.set_grad_enabled(False)

        for batch_idx, data in enumerate(self.dataloaders[do], 1):
            # 1. Data prep
            sentence = data[0]
            target = data[-1]
            curr_batch_size = target.size(0)

            # Returns list of tokens, possibly padded @pad@
            sentence = self.prepare_lines(sentence, split_on=' ', min_size=20, max_size=20)
            # Converts tokens into w2v IDs as a Tensor
            sent_w2v_idxs = self.prepare_w2v(sentence)
            # Converts output to Tensor of floats
            target = torch.Tensor(self.prepare_lines(target, cast_to=float))

            # Move input to device
            sent_w2v_idxs, target = sent_w2v_idxs.to(self.device), target.to(self.device)

            # 2. Predictions
            pred = self.model(curr_batch_size, sentence_input=sent_w2v_idxs)
            loss = self.criterion(pred, target)

            # 3. Optimise during training
            if do == 'train':
                self.optimizer.zero_grad()
                loss.backward()
                self.optimizer.step()

            # 4. Save results
            pred = pred.detach().cpu().numpy()
            target = target.cpu().numpy()

            results['predictions'] = np.append(results['predictions'], pred, axis=None)
            results['targets'] = np.append(results['targets'], target, axis=None)
            losses = np.append(losses, float(loss))

        torch.set_grad_enabled(True)

        return losses, results


if __name__ == '__main__':
    HIDDEN_DIM = 200

    # Load embeddings from pretrained gensim model
    embed_p = Path('path-to.w2v_model').resolve()
    w2v_model = gensim.models.KeyedVectors.load_word2vec_format(str(embed_p))
    # add a padding token with only zeros
    w2v_model.add(['@pad@'], [np.zeros(w2v_model.vectors.shape[1])])
    embed_weights = torch.FloatTensor(w2v_model.vectors)


    # Text files are used as input. Every line is one datapoint.
    # *.tok.low.*: tokenized (space-separated) sentences
    # *.cross: one floating point number per line, which we are trying to predict
    regr = RegressionRNN(train_files=(r'train.tok.low.en',
                                      r'train.cross'),
                         dev_files=(r'dev.tok.low.en',
                                    r'dev.cross'),
                         test_files=(r'test.tok.low.en',
                                     r'test.cross'))
    regr.w2v_vocab = w2v_model.vocab
    regr.model = RegressorNet(HIDDEN_DIM, embed_weights, drop_prob=0.2)
    regr.criterion = nn.MSELoss()
    regr.optimizer = optim.Adam(list(regr.model.parameters())[0:], lr=0.001)
    regr.scheduler = optim.lr_scheduler.ReduceLROnPlateau(regr.optimizer, 'min', factor=0.1, patience=5, verbose=True)

    regr.train(epochs=100)

关于LazyTextDataset,可以参考下面的类。

from torch.utils.data import Dataset

import linecache


class LazyTextDataset(Dataset):
    def __init__(self, paths):
        # labels are in the last path
        self.paths, self.labels_path = paths[:-1], paths[-1]

        with open(self.labels_path, encoding='utf-8') as fhin:
            lines = 0
            for line in fhin:
                if line.strip() != '':
                    lines += 1

            self.num_entries = lines

    def __getitem__(self, idx):
        data = [linecache.getline(p, idx + 1) for p in self.paths]
        label = linecache.getline(self.labels_path, idx + 1)

        return (*data, label)

    def __len__(self):
        return self.num_entries

正如我之前所写,我正在尝试将 Keras 模型转换为 PyTorch。原始 Keras 代码不使用嵌入层,而是使用每个句子的预构建 word2vec 向量作为输入。在下面的模型中,没有嵌入层。 Keras 摘要看起来像这样(我无权访问基本模型设置)。


Layer (type)                     Output Shape          Param #     Connected to
====================================================================================================
bidirectional_1 (Bidirectional)  (200, 400)            417600
____________________________________________________________________________________________________
dropout_1 (Dropout)              (200, 800)            0           merge_1[0][0]
____________________________________________________________________________________________________
dense_1 (Dense)                  (200, 1)              801         dropout_1[0][0]
====================================================================================================

问题在于,在输入相同的情况下,Keras 模型工作,并在预测标签和实际标签之间获得 +0.5 的 Pearson 相关性。但是,上面的 PyTorch 模型似乎根本不起作用。给你一个概念,这里是第一个 epoch 之后的损失(均方误差)和 Pearson(相关系数,p 值):

Epoch 1 - completed in 11 seconds
Training Loss: 1.684495  Pearson: (-0.0006077809280690612, 0.8173368901481127)
Validation loss: 1.708228    Pearson: (0.017794288315261794, 0.4264098054188664)

在第 100 个 epoch 之后:

Epoch 100 - completed in 11 seconds
Training Loss: 1.660194  Pearson: (0.0020315421756790806, 0.4400929436716754)
Validation loss: 1.704910    Pearson: (-0.017288118524826892, 0.4396865964324158)

损失如下图所示(当您查看 Y 轴时,您可以看到改进很小)。

可能有问题的最后一个指标是,对于我的 140K 行输入,在我的 GTX 1080TI 上每个 epoch 只需要 10 秒。我觉得他的并不多,我猜想优化没有工作/运行。不过,我不知道为什么。问题可能在我的训练循环或模型本身中,但我找不到它。

再一次,一定是出了点问题,因为: - Keras 模型确实表现良好; - 140K 句子的训练速度“太快” - 训练后几乎没有任何改进。

我错过了什么?该问题很可能存在于训练循环或网络结构中。

【问题讨论】:

因为太宽泛而投反对票甚至投票结束的人:请阅读整个问题,如果您阅读的内容过多,请转到下一个问题。出于可重复性的目的,该代码非常详细。如果您只想查看问题出在哪里,请查看网络类和训练循环。 非常适合在没有线性的情况下使用“回归”一词。 【参考方案1】:

TL;DR:在交换轴时使用permute 而不是view,请参阅答案的结尾以了解不同之处。

关于 RegressorNet(神经网络模型)

    如果您使用from_pretrained,则无需冻结嵌入层。正如documentation 所说,它使用梯度更新。

    这部分:

    self.w2v_rnode = nn.GRU(embeddings.size(1), hidden_dim, bidirectional=True, dropout=drop_prob)
    

    尤其是dropout 没有可提供的num_layers 是完全没有意义的(因为不能用浅层网络指定dropout)。

    BUG 和主要问题:在您的forward 函数中,您使用的是view 而不是permute,这里:

    w2v_out, _ = self.w2v_rnode(embeds.view(-1, batch_size, embeds.size(2)))
    

    请参阅this answer 和每个函数的相应文档,并尝试改用此行:

    w2v_out, _ = self.w2v_rnode(embeds.permute(1, 0, 2))
    

    您可以考虑在创建w2v_rnode 期间使用batch_first=True 参数,您不必以这种方式置换索引。

    检查torch.nn.GRU 的文档,您在序列的最后一步之后,而不是在您拥有的所有序列之后,所以您应该在之后:

    _, last_hidden = self.w2v_rnode(embeds.permute(1, 0, 2))
    

    但我认为这部分很好。

数据准备

没有冒犯,但prepare_lines 非常难以阅读并且似乎也很难维护,更不用说发现最终的错误(我想它就在这里)。

首先,您似乎在手动填充。 请不要那样做,使用torch.nn.pad_sequence 处理批处理!

本质上,首先将每个句子中的每个单词编码为指向嵌入的索引(就像您在prepare_w2v 中所做的那样),然后使用torch.nn.pad_sequencetorch.nn.pack_padded_sequence torch.nn.pack_sequence 如果行已经按长度排序。

适当的批处理

这部分非常重要,您似乎根本没有这样做(很可能这是您实施中的第二个错误)。

PyTorch 的 RNN 单元接受输入不是作为填充张量,而是作为 torch.nn.PackedSequence 对象。这是一个高效的对象存储索引,指定每个序列的未填充长度。

查看有关 here、here 主题的更多信息以及网络上的许多其他博客文章。

批次中的第一个序列必须是最长的,所有其他序列必须按降序提供。接下来是:

    您必须每次按序列长度对批次进行排序并以类似的方式对目标进行排序 对您的批次进行排序,通过网络推送,然后取消排序以匹配您的目标。

两者都可以,这对您来说似乎更直观。 我喜欢做的或多或少如下,希望对您有所帮助:

    为每个单词创建唯一索引并适当映射每个句子(您已经完成了)。 创建常规的torch.utils.data.Dataset 对象,为每个 geitem 返回单个句子,其中它作为由特征 (torch.Tensor) 和标签(单个值)组成的元组返回,看起来像你也这样做。 创建自定义collate_fn 以与torch.utils.data.DataLoader 一起使用,该torch.utils.data.DataLoader 负责在这种情况下对每个批次进行排序和填充(+ 它返回要传递到神经网络的每个句子的长度)。 使用排序和填充特征它们的长度我在神经网络的forward 方法中使用torch.nn.pack_sequence嵌入后执行! strong>) 将其推过 RNN 层。 根据用例,我使用torch.nn.pad_packed_sequence 解压它们。在您的情况下,您只关心最后一个隐藏状态,因此 您不必这样做。如果您正在使用所有隐藏输出(例如注意力网络的情况),您将添加这部分。

说到第三点,这里有一个collate_fn的示例实现,你应该明白了:

import torch


def length_sort(features):
    # Get length of each sentence in batch
    sentences_lengths = torch.tensor(list(map(len, features)))
    # Get indices which sort the sentences based on descending length
    _, sorter = sentences_lengths.sort(descending=True)
    # Pad batch as you have the lengths and sorter saved already
    padded_features = torch.nn.utils.rnn.pad_sequence(features, batch_first=True)
    return padded_features, sentences_lengths, sorter


def pad_collate_fn(batch):
    # DataLoader return batch like that unluckily, check it on your own
    features, labels = (
        [element[0] for element in batch],
        [element[1] for element in batch],
    )
    padded_features, sentences_lengths, sorter = length_sort(features)
    # Sort by length features and labels accordingly
    sorted_padded_features, sorted_labels = (
        padded_features[sorter],
        torch.tensor(labels)[sorter],
    )
    return sorted_padded_features, sorted_labels, sentences_lengths

将它们用作DataLoaders 中的collate_fn,您应该就可以了(也许稍作调整,因此您必须了解其背后的想法)。

其他可能出现的问题和提示

训练循环:很多小错误的好地方,您可能希望使用PyTorch Ignite 将这些错误最小化。我在经历你的 Tensorflow-like-Estimator-like-API-like 训练循环时遇到了难以置信的困难(例如self.model = self.w2v_vocab = self.criterion = self.optimizer = self.scheduler = None this)。请不要这样做,将每个任务(数据创建、数据加载、数据准备、模型设置、训练循环、日志记录)分离到各自的模块中。总而言之,PyTorch/Keras 比 Tensorflow 更具可读性和完整性是有原因的。

使嵌入的第一行等于包含零的向量:默认情况下,torch.nn.functional.embedding 期望第一行用于填充。因此,您应该从 1 开始为每个单词创建唯一索引将参数 padding_idx 指定为不同的值(尽管我强烈反对这种方法,充其量是令人困惑的)。

我希望这个答案至少对您有所帮助,如果有不清楚的地方在下面发表评论,我会尝试从不同的角度/更详细的解释。

一些最终的cmets

此代码不可重现,也不是特定问题。我们没有您使用的数据,也没有您的词向量,随机种子不固定等。

PS。最后一件事:检查你对非常小的子集数据(比如 96 个示例)的表现,如果它不收敛,很可能你的代码确实存在错误。

关于时间:它们可能已关闭(我想是由于未排序和未填充),通常 Keras 和 PyTorch 的时间非常相似(如果我按照预期理解了您的问题的这一部分),以实现正确和高效的实现。

Permute vs view vs reshape 解释

这个简单的例子展示了permute()view() 之间的区别。第一个交换轴,而第二个不改变内存布局,只是将数组分块成所需的形状(如果可能的话)。

import torch

a = torch.tensor([[1, 2], [3, 4], [5, 6]])

print(a)
print(a.permute(1, 0))
print(a.view(2, 3))

输出将是:

tensor([[1, 2],
        [3, 4],
        [5, 6]])
tensor([[1, 3, 5],
        [2, 4, 6]])
tensor([[1, 2, 3],
        [4, 5, 6]])

reshapeview 差不多,是为来自numpy 的人添加的,所以对他们来说更简单、更自然,但有一个重要区别:

view 从不复制数据并且只在连续的内存上工作(所以像上面的排列之后你的数据可能不连续,因此访问它可能会更慢) reshape 可以根据需要复制数据,因此它也适用于非连续数组。

【讨论】:

感谢您非常详尽的回答!我知道这个问题是不可重现的,但我认为这通常是 ML 架构问题的情况,因为没有简单的方法来共享输入数据(提供外部链接在 SO 中是不受欢迎的)。我不确定我怎么会问这个问题。 OT:如果我理解正确,GRU 返回最后一层和所有先前层的输出包含 GRU 最后一层的输出特征 h_t 的张量,对于每个 t ,并且只有最后一个隐藏状态,对吗? 我问是因为我会考虑使用输出而不是最后的隐藏状态。 (不过,不确定这里最好的方法是什么。)您还建议使用填充批处理,我确实想实施。所以谢谢你的解释。但正如它目前所写的那样,使用手动填充,我没有使用它。我使用批次的方式还有什么错误吗?我会在接下来的几天里尝试你的建议,我会回复你的。再次感谢! 关于批次:是的,肯定是报错。您正在用不同长度的噪声(用零填充的向量)填充句子,这会影响执行。 PyTorch 无法告诉 哪里 填充开始和原始句子结束。 torch.nn.PackedSequence 是一个包含索引的对象,它告诉网络只是这样,所以它可能是稍后的 unpaddedtorch.nn.pad_packed_sequence。此外,使用PackedSequence,您不会对填充元素进行不必要的操作(RNN 不会为这些元素计算隐藏状态)。 关于 GRU:output 包含所有时间步长的隐藏状态仅适用于最后一层(在您使用浅层神经网络的情况下无关紧要),而 h_n 是最后一个隐藏状态(最后一个时间步)包含所有层的隐藏。对于浅层网络output[-1] 等于h_n,形状只是以不同的方式返回(由于在第一种和第二种情况下返回所有与最后一个 [depth wise] 层)。从概念上讲,output 是隐藏状态,但从每个时间步返回,所以本质上是hidden == output 哦,真的在你的神经网络模型中将forward 中的.view 更改为.permute,这是一个错误,而且两者都做了不同的事情(你可能想查看一些示例或请参阅我提供的链接)。

以上是关于word2vec 的 RNN 模型 (GRU) 到回归不学习的主要内容,如果未能解决你的问题,请参考以下文章

语音识别食用指南

了解RNN模型的基础单元LSTM、GRU、RQNN 与 SRU

RNN架构解析GRU 模型 & 注意力机制

RNN架构解析GRU 模型 & 注意力机制

RNN从入门到实战GRU入门到实战——使用GRU预测股票。

拟合自定义(非顺序)有状态 RNN (GRU) 模型