Порождение данных с помощью рекурентной нейронной сети становится все более популярным методом и находит свое применение во многих областях компьютерной науки. С начала рождения концепции seq2seq в 2014 году прошло всего пять лет, но мир увидел множество применений, начиная с классических моделей перевода и распознавания речи, и заканчивая генерацией описаний объектов на фотографиях.


С другой стороны, со временем набрала популярность библиотека Tensorflow, выпущенная компанией Google специально для разработки нейронных сетей. Естественно, разработчики Google не могли обойти стороной такую популярную парадигму как seq2seq, поэтому библиотека Tensorflow предоставляет классы для разработки в рамках этой парадигмы. Эта статья посвящена описанию данной системы классов.


Рекурентные сети


В настоящее время рекурентные сети являются одним из наиболее известных и применяемых на практике формализмов построения глубоких нейронных сетей. Рекурентные сети предназначены для обработки последовательных данных, поэтому в отличие от обычной ячейки (нейрона), получающей на вход данные и выдающей на выход результат вычислений, рекурентная ячейка содержит два входа и два выхода.


Один из входов представляет данные текущего элемента последовательности, а второй вход называется состоянием и передается как результат вычислений ячейки на предыдущем элементе последовательности.


image

На рисунке изображена ячейка A, у которой на вход подается данные элемента последовательности $x_t$, а также не обозначенное здесь состояние $s_{t-1}$. На выход ячейка A выдает состояние $s_t$ и результат вычисления $h_t$.


На практике, последовательность данных обычно разделяют на подпоследовательности определенной фиксированной длины и передают на вычисление целыми подмножествами (batches). Иначе говоря, подпоследовательности представляют собой примеры для обучения. Входы, выходы и состояния ячеек рекурентной сети — это последовательности вещественных чисел. Для вычисления на входе $x_1$ необходимо использовать состояние, которое не было результатом вычисления на данной последовательности данных. Такие состояния называются начальными (initial states). Если последовательность достаточно длинная, то имеет смысл сохранять контекст вычислений на каждой подпоследовательности. В этом случае, можно в качестве начального состояния передавать последнее вычисленное состояние на предыдущей последовательности. Если последовательность не такая длинная или подпоследовательность является первым отрезком, то можно инициализировать начальное состояние нулями.


На данный момент, для обучения нейронных сетей почти везде используется алгоритм обратного распространения ошибки. Результат вычисления на переданном множестве примеров (в нашем случае, множестве подпоследовательностей) сверяется с ожидаемым результатом (размеченными данными). Разность между фактическим и ожидаемым значениями называют ошибкой и распространяют эту ошибку на веса сети в обратном направлении. Таким образом, сеть адаптируется под размеченные данные и, как правило, результат этой адаптации хорошо работает и для тех данных, которые сеть не встречала в исходных примерах для обучения (гипотеза обобщения).


В случае рекурентной сети у нас имеется несколько вариантов того, на каких выходах считать ошибку. Опишем здесь два основных:


  1. Можно считать ошибку, сравнивая выход последней ячейки подпоследовательности с ожидаемым выходом. Это хорошо работает для задачи классификации. Например, нам необходимо определить эмоциональную окраску твита. Для этого мы подбираем твиты и размечаем их на три категории: негативные, позитивные и нейтральные. Выходом ячейки будут три числа — веса категорий. Разметкой твита также будут три числа — вероятности принадлежности твита соответствующей категории. После вычисления ошибки на подмножестве данных, можно распространить ее через выход или состояние, как захочется.
  2. Можно считать ошибку сразу на выходах вычисления ячейки для каждого элемента подпоследовательности. Это хорошо подходит для задачи предсказания следующего элемента последовательности по предыдущим. Такой подход можно использовать, например, в задаче определения аномалий на временных рядах данных или задаче предсказания следующего символа в тексте, чтобы потом сгенерировать его. Распространять ошибку также можно через состояния или выходы.

В отличие от обычной полносвязной нейронной сети, рекурентная сеть является глубокой в том смысле, что ошибка распространяется не только вниз, от выходов сети к ее весам, но и налево, через связи между состояниями. Глубина сети, таким образом, определяется длиной подпоследовательности. Для распространения ошибки через состояния рекурентной сети имеется специальный алгоритм. Его особенность состоит в том, что градиенты весов перемножаются друг с другом, при распространении ошибки справа налево. Если начальная ошибка больше единицы, то в результате ошибка может стать очень большой. И наоборот, если начальная ошибка меньше единицы, то где-нибудь к началу последовательности ошибка может угаснуть. Эта ситуация в теории нейронных сетей называется каруселью стандартной ошибки. Для того, чтобы избежать подобных ситуаций при обучении, были придуманы специальные ячейки, которые не имеют таких недостатков. Первой такой ячейкой была LSTM, сейчас имеется широкий спектр альтернатив, из которых наиболее популярна GRU.


Хорошее введение в рекурентные сети можно найти в этой статье. Другой известный источник это статья из блога Андрея Карпаты.


В библиотеке Tensorflow имеется множество классов и функций, предназначенных для реализации рекурентных сетей. Приведем пример создания динамической рекурентной сети на основе ячейки типа GRU:


cell = tf.contrib.rnn.GRUCell(dimension)
outputs, state = tf.nn.dynamic_rnn(cell, input, sequence_length=input_length, dtype=tf.float32)

В данном примере создается ячейка GRU, которая затем используется для создания динамической рекурентной сети. В сеть передается тензор входных данных и действительные длины подпоследовательностей. Входные данные всегда задаются вектором вещественных чисел. Для одного значения, например, кода символа или слова, производится т.н. внедрение (embedding) — отображение этого кода на какую-то последовательность чисел. Функция создания динамической рекурентной сети возвращает пару значений: список выходов сети для всех значений последовательности и последнее вычисленное состояние. В качестве входа функция принимает ячейку, входные данные и тензор длин подпоследовательностей.


Динамическая рекурентная сеть отличается от статической тем, что не создает сеть ячеек сети для подпоследовательности заранее (на этапе определения графа вычисления), но запускает ячейки на входы динамически, во время вычисления графа на входных данных. Поэтому этой функции необходимо знать длины подпоследовательностей входных данных, чтобы остановиться в нужный момент.


Порождающие модели на основе рекурентных сетей


Порождающие рекурентные сети


Ранее мы рассматривали два способа вычисления ошибок рекурентных сетей: на последнем выходе или на всех выходах для данной последовательности. Здесь мы рассмотрим задачу порождения последовательностей. Обучение порождающей сети основано на втором способе из перечисленных выше.


Более детально, мы пытаемся обучить рекурентную сеть предсказывать следующий элемент последовательности. Как уже говорилось выше, выход ячейки рекурентной сети это просто последовательность чисел. Этот вектор не очень удобен для обучения, поэтому вводят еще один уровень, который на вход получает этот вектор, а на выходе дает веса предсказаний. Этот уровень называется уровнем проекции и позволяет сравнивать выход ячейки на данном элементе последовательности с ожидаемым выходом в размеченных данных.


Для иллюстрации, рассмотрим задачу порождения текста, представляемого как последовательность символов. Длина вектора выхода уровня проекции равна размеру алфавита исходного текста. Размер алфавита обычно не превышает 150 символов, если считать символы русского и английского языков, а также знаки препинания. Выход уровня проекции это вектор длиной алфавита, где каждому символу соответствует некоторая позиция в этом векторе — индекс данного символа. Размеченные данные это также вектора, состоящие из нулей, где на позиции символа, следующего в последовательности, стоит единица.


Для обучения мы используем две последовательности данных:


  1. Последовательность символов исходного текста, в начале которой добавлен специальный символ, не входящий в исходный текст. Он обычно обозначается как go.
  2. Последовательность символов исходного текста как есть, без добавлений.

Пример для текст "мама мыла раму":


['<go>', 'м', 'а', 'м, 'а', ' ', 'м', 'ы', 'л', 'а', ' ', 'р', 'а', 'м', 'у']
['м', 'а', 'м, 'а', ' ', 'м', 'ы', 'л', 'а', ' ', 'р', 'а', 'м', 'у']

Для обучения обычно формируются минипакеты (minibatches), состоящие из небольшого числа примеров. В нашем случае это строки, которые могут быть разной длины. В описываемом далее коде для решения проблемы разных длин используется следующий метод. Из множества строк в данном минипакете вычисляется максимальная длина. Все остальные строки заполняются специальным символом (padding), чтобы все примеры в минипакете были одной и той же длины. В примера кода ниже в качестве такого символа для заполнения используется строка pad. Также, для лучшего порождения в конец примера добавляют еще символ конца предложения — eos. Таким образом, в реальности данные из примера будут выглядеть чуть иначе:


['<go>', 'м', 'а', 'м, 'а', ' ', 'м', 'ы', 'л', 'а', ' ', 'р', 'а', 'м', 'у', '<eos>', '<pad>', '<pad>', '<pad>']
['м', 'а', 'м, 'а', ' ', 'м', 'ы', 'л', 'а', ' ', 'р', 'а', 'м', 'у', '<eos>', '<pad>', '<pad>', '<pad>', '<pad>']

Первая последовательность подается на вход сети, а вторая последовательность используется в качестве размеченных данных. Обучение предсказанию основано на сдвиге исходной последовательности на один символ влево.


Обучение и порождение


Обучение


Алгоритм обучения достаточно прост. Для каждого элемента входной последовательности вычисляем вектор выхода его уровня проекции и сравниваем его с размеченным. Вопрос только в том, каким образом производить вычисление ошибки. Можно использовать среднюю квадратичную ошибку, но для вычисления ошибки в данной ситуации лучше использовать перекрестную энтропию. Библиотека Tensorflow предоставляет несколько функций для ее вычисления, хотя ничто не мешает реализовать формулу вычисления непосредственно в коде.


Для ясности, введем некоторые обозначения. Через symbol_id будем обозначать идентификатор символа (его порядковый номер в алфавите). Термин symbol здесь достаточно условен и обозначает просто элемент алфавита. В алфавите могут быть не символы, а слова или даже какие-то более сложные наборы признаков. Термин symbol_embedding будем использовать для обозначения вектора чисел, соответствующего данному элементу алфавита. Обычно, такие наборы чисел хранятся в таблице размера, совпадающего с размером алфавита.


Tensorflow предоставляет функцию, позволяющую обращаться к таблице embedding и заменять индексы символов на их embedding вектора. Сначала определяем переменную для хранения таблицы:


embedding_table = tf.Variable(tf.random_uniform([alphabet_size, embedding_size]))

После этого, можно преобразовывать тензоры входных данных в тензоры embedding:


input_embeddings = tf.nn.embedding_lookup(embedding_table, input_ids)

Результат вызова функции — это тензор той же размерности, что был передан на вход, но в результате все индексы символов заменены на соответствующие embedding последовательности.


Порождение


Для вычисления, ячейке рекурентной сети необходимо состояние и текущий символ. Результатом вычисления является выход и новое состояние. Если к выходу применить уровень проекции, то можно получить вектор весов, где вес на соответствующей позиции можно рассматривать (очень условно) как вероятность появления этого символа на следующей позиции в последовательности.


Для выбора следующего символа на основе вектора весов, порождаемого уровнем проекции, можно использовать различные стратегии:


  • Стратегия жадного поиска (greedy search). Мы каждый раз выбираем символ с наиболее высоким весом, т.е. наиболее вероятный в данной ситуации, но не обязательно наиболее подходящий в контексте всей последовательности.
  • Стратегия выбора наилучшей последовательности (beam search). Мы не выбираем символ сразу, но запоминаем несколько вариантов наиболее вероятных символов. После того, как все такие варианты вычислены для всех элементов порождаемой последовательности, выбираем наиболее вероятную последовательность символов с учетом контекста всей последовательности. Обычно, это реализуется посредством матрицы, ширина которой равняется длине последовательности, а высота — количеству вариантов порождения символов (beam search width). После того, как генерация вариантов последовательности закончена, используется один из вариантов алгоритма Витерби для выбора наиболее вероятной последовательности.

Система типов seq2seq в библиотеке Tensorflow


С учетом описанного выше, ясно, что реализация порождающих моделей на основе рекурентных сетей представляет собой довольно сложную задачу для кодирования. Поэтому, естественно, что были предложены системы классов для облегчения решения этой задачи. Одна из таких систем называется seq2seq, далее мы опишем функциональность ее основных типов.


Но, прежде всего, несколько слов о названии библиотеки. Название seq2seq это аббревиатура sequence to sequence (из последовательности в последовательность). Оригинальная идея порождения последовательности была предложена для реализации системы перевода. Входная последовательность слов подавалась на вход рекурентной сети, называемой в этой системе кодировщик (encoder). Выходом это рекурентной сети считалось состояние вычисления ячейки на последнем символе последовательности. Это состояние подавалось как начальное состояние второй рекурентной сети — декодировщика (decoder), которая обучалась для порождения следующего слова. В качестве символов в обеих сетях использовались слова. Ошибки на декоровщике распространялись на кодировщик через передаваемое состояние. Сам вектор состояния в этой терминологии назывался вектором промежуточного представления (thought vector). Промежуточное предсталение использовалось в традиционных моделях перевода и, как правило, представляло собой граф представления структуры входного текста для перевода. Система перевода генерировала выходной текст на основе этой промежуточной структуры.


Собственно, реализация seq2seq в Tensorflow относится к части decoder, не затрагивая кодировщика. Поэтому, правильно было бы назвать библиотеку 2seq, но сила традиции и инерция мышления здесь, очевидно, превозмогли здравый смысл.


Два основных метатипа в библиотеке seq2seq это:


  1. Класс Helper.
  2. Класс Decoder.

Разработчики библиотеки выделили эти типы исходя из следующих соображений. Рассмотрим немного под другим углом процесс обучения и процесс порождения, который мы описывали выше.


Для обучения необходимо:


  1. Для каждого символа передать на вычисление текущее состояние и embedding текущего символа.
  2. Запомнить выходное состояние и вычисленные для выхода проекции.
  3. Получить следующий символ последовательности и перейти к шагу 1.

После этого, можно начать считать ошибки, сравнивая результаты вычислений со следующими символами последовательности.


Для порождения необходимо:


  1. Для каждого символа передать на вычисление текущее состояние и embedding текущего символа.
  2. Запомнить выходное состояние и вычисленные для выхода проекции.
  3. Вычислить следующий символ как максимум из значений индексов уровня проекции и перейти к шагу 1.

Как видно из описания, алгоритмы очень похожи. Поэтому, разработчики библиотеки решили инкапсулировать в классе Helper процедуру получения следующиего символа. Для обучения, это просто чтение следующего символа из последовательности, а для порождения — выделения символа с максимальным весом (конечно, для greedy search).


Соответственно, в базовом классе Helper реализованы метод next_inputs для получения следующего символа из текущего и состояния, а также метод sample для получения индексов символов из уровня проекции. Для реализации обучения предоставляется класс TrainingHelper, а для реализации порождения методом жадного поиска — класс GreedyEmbeddingHelper. К сожалению, модель beam search не укладывается в эту систему типов, поэтому в библиотеке для этого реализован специальный класс BeamSearchDecoder. не использующий Helper.


Класс Decoder предоставляет интерфейс для реализации декодировщика. Фактически, класс предоставляет два метода:


  1. initialize для инициализации в начале работы.
  2. step для осуществления шага обучения или порождения. Содержание этого шага определяется соответствующим Helper.

В библиотеке реализован класс BasicDecoder, который можно использовать как для обучения, так и для порождения с помощниками TrainingHelper и GreedyEmbeddingHelper. Этих трех классов обычно хватает для реализации моделей порождений на основе рекурентных сетей.


Наконец, для организации прохода по входной или порождаемой последовательности используется функций dynamic_decode.


Далее мы рассмотрим иллюстративный пример, в котором показаны методы построения порождающих моделей для различных типов библиотеки seq2seq.


Иллюстративный пример


Прежде всего, следует сказать, что все примеры реализованы в Python 2.7. Список дополнительных библиотек можно найти в файле requirements.txt.


В качестве иллюстративного примера рассмотрим часть данных для конкурса Text Normalization Challenge — Russian Language, проводимого на Kaggle компанией Google в 2017 году. Цель этого конкурса состояла в преобразовании русского текста в форму, пригодную для зачитывания. Текст для конкурса был разбит на типизированные выражения. Данные для обучения были заданы в CSV файле следующего вида:


"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",".","."
...

В примере выше интересно выражение типа DATE, в нем "1862 год" переводится в "тысяча восемьсот шестьдесят второй год". Для иллюстрации мы рассмотрим данные только типа DATE как пары вида (выражение до, выражение после). Начало файла данных:


before,after
1862 год,тысяча восемьсот шестьдесят второй год
1811 года,тысяча восемьсот одиннадцатого года
12 февраля 2013,двенадцатого февраля две тысячи тринадцатого года
15 февраля 2013,пятнадцатого февраля две тысячи тринадцатого года
1905 года,тысяча девятьсот пятого года
17 июля 2014,семнадцатого июля две тысячи четырнадцатого года
7 октября 2010 года,седьмого октября две тысячи десятого года
1 марта,первого марта
1843 году,тысяча восемьсот сорок третьем году
30 мая 2007 года,тридцатого мая две тысячи седьмого года
1846 году,тысяча восемьсот сорок шестом году
1996 году,тысяча девятьсот девяносто шестом году
9 марта,девятое марта
...

Мы построим порождающую модель с помощью библиотеки seq2seq, в которой кодировщик будет реализован на уровне символов (т.е. элементы алфавита — это символы), а декодировщик будет использовать слова в качестве алфавита. Код примера, как и данные, доступны в репозитории на Github.


Данные для обучения разделены на три подмножества: train.csv, test.csv и dev.csv, для обучения, тестирования и проверки переобучения, соответственно. Данные находятся в директории data. В репозитории реализованы три модели: seq2seq_greedy.py, seq2seq_attention.py и seq2seq_beamsearch.py. Здесь мы рассмотрим код базовой модели жадного поиска.


Все модели используют класс Estimator для реализации. Использование этого класса позволяет упростить кодирование, не отвлекаясь на не относящиеся к модели детали. Например, нет необходимости реализовывать цикл передачи данных на обучение, создавать сессии для работы с Tensorflow, думать о передаче данных на Tensorboard и т.п. Для реализации Estimator требует только две функции: для передачи данных и для построения модели. В примерах также используется класс Dataset для передачи данных на обработку. Эта современная реализация работает гораздо быстрее традиционных словарей для передачи данных вида feed_dict.


Формирование данных


Рассмотрим код формирования данных для обучения и порождения.


def parse_fn(line_before, line_after):
    # Encode in Bytes for TF
    source = [c.encode('utf8') for c in line_before.decode('utf8').rstrip('\n')]
    t = [w.encode('utf8') for w in nltk.word_tokenize(line_after.decode('utf8').strip())]
    learn_target = t + ['<eos>'] + ['<pad>']
    target = ['<go>'] + t + ['<eos>']
    return (source, len(source)), (target, learn_target, len(target))

def generator_fn(data_file):
    with open(data_file, 'rb') as f:
        reader = csv.DictReader(f, delimiter=',', quotechar='"')
        for row in reader:
            yield parse_fn(row['before'], row['after'])

def input_fn(data_file, params=None):
    params = params if params is not None else {}
    shapes = (([None], ()), ([None], [None], ()))
    types = ((tf.string, tf.int32), (tf.string, tf.string, tf.int32))
    defaults = (('<pad>', 0), ('<pad>', '<pad>', 0))

dataset = tf.data.Dataset.from_generator(functools.partial(generator_fn, data_file), output_shapes=shapes, output_types=types)
dataset = dataset.repeat(params['epochs'])
return (dataset.padded_batch(params.get('batch_size', 50), shapes, defaults).prefetch(1))

Функция input_fn используется для создания коллекции данных, которую потом Estimator передает на обучение и порождение. Сначала задается тип данных. Это пара вида ((последовательность кодировщика, длина), (последовательность декодировщика, последовательность декодировщика с префиксом, длина)). В качестве префиксов используется строка "", каждая последовательность кодировщика заканчивается специальным словом "". Также, ввиду того, что последовательности (как входные, так и выходные) имеют неравную длину, используется символ заполнения (padding) со значением "".

Код подготовки данных читает файл с данными, строку кодировщика разделяет на символы, а строку декодировщика на слова, используя для этого библиотеку nltk. Обработанная таким образом строка представляет собой пример данных для обучения. Сформированная коллекция разделяется на минипакеты, причем количество данных клонируется в соответствии с числом эпох обучения (каждая эпоха — один проход данных).


Работа со словарями


Словари хранятся в виде списка в файлах, одна строка для одного слова или символа. Для формирования словарей используется скрипт build_vocabs.py. Сформированные словари находятся в директории data как файлы вида vocab.*.txt.


Код чтения словарей:


# Read vocabs and inputs
dropout = params['dropout']
source, source_length = features
training = (mode == tf.estimator.ModeKeys.TRAIN)
vocab_source = tf.contrib.lookup.index_table_from_file(vocabulary_file=params['source_vocab_file'], num_oov_buckets=params['num_oov_buckets'])
with open(params['source_vocab_file']) as f:
    num_sources = sum(1 for _ in f) + params['num_oov_buckets']
vocab_target = tf.contrib.lookup.index_table_from_file(vocabulary_file=params['target_vocab_file'], num_oov_buckets=params['num_oov_buckets'])
with open(params['target_vocab_file']) as f:
    num_targets = sum(1 for _ in f) + params['num_oov_buckets']

Здесь, наверное, интересна функция index_table_from_file, которая читает элементы словаря из файла, и ее параметр num_oov_buckets — число корзин для неизвестных слов (out of vocabulary). По умолчанию, это число равно единице, т.е. все слова, которых нет в словаре, имеют один и тот же индекс, равный размеру словаря + 1. У нас имеется три неизвестных слова: "", "" и "", для которых мы хотим иметь различные индексы. Поэтому, устанавливаем этот параметр в число три. К сожалению, приходится еще раз читать входной файл, чтобы получить количество слов в словаре в виде константы времени задания графа модели.

Нам еще необходимо создать таблицу для реализации embedding — _source_embedding, а также перевода строк слов в строки идентификаторов:


# source embeddings matrix
_source_embedding = tf.Variable(tf.random_uniform([num_sources, params['embedding_size']]))
source_ids = vocab_source.lookup(source)
source_embedding = tf.nn.embedding_lookup(_source_embedding, source_ids)

Реализация кодировщика


Для кодировщика мы будем использовать двунаправленную рекурентную сеть с несколькими уровнями. Использование уровней увеличивает глубину сети, что способствует лучшему обучению представлений, а использование двунаправленной сети повышает качество.


# add multilayer bidirectional RNN
cell_fw = tf.contrib.rnn.MultiRNNCell([tf.contrib.rnn.GRUCell(params['dim']) for _ in range(params['layers'])])

cell_bw = tf.contrib.rnn.MultiRNNCell([tf.contrib.rnn.GRUCell(params['dim']) for _ in range(params['layers'])])

outputs, states = tf.nn.bidirectional_dynamic_rnn(cell_fw, cell_bw, source_embedding, sequence_length=source_length, dtype=tf.float32)

# prepare output
output = tf.concat(outputs, axis=-1)
encoder_output = tf.layers.dense(output, params['dim'])

# prepare state
state_fw, state_bw = states
cells = []
for fw, bw in zip(state_fw, state_bw):
    state = tf.concat([fw, bw], axis=-1)
    cells += [tf.layers.dense(state, params['dim'])]
encoder_state = tuple(cells)

Для двунаправленной сети используем список ячеек GRU, который обертываем в MultiRNNCell, которая предоставляет интерфейс, совместимый с rnn.Cell. Потом задаем двунаправленную сеть,
sequence_length — это столбец длин каждого примера, для динамической сети он необходим, как мы обсуждали выше.


Наконец, мы объединяем выходы прямой и обратной сети, используя для этого конкатенацию двух последовательностей чисел, которую представляют собой выходы запуска ячейки на каждом элементе входной последовательности. Эта операция увеличивает размерность выходных данных, если исходный выход был длиной 128, то конкатенация двух таких выходов будет длиной 256. Чтобы не увеличивать размерность сети, мы пропускаем этот выход через обычный полносвязный слой, который на выходе также дает 128. На качество обучения эта операция не имеет влияния.


Чуть сложнее операция объединения состояния. Т.к. сеть имеет несколько уровней, состояния, которые возвращает функция bidirectional_dynamic_rnn, представляют собой кортеж, составленный из состояний каждого уровня. Мы должны пройти по этому кортежу и объединить состояния соответствующих уровней, снизив тут же размерность этого объединения. Результат также должен быть кортежем, т.к. он передается декодировщика в качестве начального состояния. Здесь, конечно, возможны варианты. В данном случае декодировщик также использует сеть из того же количества уровней, что и кодировщик, поэтому кортеж должен быть той же размерности.


Реализация декодировщика


Декодировщик реализован как для обучения, так и для генерации. Сначала необходимо создать ячейку рекурентной сети кодировщика и добавить уровень проекции.


 # decoder RNN cell
decoder_cell = tf.contrib.rnn.MultiRNNCell([tf.contrib.rnn.GRUCell(params['dim']) for _ in range(params['layers'])])

decoder_initial_state = encoder_state

# projection layer
projection_layer = tf.layers.Dense(num_targets, use_bias=False)

# embedding table for targets
target_embedding = tf.Variable(tf.random_uniform([num_targets, params['embedding_size']]))

Обучение


Для обучения используем TrainingHelper + BasicDecoder.


 # target embeddings matrix
 target, learn_target, target_length = labels
 target_ids = vocab_target.lookup(target)
 target_learn_ids = vocab_target.lookup(learn_target)

 # train encoder
 _target_embedding = tf.nn.embedding_lookup(target_embedding, target_ids)
 train_helper = tf.contrib.seq2seq.TrainingHelper(_target_embedding, target_length)

 train_decoder = tf.contrib.seq2seq.BasicDecoder(decoder_cell, train_helper, decoder_initial_state, output_layer=projection_layer)

train_outputs, _, _ = tf.contrib.seq2seq.dynamic_decode(train_decoder)
train_output = train_outputs.rnn_output
train_sample_id = train_outputs.sample_id

Порождение


В данном примере используем жадный поиск для порождения.


# prediction decoder
prediction_helper = tf.contrib.seq2seq.GreedyEmbeddingHelper(
            embedding=target_embedding,
            start_tokens=tf.fill([batch_size], tf.to_int32(vocab_target.lookup(tf.fill([], '<go>')))),
            end_token=tf.to_int32(vocab_target.lookup(tf.fill([], '<eos>'))))

prediction_decoder = tf.contrib.seq2seq.BasicDecoder(decoder_cell, prediction_helper, decoder_initial_state, output_layer=projection_layer)

prediction_output, _, _ = tf.contrib.seq2seq.dynamic_decode(prediction_decoder, maximum_iterations=params['max_iters'])

# prepare prediction
reverse_vocab_target = tf.contrib.lookup.index_to_string_table_from_file(params['target_vocab_file'])

pred_strings = reverse_vocab_target.lookup(tf.to_int64(prediction_output.sample_id))
predictions = {
        'ids': prediction_output.sample_id,
        'text': pred_strings
}

В функцию инициализации класса GreedyEmbeddingHelper мы должны передать столбец начальных символов "", а также конечный символ "". При порождении этого символа цикл порождения будет остановлен. Также, интересно, что в функцию dynamic_decode передается максимальное число элементов порождаемой последовательности. Это необходимо для того, чтобы алгоритм порождения знал, когда ему остановиться. Либо, если он встретит символ конца, либо когда достигнуто максимальное число элементов порождаемой последовательности.

Функция потерь и оптимизация


Функция потерь это перекрестная энтропия, для реализации которой используется встроенная функция библиотеки seq2seq.


# loss
masks = tf.sequence_mask(lengths=target_length, dtype=tf.float32)
loss = tf.contrib.seq2seq.sequence_loss(logits=train_output, targets=target_learn_ids, weights=masks)

Чтобы правильно подсчитать энтропию, необходимо передать функции длины последовательностей, для их получения используется функция 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
двадцать второе апреля две тысячи четырнадцатого года
двадцать вторым апреля две тысячи четырнадцатого года

Здесь показаны результаты предсказаний в виде троек строк. Первая строка — это исходное выражение, вторая строка — размеченные данные, третья строка — порожденное выражение.


Как видно, подавляющее число ошибок — это ошибки склонения. В обучающих данных присутствуют различные формы склонения для одних и тех же выражений. Какую форму выбрать конкретно, зависит от контекста (предыдущего слова), который в этой обучающей выборке не доступен. Отсюда и большое количество ошибок такого рода. Для конкурса с данными на английском языке результаты намного лучше, качество предсказания близко к абсолютному.


Заключение


В данной статья автор постарался донести читателю свой опыт изучения библиотеки seq2seq. Тема оказалась достаточно сложная для того, чтобы уложить ее целиком в одну статью в том формате, который предполагают публикации на Хабре. Насколько хорошо это получилось, судить читателю.


Тематика построения глубоких сетей сама по себе достаточно сложная для изучения. Библиотека Tensorflow также достаточно сложна, документация крайне скудна, что еще более затрудняет изучение. По мнению автора, публикации по теме глубокого обучения грешат либо излишней конкретикой, либо излишней абстракцией. Изучающий вынужден продираться сквозь дебри различных частных проблем, связанных с использованием тех или иных элементов библиотеки. Например, как подавать данные на вход модели, как осуществляется padding для них, зачем необходим embedding и как его правильно делать? Основная масса статей не комментирует эти вещи, подразумевая, что читатель уже имеет о них представление. Эта статья представляет собой попытку использовать другой стиль — развернутый комментарий к исходному коду. Там, где автор счел необходимым, была описана теоретическая проблематика. Там, где по мнению автора, необходимо было пояснить частности, он попытался это сделать. Конечно, в процессе написания наверняка многое осталось неясным. Поэтому, автор открыт для общения и с удовольствием ответит на вопросы, которые, возможно, появятся у читателя в результате прочтения исходного кода.

Комментарии (2)


  1. Alexey_mosc
    19.02.2019 17:31

    Володя, привет! Супер статья, спасибо! Надеюсь доберусь до seq2seq со своими новостями в ТАССе.


  1. Macvol
    21.02.2019 16:22

    Отличная статья! Владимир, спасибо!