python

RNN: сможет ли нейронная сеть писать как Лев Толстой? (Спойлер: нет)

  • четверг, 23 ноября 2017 г. в 03:13:35
https://habrahabr.ru/post/342738/
  • Машинное обучение
  • Python
  • Data Mining


При изучении технологий Deep Learning я столкнулся с нехваткой относительно простых примеров, на которых можно относительно легко потренироваться и двигаться дальше.

В данном примере мы построим рекуррентную нейронную сеть, которая получив на вход текст романа Толстого «Анна Каренина», будет генерировать свой текст, чем-то напоминающий оригинал, предсказывая, какой должен быть следующий символ.

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

Введение


За основу этого мини-проекта были взяты статьи Andrej Karpathy (ссылки ниже по тексту) и учебные материалы udacity.
Самый простой путь повторить все описанное ниже:

  • установить у себя на ПК дистрибутив anaconda с версией Python 3.6
  • создать рабочий conda environment
  • установить в этот environment библиотеки tensorflow, numpy, jupyter
  • писать и исполнять код в Jupyter Notebook, что дает нам нужную интерактивность
  • скачать текст романа в txt формате

В случае инсталляции anaconda на Windows делаем следующее:

1. Создаем папку, в которой будем работать, копируем туда текст под именем «anna.txt»

2. Запускаем Anaconda Promt, переходим в созданную папку, создаем там нужный environment «tolstoy» с необходимыми библиотеками и активируем его:

(C:\anaconda3) C:\DL\rnn-tolstoy>conda create -n tolstoy
...
(C:\anaconda3) C:\DL\rnn-tolstoy>activate tolstoy
(tolstoy) C:\DL\rnn-tolstoy>conda install numpy tensorflow jupyter
...

3. Когда все библиотеки установятся, запускаем jupyter notebook, в котором будем работать:

(tolstoy) C:\DL\rnn-tolstoy>jupyter notebook

4. В браузере открывается меню notebook, там идем в «New» и выбираем Notebook -> Python 3, как показано на картинке:

image

После чего открывается уже сам notebook, где мы будем вбивать код и любоваться результатом его работы. Например, вбив код в ячейку «In», мы можем его выполнить нажатием Shift+Enter и сразу получить результат:

image

К этому моменту мы разобрались с базовыми вещами, теперь можно приступать к самой задаче.
Ниже приведена общая архитектура рекуррентной нейронной сети (RNN), предсказывающей следующий символ (взято отсюда):

image

На схеме видна ключевая особенность RNN — информация может обрабатываться циклично при движении от input к output, обеспечивая (в отличие от традиционных нейронных сетей) эффект памяти и позволяя обрабатывать связанные последовательности.

Инициализируем и готовим данные


Импортируем нужные библиотеки:

import time
from collections import namedtuple

import numpy as np
import tensorflow as tf

Загружаем текст романа, создаем словарь символов vocab, объекты dictionary для трансляции символ -> код, код -> символ и кодируем весь текст романа (массив encoded):

with open('anna.txt', 'r') as f:
    text=f.read()
vocab = sorted(set(text))
vocab_to_int = {c: i for i, c in enumerate(vocab)}
int_to_vocab = dict(enumerate(vocab))
encoded = np.array([vocab_to_int[c] for c in text], dtype=np.int32)

Проверяем начало, знаменитая фраза на месте, все в порядке:

text[:110]

Out: 'ЧАСТЬ ПЕРВАЯ\n\n\n\nI\n\nВсе счастливые семьи похожи друг на друга, каждая несчастливая семья несчастлива по-своему.'

Смотрим, как это выглядит в закодированном виде (именно в таком виде данные будут обрабатываться в сети):

encoded[:110]

Out:
array([ 99,  77,  93,  94, 102,   1,  91,  82,  92,  79,  77, 105,   0,
         0,   0,   0,  30,   0,   0,  79, 123, 111,   1, 123, 129, 106,
       123, 124, 117, 114, 108, 133, 111,   1, 123, 111, 118, 134, 114,
         1, 121, 120, 127, 120, 112, 114,   1, 110, 122, 125, 109,   1,
       119, 106,   1, 110, 122, 125, 109, 106,   7,   1, 116, 106, 112,
       110, 106, 137,   1, 119, 111, 123, 129, 106, 123, 124, 117, 114,
       108, 106, 137,   1, 123, 111, 118, 134, 137,   1, 119, 111, 123,
       129, 106, 123, 124, 117, 114, 108, 106,   1, 121, 120,   8, 123,
       108, 120, 111, 118, 125,   9])

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

len(vocab)

Out: 140

Символов в словаре многовато, но нужно учитывать, что заглавные и строчные буквы — это разные символы, а также помним про большое количество текста на французском, т.е. у нас по сути два алфавита.

Делим данные на пакеты


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

image

Создаем процедуру получения исходного пакета, который будет подаваться на вход нейронной сети (feature) и контрольного пакета, с которым будет сравниваться предсказание сети (target):

def get_batches(arr, n_seqs, n_steps):
    '''Создаем генератор, который возвращает пакеты размером
       n_seqs x n_steps из массива arr.
       
       Аргументы
       ---------
       arr: Массив, из которого получаем пакеты
       n_seqs: Batch size, количество последовательностей в пакете
       n_steps: Sequence length, сколько "шагов" делаем в пакете
    '''
    # Считаем количество символов на пакет и количество пакетов, которое можем сформировать
    characters_per_batch = n_seqs * n_steps
    n_batches = len(arr)//characters_per_batch
    
    # Сохраняем в массиве только символы, которые позволяют сформировать целое число пакетов
    arr = arr[:n_batches * characters_per_batch]
    
    # Делаем reshape 1D -> 2D, используя n_seqs как число строк, как на картинке
    arr = arr.reshape((n_seqs, -1))
    
    for n in range(0, arr.shape[1], n_steps):
        # пакет данных, который будет подаваться на вход сети
        x = arr[:, n:n+n_steps]
        # целевой пакет, с которым будем сравнивать предсказание, получаем сдвиганием "x" на один символ вперед
        y = np.zeros_like(x)
        y[:, :-1], y[:, -1] = x[:, 1:], x[:, 0]
        yield x, y

Функция работает как генератор, каждое обращение к которому позволяет получить следующую пару «x» и «y», например:

batches = get_batches(encoded, 10, 50)
x, y = next(batches)
print('x\n', x[:5, :5])
print('\ny\n', y[:5, :5])

x
 [[ 99  77  93  94 102]
 [  1 110 108 114 112]
 [ 79 120 124   1 120]
 [114 119   1 109 120]
 [106 108 111 110 117]]

y
 [[ 77  93  94 102   1]
 [110 108 114 112 111]
 [120 124   1 120 124]
 [119   1 109 120 108]
 [108 111 110 117 114]]

В выводе виден сдвиг пакета «y» по отношению к пакету «х».

Строим модель


Ниже приведена схема нашей RNN модели:



Основная магия обучения происходит в ячейке LSTM (Long Short Term Memory).
Вот здесь лежит замечательная статья, в которой простым и понятным английским языком описывается логика работы таких ячеек и нейронных сетей, основанных на LSTM.

При построении модели сначала определяем входящие параметры:

def build_inputs(batch_size, num_steps):
    ''' Определяем placeholder'ы для входных, целевых данных, а также вероятности drop out
    
        Аргументы
        ---------
        batch_size: Batch size, количество последовательностей в пакете
        num_steps: Sequence length, сколько "шагов" делаем в пакете
        
    '''
    # Объявляем placeholder'ы
    inputs = tf.placeholder(tf.int32, [batch_size, num_steps], name='inputs')
    targets = tf.placeholder(tf.int32, [batch_size, num_steps], name='targets')
    
    # Placeholder для вероятности drop out
    keep_prob = tf.placeholder(tf.float32, name='keep_prob')
    
    return inputs, targets, keep_prob

Надо напомнить, что данные в Tensorflow хранятся в тензорах.
"placeholder'ы" — вид тензоров, который определяет тип и формат данных (например размерность матрицы), а сами данные реально будут загружены в нужный момент в будущем.
Что касается drop out — это механизм противодействия эффекту «переобучения» нашей сети, когда в процессе мы случайным образом исключаем часть вершин нашего графа из расчетов:



Дальше мы строим структуру LTSM ячейки.

def build_lstm(lstm_size, num_layers, batch_size, keep_prob):
    ''' Строим LSTM ячейку.
    
        Аргументы
        ---------
        keep_prob: Скаляр (tf.placeholder) для dropout keep probability
        lstm_size: Размер скрытых слоев в LSTM ячейках
        num_layers: Количество LSTM слоев
        batch_size: Batch size

    '''
    ### Строим LSTM ячейку
    
    def build_cell(lstm_size, keep_prob):
        # Начинаем с базовой LSTM ячейки
        lstm = tf.contrib.rnn.BasicLSTMCell(lstm_size)
        
        # Добавляем dropout к ячейке
        drop = tf.contrib.rnn.DropoutWrapper(lstm, output_keep_prob=keep_prob)
        return drop
    
    
    # Стэкируем несколько LSTM слоев для придания глубины нашему deep learning
    cell = tf.contrib.rnn.MultiRNNCell([build_cell(lstm_size, keep_prob) for _ in range(num_layers)])
    # Инициализируем начальное состояние LTSM ячейки
    initial_state = cell.zero_state(batch_size, tf.float32)
    
    return cell, initial_state

Далее будем строить выходной слой. Определяемся с размерностью.
Если входные данные имели размерность M (batch size), N (sequence length) и проходили через скрытые слои размером L юнитов, то на выходе получаем 3D тензор размерностью MxNxL. Чтобы упростить задачу, сделаем reshape 3D -> 2D и приведем тензор к виду (M∗N)×L. Таким образом, у нас будет одна строка для каждой последовательности и каждого «шага» и значения каждой строки — выход из LSTM юнитов.
Данную матрицу перемножаем на матрицу весов выходного уровня и прибавляем смещение выходного уровня.

При этом инициализируем веса случайными величинами с усеченным нормальным распределением (в диапазоне 2 стандартных отклонений), а смещения (bias) инициализируем нулями, что является рекомендуемой практикой в нейронных сетях.

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

def build_output(lstm_output, in_size, out_size):
    ''' Строим softmax слой и возвращаем результат его работы.
    
        Аргументы
        ---------
        
        x: Входящий от LSTM тензор
        in_size: Размер входящего тензора, (кол-во LSTM юнитов скрытого слоя)
        out_size: Размер softmax слоя (объем словаря)
    
    '''

    # вытягиваем и решэйпим тензор, выполняя преобразование 3D -> 2D
    seq_output = tf.concat(lstm_output, axis=1)
    x = tf.reshape(seq_output, [-1, in_size])
    
    # Соединяем результат LTSM слоев с softmax слоем
    with tf.variable_scope('softmax'):
        softmax_w = tf.Variable(tf.truncated_normal((in_size, out_size), stddev=0.1))
        softmax_b = tf.Variable(tf.zeros(out_size))
    
    # Считаем logit-функцию
    logits = tf.matmul(x, softmax_w) + softmax_b
    # Используем функцию softmax для получения предсказания
    out = tf.nn.softmax(logits, name='predictions')
    
    return out, logits

Дальше мы определяем функцию потери (то есть измеряем, насколько мы ошиблись). Для этого вычисляем softmax cross entropy между значениями logit-функции и label (которые в свою очередь являются целевыми значениями, прошедшими через one-hot кодирование).
В deep learning часто используют one-hot кодирование для представления категорийных переменных в виде бинарных векторов, чтобы их было удобнее использовать в дальнейших вычислениях. Например, последовательность данных:

[red, yellow, green]
мы можем закодировать в integer (как мы сделали выше в переменной encoded) в:

[0, 1, 2]
а после one-hot кодирования это будет выглядеть так:

[[1, 0, 0],
[0, 1, 0],
[0, 0, 1]]

Функцию потери в deep learning считают по разному. Для задач классификации объектов, которые относятся к взаимоисключающим классам (в нашем случае следующий символ не может являться одновременно «а» и «б»), функцию потери считают через функцию softmax cross entropy with logits и возвращаем среднее значение этой функции всех элементов по всем измерениям тензора.

def build_loss(logits, targets, lstm_size, num_classes):
    ''' Считаем функцию потери на основании значений logit-функции и целевых значений.
    
        Аргументы
        ---------
        logits: значение logit-функции
        targets: целевые значения, с которыми сравниваем предсказания
        lstm_size: Количество юнитов в LSTM слое
        num_classes: Количество классов в целевых значениях (размер словаря)
        
    '''
    # Делаем one-hot кодирование целевых значений и решейпим по образу и подобию logits
    y_one_hot = tf.one_hot(targets, num_classes)
    y_reshaped = tf.reshape(y_one_hot, logits.get_shape())
    
    # Считаем значение функции потери softmax cross entropy loss и возвращаем среднее значение
    loss = tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=y_reshaped)
    loss = tf.reduce_mean(loss)
    return loss

Дальше строим оптимизатор, который основан на методе градиентного спуска. При этом мы защищаемся от двух проблем (подробнее — здесь):

  • «исчезновение» градиента (защита встроена в логику работы LSTM ячеек);
  • «взрыв» градиента (для этого мы здесь используем gradient clipping).

В качестве функции оптимизации используем Adam optimizer

def build_optimizer(loss, learning_rate, grad_clip):
    ''' Строим оптимизатор для обучения, используя обрезку градиента.
    
        Arguments:
        loss: значение функции потери
        learning_rate: параметр скорости обучения
    
    '''
    
    # Оптимизатор для обучения, обрезка градиента для контроля "взрывающихся" градиентов
    tvars = tf.trainable_variables()
    grads, _ = tf.clip_by_global_norm(tf.gradients(loss, tvars), grad_clip)
    train_op = tf.train.AdamOptimizer(learning_rate)
    optimizer = train_op.apply_gradients(zip(grads, tvars))
    
    return optimizer

Теперь собираем все детали пазла вместе и строим класс, описывающий нашу сеть. Ключевой оператор, формирующий RNN сеть — tf.nn.dynamic_rnn. Он возвращает вывод каждой LSTM ячейки на каждом шаге, для каждой последовательности, в каждом пакете (mini-batch). Кроме этого, он возвращает финальный статус LSTM ячеек, который мы сохраняем и передаем на вход в первую LSTM ячейку при загрузке следующего пакета данных. На вход tf.nn.dynamic_rnn мы подаем ячейку (cell), начальный статус, который мы получаем из build_lstm и входную последовательность данных.

class CharRNN:
    
    def __init__(self, num_classes, batch_size=64, num_steps=50, 
                       lstm_size=128, num_layers=2, learning_rate=0.001, 
                       grad_clip=5, sampling=False):
    
        # Мы будем использовать эту же сеть для сэмплирования (генерации текста),
        # при этом будем подавать по одному символу за один раз
        if sampling == True:
            batch_size, num_steps = 1, 1
        else:
            batch_size, num_steps = batch_size, num_steps

        tf.reset_default_graph()
        
        # Получаем input placeholder'ы
        self.inputs, self.targets, self.keep_prob = build_inputs(batch_size, num_steps)

        # Строим LSTM ячейку
        cell, self.initial_state = build_lstm(lstm_size, num_layers, batch_size, self.keep_prob)

        ### Прогоняем данные через RNN слои
        # Делаем one-hot кодирование входящих данных
        x_one_hot = tf.one_hot(self.inputs, num_classes)
        
        # Прогоняем данные через RNN и собираем результаты
        outputs, state = tf.nn.dynamic_rnn(cell, x_one_hot, initial_state=self.initial_state)
        self.final_state = state
        
        # Получаем предсказания (softmax) и результат logit-функции
        self.prediction, self.logits = build_output(outputs, lstm_size, num_classes)
        
        # Считаем потери и оптимизируем (с обрезкой градиента)
        self.loss = build_loss(self.logits, self.targets, lstm_size, num_classes)
        self.optimizer = build_optimizer(self.loss, learning_rate, grad_clip)

Подбираем гиперпараметры


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

batch_size = 100        # Размер пакета
num_steps = 100         # Шагов в пакете
lstm_size = 512         # Количество LSTM юнитов в скрытом слое
num_layers = 2          # Количество LSTM слоев
learning_rate = 0.001   # Скорость обучения
keep_prob = 0.5         # Dropout keep probability

Обучаем модель


Теперь приступаем к обучению нашей модели.
Запускаем входные и целевые данные в сеть, запускаем оптимизацию. Для каждого пакета (mini-batch) сохраняем окончательный LSTM статус, который отдаем на вход в сеть при следующем пакете, обеспечивая преемственность. Периодически (определяется переменной save_every_n) сохраняем состояние нашей модели (со всеми переменными, весами и т.д.) в checkpoint. Здесь есть еще один параметр — количество эпох (полных циклов обучения модели). Также необходимо напомнить, что вся работа с данными в Tensorflow ведется в рамках открытой сессии, что обычно начинается с кода with tf.Session() as sess:.

epochs = 20
# Сохраняться каждый N итераций
save_every_n = 200

model = CharRNN(len(vocab), batch_size=batch_size, num_steps=num_steps,
                lstm_size=lstm_size, num_layers=num_layers, 
                learning_rate=learning_rate)

saver = tf.train.Saver(max_to_keep=100)
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    
    # Можно раскомментировать строчку ниже и продолжить обучение с checkpoint'а
    #saver.restore(sess, 'checkpoints/______.ckpt')
    counter = 0
    for e in range(epochs):
        # Обучаем сеть
        new_state = sess.run(model.initial_state)
        loss = 0
        for x, y in get_batches(encoded, batch_size, num_steps):
            counter += 1
            start = time.time()
            feed = {model.inputs: x,
                    model.targets: y,
                    model.keep_prob: keep_prob,
                    model.initial_state: new_state}
            batch_loss, new_state, _ = sess.run([model.loss, 
                                                 model.final_state, 
                                                 model.optimizer], 
                                                 feed_dict=feed)
            
            end = time.time()
            print('Epoch: {}/{}... '.format(e+1, epochs),
                  'Training Step: {}... '.format(counter),
                  'Training loss: {:.4f}... '.format(batch_loss),
                  '{:.4f} sec/batch'.format((end-start)))
        
            if (counter % save_every_n == 0):
                saver.save(sess, "checkpoints/i{}_l{}.ckpt".format(counter, lstm_size))
    
    saver.save(sess, "checkpoints/i{}_l{}.ckpt".format(counter, lstm_size))

Дальше наблюдаем процесс обучения:

Epoch: 1/20...  Training Step: 1...  Training loss: 4.9402...  7.7964 sec/batch
Epoch: 1/20...  Training Step: 2...  Training loss: 4.8530...  7.1318 sec/batch
...
Epoch: 20/20...  Training Step: 3400...  Training loss: 1.4003...  6.6569 sec/batch

Мы видим постепенное уменьшение training loss.

На моем ПК этот процесс обучения занял порядка 6 часов. При наличии машины с хорошим GPU этот срок может быть уменьшен в разы.

Проверяем наши сохраненные чекпоинты:

tf.train.get_checkpoint_state('checkpoints')

model_checkpoint_path: "checkpoints\\i3400_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i200_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i400_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i600_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i800_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i1000_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i1200_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i1400_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i1600_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i1800_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i2000_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i2200_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i2400_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i2600_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i2800_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i3000_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i3200_l512.ckpt"
all_model_checkpoint_paths: "checkpoints\\i3400_l512.ckpt"

Генерируем текст


Теперь можем приступать к сэмплированию, то есть к генерации текста.
Идея в том, что подавая на вход в сеть один символ, мы получаем на выходе предсказанный символ, который мы добавим в сгенеренный текст и подадим его опять на вход в сеть на следующей итерации и т.д. Исключение — это текст для «разогрева» модели, подаваемый на вход в параметре prime.

Функцию pick_top_n используем для уменьшения «шума» предсказаний, оставляя для выбора только заданное количество (по умолчанию 5) вариантов символов, отбрасывая все остальные варианты.

def pick_top_n(preds, vocab_size, top_n=5):
    p = np.squeeze(preds)
    p[np.argsort(p)[:-top_n]] = 0
    p = p / np.sum(p)
    c = np.random.choice(vocab_size, 1, p=p)[0]
    return c

def sample(checkpoint, n_samples, lstm_size, vocab_size, prime="Гостиная Анны Павловны начала понемногу наполняться."):
    samples = [c for c in prime]
    model = CharRNN(len(vocab), lstm_size=lstm_size, sampling=True)
    saver = tf.train.Saver()
    with tf.Session() as sess:
        saver.restore(sess, checkpoint)
        new_state = sess.run(model.initial_state)
        for c in prime:
            x = np.zeros((1, 1))
            x[0,0] = vocab_to_int[c]
            feed = {model.inputs: x,
                    model.keep_prob: 1.,
                    model.initial_state: new_state}
            preds, new_state = sess.run([model.prediction, model.final_state], 
                                         feed_dict=feed)

        c = pick_top_n(preds, len(vocab))
        samples.append(int_to_vocab[c])

        for i in range(n_samples):
            x[0,0] = c
            feed = {model.inputs: x,
                    model.keep_prob: 1.,
                    model.initial_state: new_state}
            preds, new_state = sess.run([model.prediction, model.final_state], 
                                         feed_dict=feed)

            c = pick_top_n(preds, len(vocab))
            samples.append(int_to_vocab[c])
        
    return ''.join(samples)

Теперь генерируем текст и смотрим что получилось.

Для начала — раннее состояние модели (после 200 итераций).

checkpoint = 'checkpoints/i200_l512.ckpt'
samp = sample(checkpoint, 1000, lstm_size, len(vocab))
print(samp)

INFO:tensorflow:Restoring parameters from checkpoints/i200_l512.ckpt
Гостиная Анны Павловны начала понемногу наполняться. – о пота тот о олоната,, то ол водит то та о олетоло нел олелоноли но о нол тал ветила ноль о то пролина о то вота о сел вина сти ву о нонит но то виле, тон тон е не вели и не вени не нело нинене н енел вита, о пототота, снита, половето о сенуле не о сене та сола сито осново сно ва вено нато но сте ва те вонили, повонино се на оно тона оснанина вала, к тонала сеснить и тона олане села тета и се оло е со едо снаноти и ноно нетел о о то не вале не селе оли тол вовевити ста, порато то оло на оло то она онене си о о пела о не нолоно нета нол ти соно о тено оно веле, с родал ва и ни ситато нино, по поволена снела се пота сень, ото ноте, подоли ола о ни се нать на сна не во о пил вол и вели о ни нол но инене се о трилана но повень нот е ноти и неноть о о нена,..

–
Она порано вева ни педонь о о тама стоно, о падо то олельлат во но о о сроволо на о то сети тот ве тет врули, и та тро ву со о тон ето на нат е нала, нени о тени сте сото ти о стена вона ине оне се не ново но нил та, вете олито та 

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

Идем дальше (после 600-й итерации).

checkpoint = 'checkpoints/i600_l512.ckpt'
samp = sample(checkpoint, 1000, lstm_size, len(vocab))
print(samp)

INFO:tensorflow:Restoring parameters from checkpoints/i600_l512.ckpt
Гостиная Анны Павловны начала понемногу наполняться. Онна, которую проваделая, как оста не поремала ота подула свае полодом, что-то ос несто не собя стольчась ого дру про текать просвидный, как в тарек в деле вым не переднени в пресведанее и совсенали его, песем его на на не мужи от немуть встрота и тругу водотольное на не престила и стровить в сторой после веровать его, что о весто с немогразавая седа несто, на она сказал старит, содриваят серью.

– Я на селена пристали обень присито прететь не воден е ни мотра на то посере присела всего не него они стать о тром всеме на него оненно накодотьее обенние с всем, что всебо на только насковиль паредили, что томого потерала вы него не поленней. Но ну ни не подему, – сказал он.

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

Здесь мы видим и «слова» стали подлиннее, наметились какие-то зачатки диалогов. В какой-то момент сетка даже ругнулась матом :)

В общем положительная динамика налицо.

Ну и результат последней итерации.

checkpoint = tf.train.latest_checkpoint('checkpoints')
samp = sample(checkpoint, 2000, lstm_size, len(vocab))
print(samp)

INFO:tensorflow:Restoring parameters from checkpoints\i3400_l512.ckpt
Гостиная Анны Павловны начала понемногу наполняться. И, строго встал в последние волоса, петербургский дома, и он видел, что он не слышал. И только по послу и подошел к свядене. Все это было не все теперьшему в передом и потому, что неправда на то, что он был в половине именно переговорил с ним.

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

– Только то есть нужны во просту. Нет, я пойду, когда наделал, как она помочь мне направа.

– Ах, вы перемените вам нето, – сказал Степан Аркадьич.

– Да, та мы, ни ты давно, как во всего не могут быть имен положение. Он не заметил вас, что ты не понимает воспоминания о первой меленикий с мономы и сомневился в элом пель, но нет. Теперь он стал, – сказала она.

– Непромена в себе противотереение на другою.

– Да, я понимаю, – обратилась она к нему к Анне, – он не мог надельте и теперь в том, что осоне весело, – сказал Вронский.

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

«Нет, это неприятно, и одно возможно. Очинаясь, что я не могу, но очень рада, – сказал Степан Аркадьич, совсем приготовили ему с своих платье и всех дорогах с которая он с ней стало больши всего, или возможно, – поднялся от всех из-за движения которой надо сказать ему.

– Нет, вы не видала его. Я не могу высказать его. Я возмралась во взгляду на своего мнения, – сказал Левин, стала стараяться со себя советиеть и не может сказать его. Он все приезжал и поставил получившие про то, что все будущо

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

Заключение


Очевидно, что писать как Лев Толстой, наша сеть все-таки не научилась, но прогресс по мере обучения очевиден. При этом чтобы двигаться в сторону большей осмысленности, нужно использовать другие методы (например Embeddings), поскольку с помощью char-wise RNN можно относительно легко получить хорошую грамматику, но добиться смысла от текста видимо непросто.

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

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