Курсовая работа. Глубокое обучение
Скачать 4.97 Mb.
|
Двумерная свертка 2D-свертка — это просто расширение одномерного случая, так как соеди- нение входа и выхода через фильтры в каждой отдельной размерности будет таким, как в одномерном случае. Действия будут такими же: 1. На прямом проходе мы: x дополняем входные данные; x используем новый вход с отступами и параметры для вычисления результата. 178 Глава 5. Сверточная нейронная сеть 2. На обратном проходе для вычисления входного градиента мы: x соответствующим образом заполняем выходной градиент; x используем этот выходной градиент с дополнением, а также вход- ные данные и параметры, чтобы вычислить как входной градиент, так и градиент параметров. 3. На обратном проходе для вычисления градиента параметра мы: x правильно дополняем ввод; x проходим по элементам дополненного ввода и соответствующим образом увеличиваем градиент параметра. 2D-свертка: кодирование прямого прохода В одномерном случае код с учетом входных данных и параметров прямого прохода выглядел следующим образом: # input_pad: вход с добавленными отступами out = np.zeros_like(inp) for o in range(out.shape[0]): for p in range(param_len): out[o] += param[p] * input_pad[o+p] Для двумерных сверток получаем: # input_pad: вход с добавленными отступами out = np.zeros_like(inp) for o_w in range(img_size): # проход по высоте изображения for o_h in range(img_size): # проход по ширине изображения for p_w in range(param_size): # проход по высоте параметра for p_h in range(param_size): # проход по ширине параметра out[o_w][o_h] += param[p_w][p_h] * input_pad[o_ w+p_w][o_h+p_h] Каждый цикл разбивался на два вложенных цикла. Свертка: обратный проход 179 Расширение до двух измерений при наличии пакета изображений тоже похоже на одномерный случай: мы просто добавляем цикл for снаружи циклов, показанных здесь. 2D-свертка: написание кода для обратного прохода Конечно же, как и в прямом проходе, мы можем использовать для об- ратного прохода те же индексы, что и в одномерном случае. Напомню, что в одномерном случае код имел вид: input_grad = np.zeros_like(inp) for o in range(inp.shape[0]): for p in range(param_len): input_grad[o] += output_pad[o+param_len-p-1] * param[p] В 2D-случае код выглядит так: # output_pad: вывод с отступами input_grad = np.zeros_like(inp) for i_w in range(img_width): for i_h in range(img_height): for p_w in range(param_size): for p_h in range(param_size): input_grad[i_w][i_h] += output_pad[i_w+param_size-p_w-1][i_h+param_size-p_h-1] \ * param[p_w][p_h] Обратите внимание, что индексирование на выходе будет таким же, как в одномерном случае, однако происходит в двух измерениях; в одномер- ном случае было: output_pad[i+param_size-p-1] * param[p] в 2D-случае есть: output_pad[i_w+param_size-p_w-1][i_h+param_size-p_h-1] * param[p_w][p_h] Другие факты одномерного случая тоже остались актуальны: y для пакета входных изображений выполняется предыдущая операция для каждого наблюдения, а затем результаты суммируются; 180 Глава 5. Сверточная нейронная сеть y для градиента параметра производится проход через все изображения в пакете и добавляются компоненты от каждого к соответствующим местам в градиенте параметра 1 : # input_pad: входные данные с отступами param_grad = np.zeros_like(param) for i in range(batch_size): # equal to inp.shape[0] for o_w in range(img_size): for o_h in range(img_size): for p_w in range(param_size): for p_h in range(param_size): param_grad[p_w][p_h] += input_pad[i][o_w+p_w] [o_h+p_h] \ * output_grad[i][o_w][o_h] Код для полноценной многоканальной свертки почти готов. В данный момент код сворачивает фильтры по двумерному вводу и производит двумерный вывод. Как уже говорилось, на каждом сверточном слое есть не только нейроны, расположенные вдоль этих двух измерений, но и некоторое количество «каналов», равное количеству карт признаков, которые создает слой. Рассмотрим этот момент подробнее. Последний элемент: добавление «каналов» Как учесть случаи, когда и ввод, и вывод являются многоканальными? Как и в случае добавления пакетов, ответ прост: добавляются два внешних цикла for в уже знакомый код — один цикл для входных каналов и другой для выходных каналов. Зацикливая все комбинации входного и выходного каналов, мы делаем каждую карту выходных объектов комбинацией всех карт входных объектов. Чтобы это работало, следует всегда представлять изображения как трех- мерные ndarrays . Черно-белые изображения будут иметь один канал, а цветные изображения — три (красный, синий и зеленый). Затем, неза- висимо от количества каналов, работа продолжается, как описано ранее, с использованием ряда карт признаков, созданных из изображения, каж- 1 Полную реализацию см. на веб-сайте книги ( https://oreil.ly/2H99xkJ ). Свертка: обратный проход 181 дый из которых представляет собой комбинацию сверток, полученных от всех каналов в изображении (или из каналов на предыдущем слое, если речь идет о слоях в сети). Прямой проход А теперь напишем код для вычисления вывода сверточного слоя с учетом четырехмерных ndarrays на входе и параметров: def _compute_output_obs(obs: ndarray, param: ndarray) -> ndarray: ''' obs: [channels, img_width, img_height] param: [in_channels, out_channels, param_width, param_height] ''' assert_dim(obs, 3) assert_dim(param, 4) param_size = param.shape[2] param_mid = param_size // 2 obs_pad = _pad_2d_channel(obs, param_mid) in_channels = fil.shape[0] out_channels = fil.shape[1] img_size = obs.shape[1] out = np.zeros((out_channels,) + obs.shape[1:]) for c_in in range(in_channels): for c_out in range(out_channels): for o_w in range(img_size): for o_h in range(img_size): for p_w in range(param_size): for p_h in range(param_size): out[c_out][o_w][o_h] += \ param[c_in][c_out][p_w][p_h] * obs_pad[c_in][o_w+p_w][o_h+p_h] return out def _output(inp: ndarray, param: ndarray) -> ndarray: ''' 182 Глава 5. Сверточная нейронная сеть obs: [batch_size, channels, img_width, img_height] param: [in_channels, out_channels, param_width, param_height] ''' outs = [_compute_output_obs(obs, param) for obs in inp] return np.stack(outs) Обратите внимание, что функция _pad_2d_channel добавляет отступы. Код вычислений аналогичен коду в более простом 2D-случае (без ка- налов), показанном ранее, за исключением того, что теперь имеется fil[c_out][c_in][p_w][p_h] вместо обычного fil[p_w][p_h] , поскольку в массиве фильтров есть еще два измерения и лишние c_out × c_in элементов. Обратный проход Обратный проход аналогичен и выполняется так же, как и в простом 2D-случае: 1) для входных градиентов вычисляются градиенты каждого наблю- дения по отдельности (для этого добавляем выходной градиент), а затем градиенты складываются; 2) используется выходной градиент с отступами для градиента параме- тра, выполняется проход в цикле по наблюдениям и применяются соответствующие значения для каждого из них, чтобы обновить градиент параметра. Ниже приведен код вычисления выходного градиента: def _compute_grads_obs(input_obs: ndarray, output_grad_obs: ndarray, param: ndarray) -> ndarray: ''' input_obs: [in_channels, img_width, img_height] output_grad_obs: [out_channels, img_width, img_height] param: [in_channels, out_channels, img_width, img_height] ''' input_grad = np.zeros_like(input_obs) param_size = param.shape[2] param_mid = param_size // 2 img_size = input_obs.shape[1] Свертка: обратный проход 183 in_channels = input_obs.shape[0] out_channels = param.shape[1] output_obs_pad = _pad_2d_channel(output_grad_obs, param_mid) for c_in in range(in_channels): for c_out in range(out_channels): for i_w in range(input_obs.shape[1]): for i_h in range(input_obs.shape[2]): for p_w in range(param_size): for p_h in range(param_size): input_grad[c_in][i_w][i_h] += \ output_obs_pad[c_out][i_w+param_size-p_w-1] [i_h+param_size-p_h-1] \ * param[c_in][c_out][p_w][p_h] return input_grad def _input_grad(inp: ndarray, output_grad: ndarray, param: ndarray) -> ndarray: grads = [_compute_grads_obs(inp[i], output_grad[i], param) for i in range( output_grad.shape[0])] return np.stack(grads) А вот и градиент параметра: def _param_grad(inp: ndarray, output_grad: ndarray, param: ndarray) -> ndarray: ''' inp: [in_channels, img_width, img_height] output_grad_obs: [out_channels, img_width, img_height] param: [in_channels, out_channels, img_width, img_height] ''' param_grad = np.zeros_like(param) param_size = param.shape[2] param_mid = param_size // 2 img_size = inp.shape[2] in_channels = inp.shape[1] out_channels = output_grad.shape[1] 184 Глава 5. Сверточная нейронная сеть inp_pad = _pad_conv_input(inp, param_mid) img_shape = output_grad.shape[2:] for i in range(inp.shape[0]): for c_in in range(in_channels): for c_out in range(out_channels): for o_w in range(img_shape[0]): for o_h in range(img_shape[1]): for p_w in range(param_size): for p_h in range(param_size): param_grad[c_in][c_out][p_w][p_h] += \ inp_pad[i][c_in][o_w+p_w][o_h+p_h] \ * output_grad[i][c_out][o_w][o_h] return param_grad Эти три функции — _output , _input_grad и _param_grad — именно то, что нужно для создания класса Conv2DOperation , формирующего ядро Conv2DLayers , которое мы будем использовать в наших CNN! Осталось проработать всего пару деталей, а потом можно применять эту операцию в сверточной сети. Использование операции для обучения CNN Чтобы получить работающую модель CNN, нужно реализовать еще не- много: 1) реализовать операцию flatten, рассмотренную ранее в этой главе, чтобы модель могла делать прогнозы; 2) включить этот класс Operation , а также Conv2DOpOperation в слой Conv2D ; 3) написать более быструю версию Conv2DOperation . Напишем ее здесь, а подробности рассмотрим в разделе «Цепное правило» приложения А. Операция flatten Для завершения сверточного слоя понадобится еще одна операция: опера- ция flatten. Результатом операции свертки будет трехмерный массив для каждого наблюдения измерения (число каналов, img_height , img_width ). Но если мы не передадим эти данные в другой сверточный слой, сначала Использование операции для обучения CNN 185 нужно будет преобразовать их в вектор для каждого наблюдения. По- скольку каждый нейрон кодирует присутствие шаблона в данном месте на изображении, мы легко можем «сплющить» этот трехмерный ndarray в одномерный вектор и передать его вперед. Операция flatten, показанная здесь, делает именно это, учитывая тот факт, что в сверточных слоях, как и в любом другом слое, первым измерением нашего ndarray всегда является размер пакета: class Flatten(Operation): def __init__(self): super().__init__() def _output(self) -> ndarray: return self.input.reshape(self.input.shape[0], -1) def _input_grad(self, output_grad: ndarray) -> ndarray: return output_grad.reshape(self.input.shape) Это последняя требуемая операция. Теперь обернем наши Operation в Layer Готовый слой Conv2D Таким образом, весь сверточный слой будет выглядеть примерно так: class Conv2D(Layer): def __init__(self, out_channels: int, param_size: int, activation: Operation = Sigmoid(), flatten: bool = False) -> None: super().__init__() self.out_channels = out_channels self.param_size = param_size self.activation = activation self.flatten = flatten def _setup_layer(self, input_: ndarray) -> ndarray: self.params = [] 186 Глава 5. Сверточная нейронная сеть conv_param = np.random.randn(self.out_channels, input_.shape[1], # входные каналы self.param_size, self.param_size) self.params.append(conv_param) self.operations = [] self.operations.append(Conv2D(conv_param)) self.operations.append(self.activation) if self.flatten: self.operations.append(Flatten()) return None В зависимости от того, хотим ли мы передавать выходные данные этого слоя в другой сверточный слой или в полносвязный связанный слой для предсказаний, применятся (или нет) операция flatten. Пара слов о скорости и альтернативной реализации Читатели, знакомые с понятием вычислительной сложности, могут сказать, что такой код катастрофически медленный: для вычисления градиента параметра пришлось написать семь вложенных циклов! В этом нет ничего плохого, поскольку нам нужно было прочувствовать и понять принцип работы CNN, написав все с нуля. Но можно написать по-другому: 1) из входных данных извлекаются участки image_height × image_ width × num_channels размера filter_height × filter_width из набора тестов; 2) для каждого участка выполняется скалярное произведение на соот- ветствующий фильтр, соединяющий входные каналы с выходными каналами; 3) складываем результаты скалярных произведений, чтобы сформи- ровать результат. Проявив смекалку, можно выразить почти все описанные ранее операции через пакетное умножение матриц, реализованное с помощью функции NumPy mul . В приложении A и на веб-сайте книги показано, как это сде- лать, а пока достаточно сказать, что такая реализация позволяет писать Использование операции для обучения CNN 187 относительно небольшие сверточные нейронные сети, которые будут об- учаться за разумное количество времени. А это, в свою очередь, открывает простор для экпериментов! Эксперименты Даже если мы используем функцию matmul и изменение формы, обуче- ние модели на одну эпоху с одним слоем свертки все равно займет около 10 минут, поэтому ограничимся демонстрацией модели только с одним сверточным слоем с 32 каналами (количество выбрано условно): model = NeuralNetwork( layers=[Conv2D(out_channels=32, param_size=5, dropout=0.8, weight_init="glorot", flatten=True, activation=Tanh()), Dense(neurons=10, activation=Linear())], loss = SoftmaxCrossEntropy(), seed=20190402) Обратите внимание, что у этой модели 32 × 5 × 5 = 800 параметров в пер- вом слое, но эти параметры используются для создания 32 × 28 × 28 = = 25 088 нейронов, или «изученных признаков». В полносвязном слое со скрытым размером 32 получится 784 × 32 = 25 088 параметров и всего 32 нейрона. Несколько простых проб и ошибок — обучение этой модели на нескольких сотнях пакетов с разными скоростями обучения и наблюдение за полу- ченными в результате потерями — показывают, что скорость обучения 0.01 работает лучше, чем скорость обучения 0.1, когда первый слой сверточ- ный, а не полносвязный. Обучение сети за одну эпоху с оптимизатором SGDMomentum (lr = 0.01, momentum = 0.9) дает: Validation accuracy after 100 batches is 79.65% Validation accuracy after 200 batches is 86.25% Validation accuracy after 300 batches is 85.47% Validation accuracy after 400 batches is 87.27% Validation accuracy after 500 batches is 88.93% 188 Глава 5. Сверточная нейронная сеть Validation accuracy after 600 batches is 88.25% Validation accuracy after 700 batches is 89.91% Validation accuracy after 800 batches is 89.59% Validation accuracy after 900 batches is 89.96% Validation loss after 1 epochs is 3.453 Model validation accuracy after 1 epoch is 90.50% Из результата видно, что мы можем обучить сверточную нейронную сеть с нуля, что в итоге дает MNIST более 90% точности всего за один проход по обучающему набору 1 ! Заключение В этой главе мы поговорили о сверточных нейронных сетях. Мы начали с общих понятий о сверточных сетях и о том, в чем они схожи и чем от- личаются от полносвязных нейронных сетей, а затем описали их работу на низком уровне, реализовав базовую операцию многоканальной свертки с нуля в Python. Начиная с высокого уровня сверточные слои создают примерно на по- рядок больше нейронов, чем полносвязные слои, которые мы видели до этого, причем каждый нейрон представляет собой комбинацию всего лишь нескольких признаков из предыдущего слоя, а не всех элементов предыдущего уровня, как в полносвязных слоях. На уровне ниже мы увидели, что нейроны фактически сгруппированы в «карты признаков», каждая из которых показывает, присутствует ли определенный шаблон или их комбинация в данном месте на изображении. Такие карты при- знаков называются «каналами» сверточного слоя. Несмотря на все отличия от классов Operation , которые мы использовали в плотных слоях, операция свертки вписывается в тот же шаблон, что и другие операции ParamOperation , которые мы видели: y у него есть метод _output , который вычисляет вывод с учетом его входных данных и параметров; y у него есть методы _input_grad и _param_grad , которые при заданном output_grad той же формы, что и выходные данные операции, вычис- 1 Полный код можно найти в разделе главы в репозитории книги на GitHub. Заключение 189 ляют градиенты той же формы, что и входные данные и параметры соответственно. Разница тут лишь в том, что _input , output и params теперь являются четы- рехмерными объектами ndarray , тогда как в случае полностью связанных слоев они были двумерными. Полученные знания сформируют прочный фундамент для изучения или применения сверточных нейронных сетей в будущем. Далее рассмотрим еще один распространенный вид архитектуры нейронных сетей: рекур- рентные нейронные сети, предназначенные для работы с данными, появ- ляющимися в последовательностях, а не просто с несвязанными пакетами, с которыми мы имели дело в случаях с домами и изображениями. Вперед! ГЛАВА 6 Рекуррентные нейронные сети В этой главе рассмотрим рекуррентные нейронные сети (recurrent neural networks, RNN), класс нейросетей, предназначенных для обработки по- следовательных данных. В нейронных сетях, с которыми мы работали до этого, каждая полученная партия данных рассматривалась как набор независимых наблюдений. Сеть не знала ничего о предыдущих или будущих данных цифр из MNIST, будь то полносвязная нейронная сеть из главы 4 или сверточная нейронная сеть из главы 5. Однако многие виды данных являются по своей природе упорядоченными. Это могут быть зависящие от времени последовательности финансовых данных или языковые данные, в которых символы, слова и предложения упорядоче- ны, и т. д. Цель рекуррентных нейронных сетей — научиться принимать последовательности таких данных и возвращать правильный прогноз, например цену товара на следующий день или следующее слово в пред- ложении. Для работы с упорядоченными данными с помощью полносвязных ней- ронных сетей, которые мы рассматривали в первых нескольких главах, потребуются три модификации. Во-первых, потребуется добавить «новое измерение» в данные, которые мы подаем на вход нейронной сети. Ра- нее данные, которые мы подавали нейронным сетям, были, по существу, двумерными — у каждого ndarray было одно измерение, отвечающее за число наблюдений, и еще одно, обозначающее число признаков 1 . Можно представить это иначе — каждое наблюдение представляет собой одно- мерный вектор. В рекуррентных нейронных сетях у данных тоже будет измерение, представляющее количество наблюдений, но каждое наблю- дение будет представлено в виде двумерного массива: одно измерение обозначает длину последовательности данных, а второе — сами признаки 1 Мы обнаружили, что наблюдения удобно располагать по строкам, а элементы — по столбцам, но это не обязательно. В любом случае данные должны быть двумерными. |