Реализация моделей seq2seq в Tensorflow |
||
МЕНЮ Искусственный интеллект Поиск Регистрация на сайте Помощь проекту ТЕМЫ Новости ИИ Искусственный интеллект Разработка ИИГолосовой помощник Городские сумасшедшие ИИ в медицине ИИ проекты Искусственные нейросети Слежка за людьми Угроза ИИ ИИ теория Внедрение ИИКомпьютерные науки Машинное обуч. (Ошибки) Машинное обучение Машинный перевод Реализация ИИ Реализация нейросетей Создание беспилотных авто Трезво про ИИ Философия ИИ Big data Работа разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика
Генетические алгоритмы Капсульные нейросети Основы нейронных сетей Распознавание лиц Распознавание образов Распознавание речи Техническое зрение Чат-боты Авторизация |
2019-02-20 09:37 Порождение данных с помощью рекурентной нейронной сети становится все более популярным методом и находит свое применение во многих областях компьютерной науки. С начала рождения концепции seq2seq в 2014 году прошло всего пять лет, но мир увидел множество применений, начиная с классических моделей перевода и распознавания речи, и заканчивая генерацией описаний объектов на фотографиях. С другой стороны, со временем набрала популярность библиотека Tensorflow, выпущенная компанией Google специально для разработки нейронных сетей. Естественно, разработчики Google не могли обойти стороной такую популярную парадигму как seq2seq, поэтому библиотека Tensorflow предоставляет классы для разработки в рамках этой парадигмы. Эта статья посвящена описанию данной системы классов. Рекурентные сети В настоящее время рекурентные сети являются одним из наиболее известных и применяемых на практике формализмов построения глубоких нейронных сетей. Рекурентные сети предназначены для обработки последовательных данных, поэтому в отличие от обычной ячейки (нейрона), получающей на вход данные и выдающей на выход результат вычислений, рекурентная ячейка содержит два входа и два выхода. Один из входов представляет данные текущего элемента последовательности, а второй вход называется состоянием и передается как результат вычислений ячейки на предыдущем элементе последовательности.
На рисунке изображена ячейка A, у которой на вход подается данные элемента последовательности , а также не обозначенное здесь состояние . На выход ячейка A выдает состояние и результат вычисления . На практике, последовательность данных обычно разделяют на подпоследовательности определенной фиксированной длины и передают на вычисление целыми подмножествами (batches). Иначе говоря, подпоследовательности представляют собой примеры для обучения. Входы, выходы и состояния ячеек рекурентной сети — это последовательности вещественных чисел. Для вычисления на входе необходимо использовать состояние, которое не было результатом вычисления на данной последовательности данных. Такие состояния называются начальными (initial states). Если последовательность достаточно длинная, то имеет смысл сохранять контекст вычислений на каждой подпоследовательности. В этом случае, можно в качестве начального состояния передавать последнее вычисленное состояние на предыдущей последовательности. Если последовательность не такая длинная или подпоследовательность является первым отрезком, то можно инициализировать начальное состояние нулями. На данный момент, для обучения нейронных сетей почти везде используется алгоритм обратного распространения ошибки. Результат вычисления на переданном множестве примеров (в нашем случае, множестве подпоследовательностей) сверяется с ожидаемым результатом (размеченными данными). Разность между фактическим и ожидаемым значениями называют ошибкой и распространяют эту ошибку на веса сети в обратном направлении. Таким образом, сеть адаптируется под размеченные данные и, как правило, результат этой адаптации хорошо работает и для тех данных, которые сеть не встречала в исходных примерах для обучения (гипотеза обобщения). В случае рекурентной сети у нас имеется несколько вариантов того, на каких выходах считать ошибку. Опишем здесь два основных:
В отличие от обычной полносвязной нейронной сети, рекурентная сеть является глубокой в том смысле, что ошибка распространяется не только вниз, от выходов сети к ее весам, но и налево, через связи между состояниями. Глубина сети, таким образом, определяется длиной подпоследовательности. Для распространения ошибки через состояния рекурентной сети имеется специальный алгоритм. Его особенность состоит в том, что градиенты весов перемножаются друг с другом, при распространении ошибки справа налево. Если начальная ошибка больше единицы, то в результате ошибка может стать очень большой. И наоборот, если начальная ошибка меньше единицы, то где-нибудь к началу последовательности ошибка может угаснуть. Эта ситуация в теории нейронных сетей называется каруселью стандартной ошибки. Для того, чтобы избежать подобных ситуаций при обучении, были придуманы специальные ячейки, которые не имеют таких недостатков. Первой такой ячейкой была LSTM, сейчас имеется широкий спектр альтернатив, из которых наиболее популярна GRU. Хорошее введение в рекурентные сети можно найти в этой статье. Другой известный источник это статья из блога Андрея Карпаты. В библиотеке Tensorflow имеется множество классов и функций, предназначенных для реализации рекурентных сетей. Приведем пример создания динамической рекурентной сети на основе ячейки типа GRU:
В данном примере создается ячейка GRU, которая затем используется для создания динамической рекурентной сети. В сеть передается тензор входных данных и действительные длины подпоследовательностей. Входные данные всегда задаются вектором вещественных чисел. Для одного значения, например, кода символа или слова, производится т.н. внедрение (embedding) — отображение этого кода на какую-то последовательность чисел. Функция создания динамической рекурентной сети возвращает пару значений: список выходов сети для всех значений последовательности и последнее вычисленное состояние. В качестве входа функция принимает ячейку, входные данные и тензор длин подпоследовательностей. Динамическая рекурентная сеть отличается от статической тем, что не создает сеть ячеек сети для подпоследовательности заранее (на этапе определения графа вычисления), но запускает ячейки на входы динамически, во время вычисления графа на входных данных. Поэтому этой функции необходимо знать длины подпоследовательностей входных данных, чтобы остановиться в нужный момент. Порождающие модели на основе рекурентных сетей Порождающие рекурентные сети Ранее мы рассматривали два способа вычисления ошибок рекурентных сетей: на последнем выходе или на всех выходах для данной последовательности. Здесь мы рассмотрим задачу порождения последовательностей. Обучение порождающей сети основано на втором способе из перечисленных выше. Более детально, мы пытаемся обучить рекурентную сеть предсказывать следующий элемент последовательности. Как уже говорилось выше, выход ячейки рекурентной сети это просто последовательность чисел. Этот вектор не очень удобен для обучения, поэтому вводят еще один уровень, который на вход получает этот вектор, а на выходе дает веса предсказаний. Этот уровень называется уровнем проекции и позволяет сравнивать выход ячейки на данном элементе последовательности с ожидаемым выходом в размеченных данных. Для иллюстрации, рассмотрим задачу порождения текста, представляемого как последовательность символов. Длина вектора выхода уровня проекции равна размеру алфавита исходного текста. Размер алфавита обычно не превышает 150 символов, если считать символы русского и английского языков, а также знаки препинания. Выход уровня проекции это вектор длиной алфавита, где каждому символу соответствует некоторая позиция в этом векторе — индекс данного символа. Размеченные данные это также вектора, состоящие из нулей, где на позиции символа, следующего в последовательности, стоит единица. Для обучения мы используем две последовательности данных:
Пример для текст "мама мыла раму":
Для обучения обычно формируются минипакеты (minibatches), состоящие из небольшого числа примеров. В нашем случае это строки, которые могут быть разной длины. В описываемом далее коде для решения проблемы разных длин используется следующий метод. Из множества строк в данном минипакете вычисляется максимальная длина. Все остальные строки заполняются специальным символом (padding), чтобы все примеры в минипакете были одной и той же длины. В примера кода ниже в качестве такого символа для заполнения используется строка pad. Также, для лучшего порождения в конец примера добавляют еще символ конца предложения — eos. Таким образом, в реальности данные из примера будут выглядеть чуть иначе:
Первая последовательность подается на вход сети, а вторая последовательность используется в качестве размеченных данных. Обучение предсказанию основано на сдвиге исходной последовательности на один символ влево. Обучение и порождение Обучение Алгоритм обучения достаточно прост. Для каждого элемента входной последовательности вычисляем вектор выхода его уровня проекции и сравниваем его с размеченным. Вопрос только в том, каким образом производить вычисление ошибки. Можно использовать среднюю квадратичную ошибку, но для вычисления ошибки в данной ситуации лучше использовать перекрестную энтропию. Библиотека Tensorflow предоставляет несколько функций для ее вычисления, хотя ничто не мешает реализовать формулу вычисления непосредственно в коде. Для ясности, введем некоторые обозначения. Через symbol_id будем обозначать идентификатор символа (его порядковый номер в алфавите). Термин symbol здесь достаточно условен и обозначает просто элемент алфавита. В алфавите могут быть не символы, а слова или даже какие-то более сложные наборы признаков. Термин symbol_embedding будем использовать для обозначения вектора чисел, соответствующего данному элементу алфавита. Обычно, такие наборы чисел хранятся в таблице размера, совпадающего с размером алфавита. Tensorflow предоставляет функцию, позволяющую обращаться к таблице embedding и заменять индексы символов на их embedding вектора. Сначала определяем переменную для хранения таблицы:
После этого, можно преобразовывать тензоры входных данных в тензоры embedding:
Результат вызова функции — это тензор той же размерности, что был передан на вход, но в результате все индексы символов заменены на соответствующие embedding последовательности. Порождение Для вычисления, ячейке рекурентной сети необходимо состояние и текущий символ. Результатом вычисления является выход и новое состояние. Если к выходу применить уровень проекции, то можно получить вектор весов, где вес на соответствующей позиции можно рассматривать (очень условно) как вероятность появления этого символа на следующей позиции в последовательности. Для выбора следующего символа на основе вектора весов, порождаемого уровнем проекции, можно использовать различные стратегии:
Система типов seq2seq в библиотеке Tensorflow С учетом описанного выше, ясно, что реализация порождающих моделей на основе рекурентных сетей представляет собой довольно сложную задачу для кодирования. Поэтому, естественно, что были предложены системы классов для облегчения решения этой задачи. Одна из таких систем называется seq2seq, далее мы опишем функциональность ее основных типов. Но, прежде всего, несколько слов о названии библиотеки. Название seq2seq это аббревиатура sequence to sequence (из последовательности в последовательность). Оригинальная идея порождения последовательности была предложена для реализации системы перевода. Входная последовательность слов подавалась на вход рекурентной сети, называемой в этой системе кодировщик (encoder). Выходом это рекурентной сети считалось состояние вычисления ячейки на последнем символе последовательности. Это состояние подавалось как начальное состояние второй рекурентной сети — декодировщика (decoder), которая обучалась для порождения следующего слова. В качестве символов в обеих сетях использовались слова. Ошибки на декоровщике распространялись на кодировщик через передаваемое состояние. Сам вектор состояния в этой терминологии назывался вектором промежуточного представления (thought vector). Промежуточное предсталение использовалось в традиционных моделях перевода и, как правило, представляло собой граф представления структуры входного текста для перевода. Система перевода генерировала выходной текст на основе этой промежуточной структуры. Собственно, реализация seq2seq в Tensorflow относится к части decoder, не затрагивая кодировщика. Поэтому, правильно было бы назвать библиотеку 2seq, но сила традиции и инерция мышления здесь, очевидно, превозмогли здравый смысл. Два основных метатипа в библиотеке seq2seq это: Разработчики библиотеки выделили эти типы исходя из следующих соображений. Рассмотрим немного под другим углом процесс обучения и процесс порождения, который мы описывали выше. Для обучения необходимо:
После этого, можно начать считать ошибки, сравнивая результаты вычислений со следующими символами последовательности. Для порождения необходимо:
Как видно из описания, алгоритмы очень похожи. Поэтому, разработчики библиотеки решили инкапсулировать в классе Helper процедуру получения следующиего символа. Для обучения, это просто чтение следующего символа из последовательности, а для порождения — выделения символа с максимальным весом (конечно, для greedy search). Соответственно, в базовом классе Helper реализованы метод next_inputs для получения следующего символа из текущего и состояния, а также метод sample для получения индексов символов из уровня проекции. Для реализации обучения предоставляется класс TrainingHelper, а для реализации порождения методом жадного поиска — класс GreedyEmbeddingHelper. К сожалению, модель beam search не укладывается в эту систему типов, поэтому в библиотеке для этого реализован специальный класс BeamSearchDecoder. не использующий Helper. Класс Decoder предоставляет интерфейс для реализации декодировщика. Фактически, класс предоставляет два метода:
В библиотеке реализован класс BasicDecoder, который можно использовать как для обучения, так и для порождения с помощниками TrainingHelper и GreedyEmbeddingHelper. Этих трех классов обычно хватает для реализации моделей порождений на основе рекурентных сетей. Наконец, для организации прохода по входной или порождаемой последовательности используется функций dynamic_decode. Далее мы рассмотрим иллюстративный пример, в котором показаны методы построения порождающих моделей для различных типов библиотеки seq2seq. Иллюстративный пример Прежде всего, следует сказать, что все примеры реализованы в Python 2.7. Список дополнительных библиотек можно найти в файле requirements.txt. В качестве иллюстративного примера рассмотрим часть данных для конкурса Text Normalization Challenge — Russian Language, проводимого на Kaggle компанией Google в 2017 году. Цель этого конкурса состояла в преобразовании русского текста в форму, пригодную для зачитывания. Текст для конкурса был разбит на типизированные выражения. Данные для обучения были заданы в CSV файле следующего вида:
В примере выше интересно выражение типа DATE, в нем "1862 год" переводится в "тысяча восемьсот шестьдесят второй год". Для иллюстрации мы рассмотрим данные только типа DATE как пары вида (выражение до, выражение после). Начало файла данных:
Мы построим порождающую модель с помощью библиотеки 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. Формирование данных Рассмотрим код формирования данных для обучения и порождения.
Функция input_fn используется для создания коллекции данных, которую потом Estimator передает на обучение и порождение. Сначала задается тип данных. Это пара вида ((последовательность кодировщика, длина), (последовательность декодировщика, последовательность декодировщика с префиксом, длина)). В качестве префиксов используется строка "", каждая последовательность кодировщика заканчивается специальным словом "". Также, ввиду того, что последовательности (как входные, так и выходные) имеют неравную длину, используется символ заполнения (padding) со значением "". Код подготовки данных читает файл с данными, строку кодировщика разделяет на символы, а строку декодировщика на слова, используя для этого библиотеку nltk. Обработанная таким образом строка представляет собой пример данных для обучения. Сформированная коллекция разделяется на минипакеты, причем количество данных клонируется в соответствии с числом эпох обучения (каждая эпоха — один проход данных). Работа со словарями Словари хранятся в виде списка в файлах, одна строка для одного слова или символа. Для формирования словарей используется скрипт build_vocabs.py. Сформированные словари находятся в директории data как файлы вида vocab.*.txt. Код чтения словарей:
Здесь, наверное, интересна функция index_table_from_file, которая читает элементы словаря из файла, и ее параметр num_oov_buckets — число корзин для неизвестных слов (out of vocabulary). По умолчанию, это число равно единице, т.е. все слова, которых нет в словаре, имеют один и тот же индекс, равный размеру словаря + 1. У нас имеется три неизвестных слова: "", "" и "", для которых мы хотим иметь различные индексы. Поэтому, устанавливаем этот параметр в число три. К сожалению, приходится еще раз читать входной файл, чтобы получить количество слов в словаре в виде константы времени задания графа модели. Нам еще необходимо создать таблицу для реализации embedding — _source_embedding, а также перевода строк слов в строки идентификаторов:
Реализация кодировщика Для кодировщика мы будем использовать двунаправленную рекурентную сеть с несколькими уровнями. Использование уровней увеличивает глубину сети, что способствует лучшему обучению представлений, а использование двунаправленной сети повышает качество.
Для двунаправленной сети используем список ячеек GRU, который обертываем в MultiRNNCell, которая предоставляет интерфейс, совместимый с rnn.Cell. Потом задаем двунаправленную сеть, Наконец, мы объединяем выходы прямой и обратной сети, используя для этого конкатенацию двух последовательностей чисел, которую представляют собой выходы запуска ячейки на каждом элементе входной последовательности. Эта операция увеличивает размерность выходных данных, если исходный выход был длиной 128, то конкатенация двух таких выходов будет длиной 256. Чтобы не увеличивать размерность сети, мы пропускаем этот выход через обычный полносвязный слой, который на выходе также дает 128. На качество обучения эта операция не имеет влияния. Чуть сложнее операция объединения состояния. Т.к. сеть имеет несколько уровней, состояния, которые возвращает функция bidirectional_dynamic_rnn, представляют собой кортеж, составленный из состояний каждого уровня. Мы должны пройти по этому кортежу и объединить состояния соответствующих уровней, снизив тут же размерность этого объединения. Результат также должен быть кортежем, т.к. он передается декодировщика в качестве начального состояния. Здесь, конечно, возможны варианты. В данном случае декодировщик также использует сеть из того же количества уровней, что и кодировщик, поэтому кортеж должен быть той же размерности. Реализация декодировщика Декодировщик реализован как для обучения, так и для генерации. Сначала необходимо создать ячейку рекурентной сети кодировщика и добавить уровень проекции.
Обучение Для обучения используем TrainingHelper + BasicDecoder.
Порождение В данном примере используем жадный поиск для порождения.
В функцию инициализации класса GreedyEmbeddingHelper мы должны передать столбец начальных символов "", а также конечный символ "". При порождении этого символа цикл порождения будет остановлен. Также, интересно, что в функцию dynamic_decode передается максимальное число элементов порождаемой последовательности. Это необходимо для того, чтобы алгоритм порождения знал, когда ему остановиться. Либо, если он встретит символ конца, либо когда достигнуто максимальное число элементов порождаемой последовательности. Функция потерь и оптимизация Функция потерь это перекрестная энтропия, для реализации которой используется встроенная функция библиотеки seq2seq.
Чтобы правильно подсчитать энтропию, необходимо передать функции длины последовательностей, для их получения используется функция sequence_mask. Для оптимизации используем алгоритм Adam с обрезанием градиентов, где это необходимо.
Результаты обучения После шести тысяч шагов обучения алгоритм достигает приемлемого качества. Точность предсказания равна порядка 0.9 на тестовых данных. Это, конечно, все равно достаточно высокая погрешность, но здесь есть нюансы. Давай рассмотрим примеры, на которых алгоритм ошибается.
Здесь показаны результаты предсказаний в виде троек строк. Первая строка — это исходное выражение, вторая строка — размеченные данные, третья строка — порожденное выражение. Как видно, подавляющее число ошибок — это ошибки склонения. В обучающих данных присутствуют различные формы склонения для одних и тех же выражений. Какую форму выбрать конкретно, зависит от контекста (предыдущего слова), который в этой обучающей выборке не доступен. Отсюда и большое количество ошибок такого рода. Для конкурса с данными на английском языке результаты намного лучше, качество предсказания близко к абсолютному. Заключение В данной статья автор постарался донести читателю свой опыт изучения библиотеки seq2seq. Тема оказалась достаточно сложная для того, чтобы уложить ее целиком в одну статью в том формате, который предполагают публикации на Хабре. Насколько хорошо это получилось, судить читателю. Тематика построения глубоких сетей сама по себе достаточно сложная для изучения. Библиотека Tensorflow также достаточно сложна, документация крайне скудна, что еще более затрудняет изучение. По мнению автора, публикации по теме глубокого обучения грешат либо излишней конкретикой, либо излишней абстракцией. Изучающий вынужден продираться сквозь дебри различных частных проблем, связанных с использованием тех или иных элементов библиотеки. Например, как подавать данные на вход модели, как осуществляется padding для них, зачем необходим embedding и как его правильно делать? Основная масса статей не комментирует эти вещи, подразумевая, что читатель уже имеет о них представление. Эта статья представляет собой попытку использовать другой стиль — развернутый комментарий к исходному коду. Там, где автор счел необходимым, была описана теоретическая проблематика. Там, где по мнению автора, необходимо было пояснить частности, он попытался это сделать. Конечно, в процессе написания наверняка многое осталось неясным. Поэтому, автор открыт для общения и с удовольствием ответит на вопросы, которые, возможно, появятся у читателя в результате прочтения исходного кода. Источник: habr.com Комментарии: |
|