A geração de dados usando uma rede neural recorrente está se tornando um método cada vez mais popular e está sendo usada em muitas áreas da ciência da computação. Desde o início do nascimento do conceito seq2seq em 2014, apenas cinco anos se passaram, mas o mundo viu muitas aplicações, começando com os modelos clássicos de tradução e reconhecimento de fala e terminando com a geração de descrições de objetos em fotografias.
Por outro lado, com o tempo, a biblioteca Tensorflow, lançada pelo Google especificamente para o desenvolvimento de redes neurais, ganhou popularidade. Naturalmente, os desenvolvedores do Google não podiam ignorar um paradigma tão popular como o seq2seq; portanto, a biblioteca Tensorflow fornece classes para desenvolvimento dentro desse paradigma. Este artigo descreve este sistema de classes.
Redes Recorrentes
Atualmente, as redes recorrentes são um dos formalismos mais conhecidos e práticos para a construção de redes neurais profundas. As redes recursivas são projetadas para processar dados seriais; portanto, diferentemente de uma célula normal (neurônio), que recebe dados como entrada e produz o resultado de cálculos, uma célula recursiva contém duas entradas e duas saídas.
Uma das entradas representa os dados do elemento atual da sequência e a segunda entrada é chamada de estado e é transmitida como resultado dos cálculos de célula no elemento anterior da sequência.

A figura mostra a célula A, para a qual os dados de um elemento de sequência são inseridos bem como a condição não indicada aqui . Na saída, a célula A fornece o estado e o resultado do cálculo .
Na prática, a sequência de dados é geralmente dividida em subsequências de um determinado comprimento fixo e passada para o cálculo por subconjuntos inteiros (lotes). Em outras palavras, as subsequências são exemplos de aprendizado. As entradas, saídas e estados das células de uma rede recursiva são sequências de números reais. Para cálculo de entrada é necessário usar um estado que não foi o resultado de um cálculo em uma determinada sequência de dados. Tais estados são chamados estados iniciais. Se a sequência for longa o suficiente, faz sentido manter o contexto dos cálculos em cada subsequência. Nesse caso, é possível transmitir o último estado calculado na sequência anterior como o estado inicial. Se a sequência não for tão longa ou a subsequência for o primeiro segmento, você poderá inicializar o estado inicial com zeros.
No momento, para treinar redes neurais em quase todos os lugares, é utilizado o algoritmo de propagação traseira de erros . O resultado do cálculo no conjunto de exemplos transmitidos (no nosso caso, o conjunto de subsequências) é verificado em relação ao resultado esperado (dados marcados). A diferença entre os valores reais e esperados é chamada de erro e esse erro é propagado para os pesos da rede na direção oposta. Assim, a rede se adapta aos dados rotulados e, como regra, o resultado dessa adaptação funciona bem para os dados que a rede não encontrou nos exemplos de treinamento inicial (hipótese de generalização).
No caso de uma rede recursiva, temos várias opções em quais saídas considerar o erro. Vamos descrever aqui dois principais:
- Você pode considerar o erro comparando a saída da última célula da subsequência com a saída esperada. Isso funciona bem para a tarefa de classificação. Por exemplo, precisamos determinar a coloração emocional de um tweet. Para isso, selecionamos os tweets e os marcamos em três categorias: negativo, positivo e neutro. A saída da célula será de três números - o peso das categorias. O tweet também será marcado com três números - as probabilidades de o tweet pertencer à categoria correspondente. Depois de calcular o erro em um subconjunto dos dados, você pode propagá-lo pela saída ou pelo estado que desejar.
- Você pode ler o erro imediatamente nas saídas do cálculo da célula para cada elemento da subsequência. Isso é adequado para a tarefa de prever o próximo elemento de uma sequência dos anteriores. Essa abordagem pode ser usada, por exemplo, no problema de determinar anomalias em séries temporais de dados ou na tarefa de prever o próximo caractere em um texto, para gerá-lo posteriormente. A propagação de erros também é possível através de estados ou saídas.
Ao contrário de uma rede neural regular totalmente conectada, uma rede recursiva é profunda no sentido de que o erro se propaga não apenas das saídas da rede para seus pesos, mas também para a esquerda, através de conexões entre estados. A profundidade da rede é assim determinada pelo comprimento da subsequência. Para propagar o erro pelo estado da rede recursiva, existe um algoritmo especial. Sua característica é que os gradientes dos pesos se multiplicam quando o erro se propaga da direita para a esquerda. Se o erro inicial for maior que a unidade, como resultado, o erro poderá se tornar muito grande. Por outro lado, se o erro inicial for menor que a unidade, em algum lugar no início da sequência, o erro poderá desaparecer. Essa situação na teoria das redes neurais é chamada de carrossel de erro padrão. Para evitar tais situações durante o treinamento, foram inventadas células especiais que não apresentam tais desvantagens. A primeira célula desse tipo foi o LSTM , agora existe uma ampla gama de alternativas, das quais a GRU mais popular.
Uma boa introdução às redes de recorrência pode ser encontrada neste artigo . Outra fonte bem conhecida é um artigo do blog de Andrey Karpaty.
A biblioteca Tensorflow possui muitas classes e funções para implementar redes recursivas. Aqui está um exemplo de criação de uma rede recursiva dinâmica baseada em uma célula do tipo GRU:
cell = tf.contrib.rnn.GRUCell(dimension) outputs, state = tf.nn.dynamic_rnn(cell, input, sequence_length=input_length, dtype=tf.float32)
Neste exemplo, uma célula GRU é criada, que é usada para criar uma rede recursiva dinâmica. O tensor dos dados de entrada e os comprimentos reais das subsequências são transmitidos para a rede. Os dados de entrada são sempre especificados por um vetor de números reais. Para um único valor, por exemplo, um código de símbolo ou uma palavra, o chamado incorporação - mapeando esse código para alguma sequência de números. A função de criar uma rede recursiva dinâmica retorna um par de valores: uma lista de saídas de rede para todos os valores da sequência e o último estado calculado. Como entrada, a função pega uma célula, dados de entrada e um tensor de comprimento de subsequência.
Uma rede dinâmica recursiva difere de uma estática, pois não cria uma rede de células de rede para a subsequência antecipadamente (no estágio de determinação do gráfico de cálculo), mas inicia as células nas entradas dinamicamente durante o cálculo do gráfico nos dados de entrada. Portanto, essa função precisa conhecer os comprimentos das subsequências dos dados de entrada para parar no momento certo.
Gerando modelos baseados em redes de recorrência
Gerando redes de recorrência
Antes, consideramos dois métodos para calcular os erros de redes recursivas: na última saída ou em todas as saídas para uma determinada sequência. Aqui consideramos o problema de gerar sequências. O treinamento da rede de geradores é baseado no segundo método acima.
Mais detalhadamente, estamos tentando treinar uma rede recursiva para prever o próximo elemento de uma sequência. Como mencionado acima, a saída de uma célula em uma rede recursiva é simplesmente uma sequência de números. Como esse vetor não é muito conveniente para o aprendizado, eles introduzem outro nível, que recebe esse vetor na entrada e na saída, dando o peso das previsões. Esse nível é chamado de nível de projeção e permite comparar a saída da célula em um determinado elemento da sequência com a saída esperada nos dados rotulados.
Para ilustrar, considere a tarefa de gerar texto representado como uma sequência de caracteres. O comprimento do vetor de saída do nível de projeção é igual ao tamanho do alfabeto do texto de origem. O tamanho do alfabeto geralmente não excede 150 caracteres, se você contar os caracteres dos idiomas russo e inglês, além de sinais de pontuação. A saída do nível de projeção é um vetor com o comprimento do alfabeto, em que cada símbolo corresponde a uma determinada posição nesse vetor - o índice desse símbolo. Os dados rotulados também são um vetor que consiste em zeros, onde um fica na posição do caractere após a sequência.
Para o treinamento, usamos duas seqüências de dados:
- Uma sequência de caracteres no texto de origem, no início do qual é adicionado um caractere especial que não faz parte do texto de origem. É geralmente referido como ir .
- A sequência de caracteres do texto de origem como está, sem acréscimos.
Exemplo para o texto "mãe lavou o quadro":
['<go>', '', '', ', '', ' ', '', '', '', '', ' ', '', '', '', ''] ['', '', ', '', ' ', '', '', '', '', ' ', '', '', '', '']
Para o treinamento, minibatches geralmente são formados, consistindo em um pequeno número de exemplos. No nosso caso, essas são cadeias que podem ter comprimentos diferentes. O código descrito abaixo usa o seguinte método para resolver o problema de diferentes comprimentos. Das muitas linhas neste minipacote, o comprimento máximo é calculado. Todas as outras linhas são preenchidas com um caractere especial (preenchimento), para que todos os exemplos no minipacket tenham o mesmo comprimento. No exemplo de código abaixo, a cadeia de pad é usada como um caractere. Além disso, para uma melhor geração, no final do exemplo, adicione o símbolo do final da frase - eos . Assim, na realidade, os dados do exemplo serão um pouco diferentes:
['<go>', '', '', ', '', ' ', '', '', '', '', ' ', '', '', '', '', '<eos>', '<pad>', '<pad>', '<pad>'] ['', '', ', '', ' ', '', '', '', '', ' ', '', '', '', '', '<eos>', '<pad>', '<pad>', '<pad>', '<pad>']
A primeira sequência é alimentada na entrada da rede e a segunda sequência é usada como dados marcados. O treinamento de previsão é baseado no deslocamento da sequência original de um caractere para a esquerda.
Treinamento e desova
Treinamento
O algoritmo de aprendizado é bastante simples. Para cada elemento da sequência de entrada, calculamos o vetor de saída do seu nível de projeção e o comparamos com o marcado. A única questão é como calcular o erro. Você pode usar o erro quadrático médio da raiz, mas para calcular o erro nessa situação, é melhor usar a entropia cruzada . A biblioteca Tensorflow fornece várias funções para seu cálculo, embora não haja nada que interrompa a implementação da fórmula de cálculo diretamente no código.
Para maior clareza, apresentamos algumas notações. Por symbol_id, indicaremos o identificador do símbolo (seu número de série no alfabeto). O termo símbolo aqui é bastante arbitrário e significa simplesmente um elemento do alfabeto. O alfabeto pode não conter símbolos, mas palavras ou mesmo alguns conjuntos de atributos mais complexos. O termo symbol_embedding será usado para denotar o vetor de números correspondentes a um determinado elemento do alfabeto. Normalmente, esses conjuntos de números são armazenados em uma tabela de tamanhos que corresponde ao tamanho do alfabeto.
O Tensorflow fornece um recurso que permite acessar a tabela de incorporação e substituir os índices de caracteres pelos vetores de incorporação. Primeiro, definimos uma variável para armazenar a tabela:
embedding_table = tf.Variable(tf.random_uniform([alphabet_size, embedding_size]))
Depois disso, você pode converter os tensores de entrada em incorporadores:
input_embeddings = tf.nn.embedding_lookup(embedding_table, input_ids)
O resultado da chamada de função é um tensor da mesma dimensão que foi transferida para a entrada, mas como resultado, todos os índices de caracteres são substituídos pelas sequências de incorporação correspondentes.
Spawn
Para calcular, uma célula de uma rede recursiva precisa de um estado e do caractere atual. O resultado do cálculo é uma saída e um novo estado. Se aplicarmos o nível de projeção à saída, podemos obter um vetor de pesos em que o peso na posição correspondente pode ser considerado (muito condicionalmente) como a probabilidade desse símbolo aparecer na próxima posição na sequência.
Várias estratégias podem ser usadas para selecionar o próximo símbolo com base no vetor de peso gerado pelo nível de projeção:
- Estratégia de busca gananciosa. Cada vez que selecionamos o símbolo com o maior peso, ou seja, provavelmente nesta situação, mas não necessariamente o mais apropriado no contexto de toda a sequência.
- Estratégia para escolher a melhor sequência (busca por feixe). Não selecionamos um símbolo de uma vez, mas lembre-se de várias variantes dos símbolos mais prováveis. Depois que todas essas opções são calculadas para todos os elementos da sequência gerada, selecionamos a sequência de caracteres mais provável, levando em consideração o contexto de toda a sequência. Geralmente, isso é realizado por meio de uma matriz cuja largura é igual ao comprimento da sequência e a altura ao número de variantes de caracteres geradores (largura de busca da viga). Após a geração das variantes de sequência, uma das variantes do algoritmo Viterbi é usada para selecionar a sequência mais provável.
Sistema de tipo seq2seq da biblioteca Tensorflow
Diante do exposto, fica claro que a implementação de modelos generativos baseados em redes de recorrência é uma tarefa bastante difícil de codificar. Portanto, naturalmente, foram propostos sistemas de classes para facilitar a solução desse problema. Um desses sistemas é chamado seq2seq, depois descrevemos a funcionalidade de seus principais tipos.
Mas, antes de tudo, algumas palavras sobre o nome da biblioteca. O nome seq2seq é a abreviação de sequência para sequência (de sequência para sequência). A idéia original de gerar uma sequência foi proposta para implementar um sistema de tradução. A sequência de entrada de palavras foi alimentada na entrada de uma rede recursiva, chamada codificador neste sistema. A saída dessa rede recursiva foi o estado do cálculo da célula no último caractere da sequência. Esse estado foi apresentado como o estado inicial da segunda rede recursiva, o decodificador, treinado para gerar a próxima palavra. As palavras foram usadas como símbolos nas duas redes. Erros no decorador foram propagados para o codificador através do estado transmitido. O próprio vetor de estado nessa terminologia foi chamado de vetor de pensamento. A apresentação intermediária foi usada nos modelos de tradução tradicionais e, como regra, era um gráfico representando a estrutura do texto de entrada para tradução. O sistema de tradução gerou texto de saída com base nessa estrutura intermediária.
Na verdade, a implementação do seq2seq no Tensorflow pertence à parte do decodificador, sem afetar o codificador. Portanto, seria correto chamar a biblioteca 2seq, mas a força da tradição e a inércia do pensamento aqui prevaleceram obviamente sobre o senso comum.
Os dois principais metatipos na biblioteca seq2seq são:
- Classe auxiliar .
- Decodificador de classe.
Os desenvolvedores da biblioteca identificaram esses tipos com base nas seguintes considerações. Vamos considerar o processo de aprendizagem e o processo de geração, que descrevemos acima, de um ângulo ligeiramente diferente.
Para o treinamento, você precisa:
- Para cada caractere, passe o cálculo do estado atual e a incorporação do caractere atual.
- Lembre-se do estado de saída e da projeção calculada para a saída.
- Obtenha o próximo caractere na sequência e vá para a etapa 1.
Depois disso, você pode começar a contar erros comparando os resultados dos cálculos com os seguintes caracteres da sequência.
Para gerá-lo é necessário:
- Para cada caractere, passe o cálculo do estado atual e a incorporação do caractere atual.
- Lembre-se do estado de saída e da projeção calculada para a saída.
- Calcule o próximo caractere como o máximo dos índices do nível de projeção e vá para a etapa 1.
Como pode ser visto na descrição, os algoritmos são muito semelhantes. Portanto, os desenvolvedores da biblioteca decidiram encapsular o procedimento para obter o próximo caractere na classe Helper. Para o treinamento, basta ler o próximo caractere da sequência e, para gerá-lo, selecionar o caractere com o peso máximo (é claro, para pesquisas gananciosas).
Portanto, a classe base Helper implementa o método next_inputs para obter o próximo caractere do estado atual e do estado, bem como o método de amostra para obter índices de caracteres no nível de projeção. A classe TrainingHelper é fornecida para treinamento, e a classe GreedyEmbeddingHelper está disponível para geração gananciosa. Infelizmente, o modelo de pesquisa de vigas não se encaixa nesse sistema de tipos, portanto, uma classe especial BeamSearchDecoder é implementada na biblioteca para isso. não usando o Helper.
A classe Decoder fornece uma interface para implementar um decodificador. De fato, a classe fornece dois métodos:
- inicialize para inicializar no início do trabalho.
- etapa para implementar uma etapa ou geração de aprendizado. O conteúdo desta etapa é determinado pelo auxiliar correspondente.
A biblioteca implementa a classe BasicDecoder , que pode ser usada para treinamento e criação com os assistentes TrainingHelper e GreedyEmbeddingHelper. Essas três classes geralmente são suficientes para implementar modelos de geração baseados em redes de recorrência.
Finalmente, as funções dynamic_decode são usadas para organizar a passagem por uma entrada ou sequência gerada.
A seguir, consideraremos um exemplo ilustrativo, que mostra métodos para construir modelos de geração para vários tipos de biblioteca seq2seq.
Exemplo ilustrativo
Antes de tudo, deve-se dizer que todos os exemplos são implementados no Python 2.7. Uma lista de bibliotecas adicionais pode ser encontrada no arquivo requirements.txt.
Como exemplo ilustrativo, considere parte dos dados do concurso Desafio de normalização de texto - idioma russo realizado pela Kaggle pelo Google em 2017. O objetivo deste concurso era converter o texto em russo em um formato adequado para leitura. O texto do concurso foi dividido em expressões digitadas. Os dados do treinamento foram especificados em um arquivo CSV do seguinte formato:
"sentence_id","token_id","class","before","after" 0,0,"PLAIN","","" 0,1,"PLAIN","","" 0,2,"PLAIN","","" 0,3,"DATE","1862 "," " 0,4,"PUNCT",".","." 1,0,"PLAIN","","" 1,1,"PLAIN","","" 1,2,"PLAIN","","" 1,3,"PLAIN","","" 1,4,"PLAIN","","" 1,5,"PLAIN","","" 1,6,"PLAIN","","" 1,7,"PLAIN","","" 1,8,"PLAIN","","" 1,9,"PUNCT",".","." ...
No exemplo acima, uma expressão do tipo DATE é interessante; nela, "1862" é traduzido em "mil oitocentos e sessenta e dois anos". Para ilustrar, consideramos os dados do tipo DATE apenas como pares do formulário (expressão anterior, expressão posterior). Início do arquivo de dados:
before,after 1862 , 1811 , 12 2013, 15 2013, 1905 , 17 2014, 7 2010 , 1 , 1843 , 30 2007 , 1846 , 1996 , 9 , ...
Construiremos o modelo de geração usando a biblioteca seq2seq, na qual o codificador será implementado no nível do símbolo (ou seja, os elementos do alfabeto são símbolos) e o decodificador usará as palavras como alfabeto. Código de exemplo, como dados, está disponível no repositório no Github .
Os dados do treinamento são divididos em três subconjuntos: train.csv, test.csv e dev.csv, para verificação de treinamento, teste e reciclagem, respectivamente. Os dados estão no diretório de dados. Três modelos são implementados no repositório: seq2seq_greedy.py, seq2seq_attention.py e seq2seq_beamsearch.py. Aqui, examinamos o código para o modelo básico de pesquisa gananciosa.
Todos os modelos usam a classe Estimator para implementar. O uso dessa classe permite simplificar a codificação sem se distrair com peças que não são do modelo. Por exemplo, não há necessidade de implementar um ciclo de transferência de dados para treinamento, criar sessões para trabalhar com o Tensorflow, pensar em transferir dados para o Tensorboard, etc. O Estimador requer apenas duas funções para sua implementação: para transferência de dados e para a construção de um modelo. Os exemplos também usam a classe Dataset para transmitir dados para processamento. Essa implementação moderna é muito mais rápida que os dicionários tradicionais para transferir dados no formulário feed_dict.
Considere um código de geração de dados para treinamento e geração.
def parse_fn(line_before, line_after):
A função input_fn é usada para criar uma coleção de dados que o Estimator passa para treinamento e geração. O tipo de dados é definido primeiro. Este é um par da forma ((sequência do codificador, comprimento), (sequência do decodificador, sequência do decodificador com um prefixo, comprimento)). A string "" é usada como prefixo, cada sequência do codificador termina com uma palavra especial "". Além disso, devido ao fato de as seqüências (entrada e saída) terem um comprimento desigual, o símbolo de preenchimento com o valor "" é usado.
O código de preparação de dados lê o arquivo de dados, divide a string do codificador em caracteres e a string do decodificador em palavras, usando a biblioteca nltk para isso. Uma linha processada dessa maneira é um exemplo de dados de treinamento. A coleção gerada é dividida em minipacotes e a quantidade de dados é clonada de acordo com o número de eras de treinamento (cada época é uma passagem de dados).
Trabalhar com dicionários
Os dicionários são armazenados como uma lista em arquivos, uma linha para uma palavra ou caractere. Para construir dicionários, use o script build_vocabs.py. Os dicionários gerados estão localizados no diretório de dados como arquivos no formato vocab. *. Txt.
Código para leitura de dicionários:
Aqui, provavelmente, a função index_table_from_file, que lê entradas de dicionário de um arquivo, é interessante e seu parâmetro num_oov_buckets é o número de cestas fora do vocabulário. Por padrão, esse número é igual a um, ou seja, todas as palavras que não estão no dicionário têm o mesmo índice igual ao tamanho do dicionário + 1. Temos três palavras desconhecidas: "", "" e "", para as quais queremos ter índices diferentes. Portanto, defina esse parâmetro como o número três. Infelizmente, você precisa ler o arquivo de entrada novamente para obter o número de palavras no dicionário como uma constante de tempo para definir o gráfico do modelo.
Ainda precisamos criar uma tabela para implementar a incorporação - _source_embedding, bem como converter cadeias de palavras em cadeias de identificação:
Implementação do codificador
Para o codificador, usaremos uma rede recursiva bidirecional com vários níveis. , , .
GRU, MultiRNNCell, , rnn.Cell. ,
sequence_length — , , .
, , , . , 128, 256. , , 128. .
. Porque , , bidirectional_dynamic_rnn, , . , . , .. . , , . , , .
, . .
TrainingHelper + BasicDecoder.
.
GreedyEmbeddingHelper "", "". . , , dynamic_decode . , , . , , .
, seq2seq.
, , sequence_mask.
Adam , .
optimizer = tf.train.AdamOptimizer(learning_rate=params.get('lr', .001)) grads, vs = zip(*optimizer.compute_gradients(loss)) grads, gnorm = tf.clip_by_global_norm(grads, params.get('clip', .5)) train_op = optimizer.apply_gradients(zip(grads, vs), global_step=tf.train.get_or_create_global_step())
. 0.9 . , , , . , .
24 1944 1 2003 1992 . 11 1927 1969 1 2016 1047 1863 17 22 2014
. — , — , — .
, — . . , ( ), . . , .
Conclusão
seq2seq. , , . , .
. Tensorflow , , . , , . , . , , padding , embedding ? , , . — . , , . , , , . , . , , , , .