Цель этой статьи — научить нейронную сеть играть в игру "Жизнь", не обучая ее правилам игры.
Привет, Хабр! Представляю вашему вниманию перевод статьи "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.