Использование сверточной нейронной сети для игры в «Жизнь» (на Keras)

МЕНЮ


Искусственный интеллект
Поиск
Регистрация на сайте
Помощь проекту

ТЕМЫ


Новости ИИРазработка ИИВнедрение ИИРабота разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика

Авторизация



RSS


RSS новости


Цель этой статьи — научить нейронную сеть играть в игру "Жизнь", не обучая ее правилам игры.

Привет, Хабр! Представляю вашему вниманию перевод статьи "Using a Convolutional Neural Network to Play Conway's Game of Life with Keras" автора kylewbanks.

Если вы не знакомы с игрой под названием Жизнь (это клеточный автомат, придуманный английским математиком Джоном Конвеем в 1970 году), правила таковы.

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

  • Любая живая клетка с менее чем двумя живыми соседями умирает.
  • Любая живая клетка с двумя или тремя живыми соседями доживает до следующего поколения.
  • Любая живая клетка с более чем тремя живыми соседями умирает.
  • Любая мертвая клетка с ровно тремя живыми соседями становится живой клеткой.

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

Подробнее см. Википедию.

Зачем это делать? Главным образом для развлечения, и чтобы немного узнать о сверточных нейронных сетях.

Итак...

Игровая логика

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

К счастью, в Интернете доступно множество реализаций, таких как: https://jakevdp.github.io/blog/2013/08/07/conways-game-of-life/.

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

import numpy as np  def life_step(X):     live_neighbors = sum(np.roll(np.roll(X, i, 0), j, 1)                      for i in (-1, 0, 1) for j in (-1, 0, 1)                      if (i != 0 or j != 0))     return (live_neighbors == 3) | (X & (live_neighbors == 2)).astype(int)

Генерация игрового поля

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

Функция generate_frames создает num_frames случайных игровых полей с определенной формой и предопределенной вероятностью того, что каждая ячейка будет "живой", а render_frames рисует представления изображений двух игровых полей рядом для сравнения (живые ячейки белые, а мертвые ячейки черные):

import matplotlib.pyplot as plt  def generate_frames(num_frames, board_shape=(100,100), prob_alive=0.15):     return np.array([         np.random.choice([False, True], size=board_shape, p=[1-prob_alive, prob_alive])         for _ in range(num_frames)     ]).astype(int)  def render_frames(frame1, frame2):     plt.subplot(1, 2, 1)     plt.imshow(frame1.flatten().reshape(board_shape), cmap='gray')      plt.subplot(1, 2, 2)     plt.imshow(frame2.flatten().reshape(board_shape), cmap='gray')

Давайте посмотрим, как выглядят эти поля:

board_shape = (20, 20) board_size = board_shape[0] * board_shape[1] probability_alive = 0.15  frames = generate_frames(10, board_shape=board_shape, prob_alive=probability_alive) print(frames.shape) # (num_frames, board_w, board_h)

(10, 20, 20)

print(frames[0])

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

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

ender_frames(frames[1], life_step(frames[1]))

Построение обущающего и тестового наборов

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

Каждый элемент в массивах y_train/y_val/y_test будет представлять следующее поле игры для каждого кадра поля в X_train/X_val/X_test.

def reshape_input(X):     return X.reshape(X.shape[0], X.shape[1], X.shape[2], 1)  def generate_dataset(num_frames, board_shape, prob_alive):     X = generate_frames(num_frames, board_shape=board_shape, prob_alive=prob_alive)     X = reshape_input(X)     y = np.array([         life_step(frame)          for frame in X     ])     return X, y  train_size = 70000 val_size   = 10000 test_size  = 20000

print("Training Set:") X_train, y_train = generate_dataset(train_size, board_shape, probability_alive) print(X_train.shape) print(y_train.shape)

Training Set: (70000, 20, 20, 1) (70000, 20, 20, 1)

print("Validation Set:") X_val, y_val = generate_dataset(val_size, board_shape, probability_alive) print(X_val.shape) print(y_val.shape)

Validation Set: (10000, 20, 20, 1) (10000, 20, 20, 1)

print("Test Set:") X_test, y_test = generate_dataset(test_size, board_shape, probability_alive) print(X_test.shape) print(y_test.shape)

Test Set: (20000, 20, 20, 1) (20000, 20, 20, 1)

Построение сверточной нейронной сети

Теперь мы можем сделать первый шаг к построению сверточной нейронной сети с использованием Keras. Ключевым моментом здесь являются размер ядра (3, 3) и шаг 1. Они указывают CNN использовать матрицу 3x3 окружающих ячеек для каждой ячейки поля, на которую она смотрит, включая текущую ячейку.

Например, если бы нижеследующее было игровым полем, а мы были в средней ячейке x, она бы посмотрела на все ячейки, отмеченные восклицательным знаком ! и ячейку х. Затем сеть двигается вдоль ячейки вправо и делает то же самое, повторяя снова и снова, пока не обработает каждую ячейку и ее соседей по всему полю.

0 0 0 0 0 0! ! ! 0 0! x ! 0 0! ! ! 0 0 0 0 0 0

Остальная сеть довольно проста, поэтому я не буду вдаваться в подробности. Если вам что-нибудь интересно, я рекомендую почитать документацию.

from keras.models import Sequential from keras.layers import Dense, Dropout, Activation, Conv2D, MaxPool2D  # CNN Properties filters = 50 kernel_size = (3, 3) # look at all 8 neighboring cells, plus itself strides = 1 hidden_dims = 100  model = Sequential() model.add(Conv2D(     filters,      kernel_size,     padding='same',     activation='relu',     strides=strides,     input_shape=(board_shape[0], board_shape[1], 1) )) model.add(Dense(hidden_dims)) model.add(Dense(1)) model.add(Activation('sigmoid'))  model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])

Взглянем на вывод функции summary:

model.summary()

_________________________________________________________________ Layer (type)                 Output Shape              Param #    ================================================================= conv2d_9 (Conv2D)            (None, 20, 20, 50)        500        _________________________________________________________________ dense_17 (Dense)             (None, 20, 20, 100)       5100       _________________________________________________________________ dense_18 (Dense)             (None, 20, 20, 1)         101        _________________________________________________________________ activation_9 (Activation)    (None, 20, 20, 1)         0          ================================================================= Total params: 5,701 Trainable params: 5,701 Non-trainable params: 0 _________________________________________________________________

Обучение и сохранение модели

Построив CNN, давайте обучим модель и сохраним ее на диск:

def train(model, X_train, y_train, X_val, y_val, batch_size=50, epochs=2, filename_suffix=''):     model.fit(         X_train, y_train,          batch_size=batch_size,          epochs=epochs,         validation_data=(X_val, y_val)     )      with open('cgol_cnn{}.json'.format(filename_suffix), 'w') as file:         file.write(model.to_json())     model.save_weights('cgol_cnn{}.h5'.format(filename_suffix))  train(model, X_train, y_train, X_val, y_val, filename_suffix='_basic')

Train on 70000 samples, validate on 10000 samples Epoch 1/2 70000/70000 [==============================] - 27s 388us/step      - loss: 0.1324 - acc: 0.9651 - val_loss: 0.0833 - val_acc: 0.9815 Epoch 2/2 70000/70000 [==============================] - 27s 383us/step      - loss: 0.0819 - acc: 0.9817 - val_loss: 0.0823 - val_acc: 0.9816

Эта модель обеспечивает точность чуть более 98% как для тренировочных, так и для проверочных наборов, что очень хорошо для первого прохода. Давайте попробуем выяснить, где мы делаем ошибки.

Пробуем

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

X, y = generate_dataset(1, board_shape=board_shape, prob_alive=probability_alive)  render_frames(X[0].flatten().reshape(board_shape), y)

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

pred = model.predict_classes(X) print(np.count_nonzero(pred.flatten() - y.flatten()), "incorrect cells.")

4 incorrect cells.

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

render_frames(y, pred.flatten().reshape(board_shape))

Это не страшно, но вы видите, где предсказание не удалось? Кажется, что сеть не может предсказать клетки по краям игрового поля. Посмотрим туда, где ненулевые значения указывают на неправильные предсказания:

print(pred.flatten().reshape(board_shape) - y.flatten().reshape(board_shape))

[[ 0  0  0  0  0  0  0 -1  0  0  0  0  0  0  0  0  0 -1 -1  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0]  [ 0  0  0  0  0  0 -1  0  0  0  0  0  0  0  0  0  0  0  0  0]]

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

Просмотр ошибок, используя тестовый набор

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

def view_prediction_errors(model, X, y):     y_pred = model.predict_classes(X)     sum_y_pred = np.sum(y_pred, axis=0).flatten().reshape(board_shape)     sum_y = np.sum(y, axis=0).flatten().reshape(board_shape)      plt.imshow(sum_y_pred - sum_y, cmap='hot', interpolation='nearest')     plt.show()  view_prediction_errors(model, X_test, y_test)

Все ошибки на краях и в углах. Что логично, так как CNN не может смотреть по сторонам, но логика игры в life_step это делает. Например, рассмотрим следующее. Глядя на краевую ячейку x ниже, CNN видит только x и ! клетки:

0 0 0 0 0 ! ! 0 0 0  x ! 0 0 0 ! ! 0 0 0  0 0 0 0 0

Но что мы действительно хотим, и что делает life_step, так это посмотреть на ячейки с противоположной стороны:

0 0 0 0 0 ! ! 0 0 !  x ! 0 0 ! ! ! 0 0 !  0 0 0 0 0

Похожая ситуация в углах:

x ! 0 0 ! ! ! 0 0 !  0 0 0 0 0 0 0 0 0 0 ! 0 0 0 !

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

Исправление краевых дефектов с помощью заполнения

Нам нужно дополнить каждую игровое поле противоположным значением, чтобы имитировать то, как life_step работает для краевых значений. Мы можем использовать np.pad с mode = ’wrap’ для этого. Например, рассмотрим следующий массив и дополненный вывод ниже:

x = np.array([     [1, 2, 3],     [4, 5, 6],     [7, 8, 9] ])  print(np.pad(x, (1, 1), mode='wrap'))

[[9, 7, 8, 9, 7],  [3, 1, 2, 3, 1],  [6, 4, 5, 6, 4],  [9, 7, 8, 9, 7],  [3, 1, 2, 3, 1]]

Обратите внимание, что первый столбец/строка и последний столбец/строка отзеркаливают противоположную сторону исходной матрицы, а средняя матрица 3x3 является исходным значением x. Например, ячейка [1] [1] была скопирована на противоположной стороне в ячейке [4] [1], и аналогично [0] [1] содержит [3] [1]. Во всех направлениях и даже в углах массив был исправлен так, чтобы он содержал противоположную сторону. Это позволит CNN рассмотреть все игровое поле и правильно обработать крайние случаи.

Теперь мы можем написать функцию для заполнения всех наших входных матриц:

def pad_input(X):     return reshape_input(np.array([         np.pad(x.reshape(board_shape), (1,1), mode='wrap')         for x in X     ]))  X_train_padded = pad_input(X_train) X_val_padded = pad_input(X_val) X_test_padded = pad_input(X_test)  print(X_train_padded.shape) print(X_val_padded.shape) print(X_test_padded.shape)

(70000, 22, 22, 1) (10000, 22, 22, 1) (20000, 22, 22, 1)

Все наборы данных теперь дополнены обернутыми столбцами/строками, что позволяет CNN видеть противоположную сторону игрового поля, как это делает life_step. Из-за этого каждое игровое поле теперь имеет размер 22x22 вместо оригинальных 20x20.

Затем, CNN должен быть перестроен так, чтобы отбрасывать заполнение, используя padding = 'valid' (что говорит Conv2D отбрасывать края, хотя это не сразу очевидно), и обработки нового input_shape. Таким образом, когда мы пропускаем игровые поля с размером 22x22, мы по-прежнему получаем размер 20x20 в качестве выходного, поскольку отбрасываем первый и последний столбец/строку. Остальное остается идентичным:

model_padded = Sequential() model_padded.add(Conv2D(     filters,      kernel_size,     padding='valid',     activation='relu',     strides=strides,     input_shape=(board_shape[0] + 2, board_shape[1] + 2, 1) )) model_padded.add(Dense(hidden_dims)) model_padded.add(Dense(1)) model_padded.add(Activation('sigmoid'))  model_padded.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy']) model_padded.summary()

_________________________________________________________________ Layer (type)                 Output Shape              Param #    ================================================================= conv2d_10 (Conv2D)           (None, 20, 20, 50)        500        _________________________________________________________________ dense_19 (Dense)             (None, 20, 20, 100)       5100       _________________________________________________________________ dense_20 (Dense)             (None, 20, 20, 1)         101        _________________________________________________________________ activation_10 (Activation)   (None, 20, 20, 1)         0          ================================================================= Total params: 5,701 Trainable params: 5,701 Non-trainable params: 0 _________________________________________________________________

Теперь мы можем обучиться, используя выровненное поле:

train(     model_padded,      X_train_padded, y_train, X_val_padded, y_val,      filename_suffix='_padded' )

Train on 70000 samples, validate on 10000 samples Epoch 1/2 70000/70000 [==============================] - 27s 389us/step - loss: 0.0604 - acc: 0.9807 - val_loss: 4.5475e-04 - val_acc: 1.0000 Epoch 2/2 70000/70000 [==============================] - 27s 382us/step - loss: 1.7058e-04 - acc: 1.0000 - val_loss: 5.9932e-05 - val_acc: 1.0000

Точность предсказания составляет от 98% до 100%, которые мы получили до добавления отступов. Давайте посмотрим на ошибку на тестовом наборе:

view_prediction_errors(model_padded, X_test_padded, y_test)

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

Это было забавное маленькое упражнение, чтобы поиграть с сверточными нейронными сетями, не используя большого набора данных. Не стесняйтесь заглянуть на GitHub.


Источник: habr.com

Комментарии: