Завершающие темы курса. Приложения

Пакетная нормализация. Обзор

Здравствуйте и вновь добро пожаловать на занятия по теме «Глубокое обучение без учителя, часть 2».

В этой завершающей лекции данного курса, мы сделаем обзор по пакетной нормализации, свёртке с дробным шагом и обсудим ряд деталей реализации в Tensorflow.

В конце статьи будут ссылки на приложения и дополнительные материалы по обучению, пройденные ранее!

Причина, по которой я его делаю, – это вероятность того, что вы с ней никогда ранее не встречались. Первоначально, когда я создавал курс под названием «Глубокое обучение, часть 2», в котором даётся представление о библиотеках Theano и Tensorflow и ряд способов улучшения простейшего метода обратного распространения ошибки, я не включал пакетную нормализацию. Впрочем, она оказалась полезной и сама по себе, так что если она ещё и не включена в «Глубокое обучение, часть 2», то это будет сделано очень скоро. Она определённо вписывается в тему указанного курса, так что если вы изучали его давно, то стоит его проверить. Но поскольку изначально пакетная нормализация в него не включалась, мы поговорим о ней здесь.

Итак, как же работает пакетная нормализация? Напомним, что прежде чем подставлять данные в большинство алгоритмов машинного обучения, мы их сначала нормализуем, и что нормализация – это вычитание среднего значения, а затем деление на стандартное отклонение:

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

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

Важно учитывать, когда происходит пакетная нормализация. Происходит же она прямо перед передачей данных в функцию активации. Сравнивая это с тем, что мы делали прежде, отметим, что ранее у нас было два этапа: выполнение линейного преобразования и затем подстановка в функцию активации. Однако в случае пакетной нормализации у нас есть три этапа: вначале мы выполняем линейное преобразование, затем пакетную нормализацию, а затем уже подставляем в функцию активации. Это можно изобразить с помощью блок-схемы (см. слайд).

Всё это выглядит довольно просто: у нас есть пакеты для активации, мы вычисляем их средние значения и стандартное отклонение, нормализуем и затем передаём функции активации, всё как обычно. Обратите внимание, что обычно мы добавляем в знаменатель некоторое малое число ε, чтобы не прийти в итоге к делению на нуль, если дисперсия пакета окажется равной нулю. И хотя всё тут кажется довольно тривиальными вычислениями, это ещё не всё – мы пропустили ещё один этап.

Следующий этап может показаться несколько сложным. Он идёт прямо перед подстановкой в функцию активации, так что показанное ранее и было на самом деле пропущенным этапом. Сразу после того, как мы нормализовали данные путём вычитания среднего значения и деления на стандартное отклонение, мы перемасштабируем их обратно! Да-да, сразу после стандартизации данных мы перемасштабируем их в нечто ещё, давая другое среднее значение и стандартное отклонение! Это называется вторым масштабным параметром γ и вторым параметром сдвига β:

Это может показаться очень непонятным, ведь мы мотивировали данное обсуждение той предпосылкой, что стандартизация – это хорошо. Почему же теперь мы «расстандартизуем» наши данные путём умножения на γ и сложения с β? Причина в том, что стандартизация – это не обязательно хорошо, мы точно не знаем. Так что мы позволяем нейронной сети самой решать при помощи градиентного спуска.

Предположим, стандартизация – это всё-таки хорошо. Тогда нейронная сеть выяснит, что γ должно быть близко к 1, а β – близко к 0. Теперь допустим, что стандартизация – это плохо, а к лучшим результатам ведёт нечто другое. Тогда нейронная сеть выяснит лучшие значения γ и β. Другими словами, нейронная сеть с помощью градиентного спуска изучает, какими должны быть лучшие масштабирование и сдвиг. И это вовсе не обязательно 1 и 0, это любые значения, которые минимизируют функцию затрат.

Теперь мы знаем, как обучать нейронную сеть с помощью пакетной нормализации, но у нас всё ещё остаётся проблема. Предположим, пора делать прогноз, но в качестве входных данных у нас всего один пример, а мы хотим вычислить для него прогноз. Мы не можем делать то, что делали, ведь если мы вычтем среднее значение одного примера из самого примера, то получим просто вектор, состоящий из нулей. Ясно, что это не то, что мы хотели бы получить во время проверки. А вот что было бы неплохо – так это если бы мы отслеживали все средние значения и стандартные отклонения выборок, полученные во время обучения. Тогда мы могли бы вычислить общие среднее значение и стандартное отклонение и использовать их во время проверки. Собственно, это именно то, что мы и делаем: отслеживаем скользящие среднее значение и дисперсию и используем для них экспоненциальное сглаживание, что вы могли видеть в моих курсах по обучению с подкреплением, равно как и в RMSProp и Adam, просто обозначали через μ и σ без индекса B. Теоретически мы также можем вычислить μ и σ2 как фактические среднее значение и дисперсия всех наших учебных данных, но проблема с ними заключается в том, что они не масштабируются, если данных слишком много. В то же время это может оказаться более простым способом рассматривать μ и σ.

Таким образом, во время проверки вычисления проводятся следующим образом:

где μ и σ – это то, что мы вычислили во время обучения.

Для реализации в коде (что вы бы могли сделать вручную, поскольку это отличное упражнение) и в Theano, и в Tensorflow есть встроенные функции, способные нам в этом помочь. В Tensorflow это tf.nn.batch_normalization, а в Theano это batch_normalization_train и batch_normalization_test – две разные функции в одном модуле bn.

Следует также иметь в виду, что все они – поэлементные операции, поэтому хотя мы обсуждали их с точки зрения векторов (а вы, возможно, даже рассматривали их как скаляры), они в равной степени применимы и к изображениям, поскольку каждый скалярный элемент рассматривается отдельно. Разница состоит в том, что при свёртке мы рассматриваем количество характеристических карт как выход, и именно столько нам понадобится γ и β. Здесь есть прямая аналогия с полносвязным слоем, где количество γ и β – это количество признаков или, другими словами, количество скрытых узлов.

Свёртка с дробным шагом

Рассмотрим ключевую часть генератора, с которой вы, вероятно, никогда ранее не встречались. Это нечто такое, что при просмотре диаграмм вроде приведенной (см. слайд) помечается как «развёртка», хотя в наши дни в целом принято считать, что это неудачное название, и вскоре вы увидите, почему.

Давайте обсудим, чем она отличается от любых других видов слоёв нейронных сетей, виденных нами ранее. Прежде всего по диаграмме можно сказать, что мы постепенно в каждом слое идём от маленького изображения к всё большему и большему. Но мы знаем, что обычная свёртка так не работает: в ней мы получаем или нечто примерно того же размера, или меньшее, чем оригинал, если используется шаг, больший единицы. Как же мы можем прийти от чего-то меньшего к чему-то большему?

Есть и ещё один вопрос, над которым стоит задуматься. Когда вы используете обычный редактор изображений, такой как Photoshop, чтобы изменить размер изображения, что получается при попытке увеличить изображение? Можно заметить, что результат будет размытым или мозаичным. Это называется «артефактами». Почему так происходит? Если подумать, то происходящее совершенно естественно. Маленькое изображение содержит лишь определённое количество информации. Если бы мы могли увеличить изображение и результат всегда был кристально чистым, это значило бы, что можно хранить бесконечное количество информации в конечном объёме пространства. Можно посмотреть на это и с другой стороны: что будет, если увеличивать изображение до атомного или субатомного уровня? Ясно, что это невозможно. Я не могу взять изображение Марса и просто так поменять его размеры, чтобы волшебным образом увидеть, на что он похож. Вывод таков: нельзя просто увеличить изображение и получить больше информации, чем содержалось в нём изначально.

Итак, как же генератору удаётся последовательно создавать всё большие изображения в каждом слое? Секрет в том, что каждое изображение является очень «толстым», и чем ближе к началу генератора, тем оно «толще», то есть тем больше там характерических карт или цветов. Эти характеристические карты содержат много информации, а мы в каждом слое просто переносим информацию из различных карт в пространственные размерности изображения.

Следующий вопрос – какого типа свёртка нам нужна, чтобы получить результат, больший входного? Ясно, что свёртка, использовавшая нами до этого, работать не будет. Решением является свёртка с дробным шагом. Это логично: если мы будем делать свёртку с шагом 2, то размерность нового изображения будет равна размерности старого, делённой на 2. Если же выполнять свёртку с шагом 1/2, то размерность нового изображения будет удвоенной по сравнению с исходным. Вот наглядное представление (см. слайд) обычной свёртки с шагом два (слева) и свёрткой с дробным шагом (справа). Как можно видеть, она эффективно вставляет пространство между пикселями входного изображения – именно таким образом результат становится больше входных данных.

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

В библиотеке Tensorflow правильной является функция conv2d_transpose. Вас может удивить название: какое отношение имеет транспонирование к свёртке? Ответ в том, что именно так работает автокодировщик. В случае автокодировщика с одним скрытым слоем мы можем разделить входные данные для скрытых весовых коэффициентов и входные данные для выходных весовых коэффициентов, так чтобы они были представлены одной и той же матрицей. Чтобы получить же окончательный результат, необходимо транспонировать матрицу от входа к скрытым весовым коэффициентам. Разумеется, матрицы должны иметь одну и ту же форму, что гарантирует, что при умножении матрицы для получения ответа размерности совпадут и выход будет того же размера, что и вход.

А что с библиотекой Theano? В Theano у нас будет нечто даже более чудовищное и имеющее название conv2d_grad_wrt_inputs. Возникает вопрос: какое отношение имеет свёртка с дробным шагом – или даже транспонированная свёртка – к градиенту свёртки? Отличный вопрос! Ответ на него определённо не нужен, чтобы понять, как создать DCGAN, поскольку можно использовать функцию как она есть. Но если вам интересно, придётся перескочить на другую тему. Дело в том, что когда мы пытаемся реализовать какую-либо свёртку таким образом, чтобы выход был больше входа, то можно использовать градиент свёртки. Почему так? Давайте посмотрим. Допустим, у нас есть некоторый выход свёртки, пусть это будет

В этом случае мы знаем W и X, и вызываем функцию свёртки, чтобы получить Y. Но предположим, что у нас есть Y, а нам нужно получить что-то такого же размера, что и X. Мы знаем, что если найдём градиент Y относительно X, то он будет того же размера, что и X:

Хитрость в том, что «внутри себя» Theano и Tensorflow уже знают, как это делать – они должны это знать, чтобы быть в состоянии выполнить автоматическое дифференцирование. По счастливой случайности градиент свёртки и сам по себе является свёрткой, так что это не противоречит предыдущему нашему обсуждению. Другими словами, используя градиент свёртки, мы получаем ещё одну свёртку, однако эта новая свёртка приводит к тому, что результат оказывается больше входа, поскольку в свёртке, относительно которой мы брали градиент, вход был больше, чем выход.

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

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

Замечания по реализации в Tensorflow

В завершение мы обсудим ряд деталей реализации в Tensorflow, прежде чем перейдём к рассмотрению самого кода. Поскольку здесь множество новых вещей, я, конечно же, не ожидаю, что вы сможете написать код целиком с нуля. Трудно написать код, даже если вы точно знаете, что он должен делать, так что имеет смысл изучить реализацию как минимум несколько раз, и только потом идти и пытаться написать её самостоятельно. Напомню, что это один из способов действий, которые я предлагают в лекции «Как самостоятельно писать код». Эта лекция специально сосредоточена на реализации в библиотеке Tensorflow, однако будет полезна и для реализации в Theano, хотя у нас будет специальная лекция, посвящённая реализации в Theano.

Несмотря на то, что ранее мы в общих чертах рассмотрели множество составных частей DCGAN, при попытке самостоятельной реализации можно столкнуться со множеством препятствий. В Theano и Tensorflow есть некоторые нюансы, с которыми необходимо быть знакомым или которые нужно отыскать в документации, а в ряде случаев Theano и Tensorflow выполняют диаметрально противоположные вещи! Таким образом, эти лекции по подготовке к воплощению в коде являются существенными для понимания данных тонких вопросов. Однако если вы смотрите эту лекцию впервые, без каких-либо попыток написать код самостоятельно, она не будет иметь для вас особого смысла, поскольку я буду ссылаться на очень специфические вопросы кода. Поэтому, по всей видимости, вам стоит её просмотреть, принять к сведению увиденное, хотя, вероятно, не всё будет понятно, попробовать написать что-то самостоятельно, а затем вернуться и посмотреть, не станут ли обсуждаемые вопросы более понятными?

Первый вопрос, который необходимо обсудить, – это пакетная нормализация. Можно заметить, что в библиотеке для этого есть две функции. Первая из них является своего рода низкоуровневой встроенной версией и находится в tf.nn.batch_normalization; вторая же находится в tf.contrib.layers.natch_norm. Общим правилом является то, что находящееся в contrib является более высокоуровневым, позволющим делать то же самое с меньшим количеством когда, но и менее гибкое. Встроенная в Tensorflow версия требует, чтобы скользящие среднее значение и дисперсия устанавливались вручную, да и обновлять их тоже надо вручную. Contrib-версия настраивает все переменные внутри себя, но и затрудняет доступ к этим переменным. Мы для простоты будем пользоваться contrib-версией, хотя, как вы увидите позже, это создаст нам другие сложности.

Следующая функция – для выполнения свёртки с дробным шагом. На первый взгляд, она может показаться странной, однако эта странность логически непротиворечива. Когда мы выполняем свёртку в Tensorflow, то определяем форму фильтра как высоту фильтра, ширину фильтра, количество входных характеристических карт и количество выходных характеристических карт. Кроме того, мы определяем шаг свёртки как 4-кортеж, два внутренних элемента которого относятся к осям изображения. Но в случае свёртки с дробным шагом мы определяем форму таким образом, будто входные данные являются результатом обычной свёртки, а мы пытаемся найти исходные данные. Другими словами, это в точности как обратная свёртка. Поэтому если рассматривать свёртку с дробным шагом как «прямую» операцию, то форма фильтра будет иметь вид высота фильтра, ширина фильтра, количество входных характеристических карт, количество выходных характеристических карт, но с точки зрения свёртки, которая создала такой результат, входные и выходные характеристические карты меняются местами: высота фильтра, ширина фильтра, количество выходных характеристических карт, количество входных характеристических карт.

То же, вообще говоря, относится и к шагу свёртки. Мы полагаем, что поскольку это свёртка с дробным шагом, то шаг будет представлен дробным числом, скажем, 0,5. Но, как указывалось ранее, в результате получим ошибку. Поскольку мы должны указывать параметры, как если бы это выход, то по-прежнему должны указывать шаг 2. Другими словами, для прямой свёртки выход будет половинного размера по сравнению со входом. А поскольку это «обратная» свёртка, то шаг два для выхода удваивает размер истинного входа.

Следующее – небольшой технический вопрос. В случае обычной свёртки мы можем установить режим заполнения. Обычно он неизменен, что приводит к удобным вычислениям, но в случае свёртки с дробным шагом аргументы различаются: у нас нет аргумента для заполнения, зато есть аргумент для формы выхода. Для этой формы выхода считается, что свёртка с дробным шагом идёт в «прямом» направлении, так что это истинная выходная форма выхода этой функции, что противоположно тому, что указывалось ранее, где выход на самом деле означает вход. Что также странн, что если неправильно определить выходную форму, то функция выдаст ошибку. Таким образом, кажется излишним указывать выходную форму, поскольку она уже известна из функции, но пока что всё работает именно таким образом.

Выполним быстренько пример, чтобы вы точно понимали, что я имею в виду. Предположим, мы хотим использовать функцию conv2d_transpose для входного размера в виде (N, 8, 8, 64) с шагом 2 и размером фильтра (5, 5, 32, 64). Для этого примера используем словарь, где вход означает настоящие входные значения этой функции, а выход – настоящий её выход. Вначале проверим, правильная ли форма у нашего фильтра. Это должна быть последовательность высота фильтра, ширина фильтра, количество входных характеристических карт, количество выходных характеристических карт. Количество характеристических карт в последней размерности должно соответствовать нашему входу, поскольку именно столько бы мы получили, используя фильтр в «прямом» направлении.

Теперь найдём форму выхода. Не забывайте, что для этого нам нужно сделать вид, что мы выполняем прямую свёртку, где вход является выходом, а выход – входом. Получается диаграмма (см. слайд), в которой нам нужно найти пропущенные значения. Прежде всего мы знаем, что шаг равен двум, так что размерность изображения должна быть 16×16. Далее мы знаем, что количество входных характеристических карт задано в фильтре, так что количество пропущенных характеристических карт должно быть равно 32. Следовательно, выходная форма будет иметь вид (N, 16,  16, 32).

И последний вопрос, который надо обсудить (хотя, вероятно, у вас появятся и другие; их, кстати, можно задать на форуме, если вы не уверены) – это дизайн компонентов. Я предпочитаю проектировать компоненты при помощи классов. Причина в том, что классы и объекты очень хорошо вписываются в данную парадигму: нам нужно создать слои, что значит создание матриц весовых коэффициентов и свободных членов, после чего можем выполнять такие операции, как подстановка в них входных данных и получение выходных, преобразованных весовыми коэффициентами и свободными членами этих слоёв. Как правило, это делается при помощи функции forward. То есть, это две отдельные задачи, логически разделённые в коде по своей цели: одна функция для создания весовых коэффициентов и свободных членов, вторая – для их использования и выполнения преобразования:

class MyLayer:

    def __init__(m_in, m_out):

        W = random(m_in, m_out)

        b = random(m_out)

    def forward(X):

        return f(XW + b)

Проблема в том, что при использовании слоёв в модуле contrib библиотеки Tensorflow эти вещи, как правило, выполняются одновременно. Мне это кажется глупым, но именно так всё было спроектировано. С одной стороны, это кажется лучшим решением, ведь одна функция делаёт всё, а нам остаётся лишь задать входные данные для слоя и присвоить переменную для выхода, а функция внутри создаст весовые коэффициенты. Не забывайте, что в Tensorflow нам, по сути, не нужно иметь доступ к параметрам модели, поскольку Tensorflow автоматически отслеживает их и запускает для них оптимизатор. Однако в нашем случае это верно не на 100%, поскольку нам нужны два оптимизатора: один для генератора, второй для дискриминатора, а нам нужна оптимизация относительно только по одному набору параметров за раз. Это значит, что нам понадобится ещё одна специальная функция для сбора этих параметров, чтобы сообщить Tensorflow, какой из них обновлять, а какой – нет. Хотя, вообще говоря, когда у вас есть лишь одна нейронная сеть, вам вообще не нужен доступ к параметрам в коде.

В чём же проблема с использованием этой простой функции, которая принимает входные данные и выдаёт выходные, используя избранный нами тип слоя? Напомним, как работает GAN. В дискриминатор нам на самом деле нужно вставить два различных набора данных: один для настоящих изображений изображений, а один – для фальшивых. Другими словами, с классово-ориентированной моделью всё было бы хорошо, поскольку, единожды создав модель, можно дважды использовать функцию forward. Но в случае функционально-ориентированного слоя вроде batch_norm если мы вызовем функцию ещё раз, она создаст целый новый набор весовых коэффициентов, потому что именно это функция и делает – создаёт весовые коэффициенты и вычисляет результат одновременно. Это явно не то, что нам нужно, поскольку нам требуется лишь одна сеть дискриминатора. Каково же решение?

Решение состоит в том, что batch_norm принимает аргумент, который называется reuse. Он устанавливается в значение «истина», если мы хотим вновь использовать весовые коэффициенты этого слоя. К сожалению, применение аргумента reuse привносит другие сложности в наш код. Например, когда мы вызываем функцию и ставим reuse значение «истина», как Tensorflow будет знать, какие весовые коэффициенты использовать?

Приложения

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

Основы Theano и Tensorflow. Обзор; Приложение. FAQ; Как самостоятельно писать код. Приложение; Приложение. Кому и для чего подходит данный курс?; Приложение. Theano. В каком порядке изучать курсы.

Андрей Никитенко
Андрей Никитенко
Задать вопрос эксперту
Понравилась статья? Поделить с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!:

Share via
Copy link