Буквально в последние пару лет нейросети стали применяться в задачах анализа стилистических особенностей текстов и изображений. С их помощью разработчики пытаются ухватить и формализовать то, что отличает, например, одного художника от другого. Иногда решаются и обратные задачи — совсем недавно сообщалось, что нейросеть научили стилизовать изображения под картины художников. А до этого была громкая история про DeepDream от Google, которая научилась рисовать очень странные картины, некоторые из которых, впрочем, считаются сейчас почти искусством.
Сложность всех этих подходов заключается в том, что для работы они требуют действительно больших вычислительных мощностей. Например, прогонка DeepDream для конкретного снимка может занимать до нескольких часов. Тем любопытнее, что в марте 2016 года появилась работа четырех исследователей, в которой утверждалось, что процесс стилизации изображения можно драматически — то есть на несколько порядков — ускорить. Двое авторов — Дмитрий Ульянов и Вадим Лебедев — занимаются исследованиями нейронных сетей и искусственного интеллекта в Яндексе. Помимо этого, они вместе с Виктором Лемпицким участвуют в научной работе Сколтеха. Четвертый автор, Андреа Ведальди, работает в Оксфорде. На днях статью приняли на ведущую мировую конференцию по машинному обучению ICML.
В основе описанного ускорения лежит использование текстур, однако, суть работы несколько глубже, поэтому мы предлагаем читателям текст в формате вопросов-ответов, объясняющий, как работает стилизующая под Моне, Ренуара или Ван Гога нейросеть. Мы также предлагаем попробовать свои силы и самостоятельно сделать какой-нибудь шедевр: в конце статьи есть эмбед созданного авторами статьи несложного сервиса likemo.net, который позволяет испытать всю описанную в статье науку на практике и создать свою собственную картину, стилизованную под полотна великих художников.
Можно как-то просто и коротко: что такое нейросети, и что в них такого замечательного?
Просто и коротко можно: искусственная нейронная сеть — это удобный способ, как в виде программы реализовать какой-то из методов машинного обучения. Называются нейросети так потому, что они в буквальном смысле представляют собой множество нейронов (элементарных подпрограмм), организованных в сеть, что очень похоже на то, как устроен человеческий мозг. А замечательно в них то, что, изменяя количество нейронов, структуру сети и метод ее обучения, можно решать поистине огромный спектр задач. Главное — это наличие уймы исходных данных и понимания того, что вы хотите от вашей сети.
Пока понятнее не стало. Может тогда поподробнее? Что еще за машинное обучение?
Давайте подробнее. Цель любого из методов машинного обучения — это, опираясь на большое количество исходных данных, разглядеть в них какой-то шаблон или мотив (по-английски это будет pattern). С точки зрения программы «разглядеть» значит, что после обучения она сможет работать с новыми данными, опираясь на этот шаблон. Например, просмотрев миллионы картинок с котиками, программа сможет определять, есть ли котик на других картинках. А если ей «показать» много партий в настольную игру го, то она и сама сможет неплохо играть.
Конечно, мы лукавим и упрощаем картину, но при этом очень стараемся сохранить центральную идею. С точки зрения математики можно сказать, что шаблон — эта какая-то определенная функция f, а исходные данные — это аргумент этой функции, x. Тогда можно постараться так подобрать f, что если данные подходят под шаблон, то f(x)=1, а если нет — f(x)=0. Чем слово функция лучше, чем слово шаблон? Тем, что ее можно выразить в виде математического выражения, а потом и запрограммировать. Только оказывается, что для больших и сложных данных едва ли нас устроят школьные функции вроде f(x) = x2, а нужно придумывать что-то более сложное.
Вот тут-то и оказывается, что нейросеть — это как раз та самая функция f, которую мы ищем. Неважно, что состоит она из десятков, а может и тысяч нейронов, а каждый нейрон — это тоже какая-то функция. Главное, что f(x) все еще будет выдавать какое-то число, на основании которого наша программа будет принимать решение: есть ли на картинке котик, или нет.
Здесь начинается самая сложная и интересная часть: нейросеть мало создать, ее еще нужно обучить. Не переживайте, это тоже очень легко понять на примере функции. Вот возьмите, к примеру, f(x) = 2*x. Берем исходное число — x — и умножаем его на два, все просто. Немного усложним: f(x) = А*x, где А — это тоже какое-то число, которое мы знаем. Его называют параметром функции. В этом случае любое число x мы берем и умножаем на число А. Более того, мы можем записать нашу функцию в виде f(x,А), где x — исходные данные, которые мы заранее не знаем и не очень контролируем, а А — параметры функции, которые мы знаем и выбираем так, как нам надо.
Задача обучения нейросети — это как раз задача о подборе параметров А так, чтобы функция на выходе выдавала то, что нам надо. Обычно то, что нам надо — это правильно распознанный шаблон. В случае нейросетей параметрами A обычно являются веса, которые есть у каждого нейрона. В самом простом случае нейрон получает маленький кусочек входных данных (обычно это число), домножает его на свой вес и передает дальше по сети. При этом нейрон совершенно не представляет, что с его данными происходит дальше, от него требуется только эта простая операция. Если в итоге оказывается, что какой-то нейрон отвечает за очень важный кусок входных данных, этому нейрону присвоят вес побольше. Менее везучим нейронам, наоборот, вес скрутят. В итоге, чем правильнее подобраны веса, тем точнее программа будет попадать в шаблон, заложенный в данных.
«Но позвольте!» — возражаете вы, — «если мы сами будем эти веса выбирать, если мы сами знаем, что важно, а что — нет, зачем тогда вообще нейросеть?». Совершенно справедливо! В этом случае нейросети бы оказались совершенно бестолковой затеей. Но представьте, что в качестве входных данных у вас выступает картинка размером 1000×1000 точек, то есть — один миллион пикселей (чисел, фактически). При этом важно не только посмотреть, влияет ли каждый пиксель на окончательный ответ (есть ли на картинке котик?), надо еще проследить за корреляциями между несколькими пикселями: если этот — серый, а этот — зеленый, а этот — черный, то... Вот когда данных, за которыми нужно следить, оказывается намного больше, чем человек вообще способен охватить и строго запрограммировать выводы, вот тогда нам и нужны нейросети, потому что их можно обучать в автоматическом режиме.
Давайте про этот самый «автоматический режим».
Хорошо. Допустим, у нас есть нейросеть, которую нужно обучить. В самом начале у каждого нейрона вес будет задан случайно (или почти случайно, тут тоже есть целая наука, но это тема для отдельного обсуждения). Не будем ничего менять, а сразу пустим сеть в бой: дадим ей на вход картинку. Мы знаем, что нейросеть в итоге должна нам выдать одно число, допустим, от 0 до 1. Мы можем доверять нейросети, если она говорит > 0.9 и не доверять если меньше. Если сеть говорит 0.8 она вряд ли уверена, что котика нет.
Поскольку веса нейронов распределены почти случайно, то и общий ответ будет случайным, поэтому нам придется его проверять вручную. Если сеть сказала, что на картинке есть котик, а на самом деле его там нет, мы можем посмотреть, насколько ответ нейросети отличается от порогового значения, грубо говоря, насколько сеть промахнулась. Потом мы сможем посмотреть, насколько промахнулся каждый нейрон, который влиял на финальный ответ. Если в сети больше одного слоя, посмотрим, насколько ошиблись нейроны предпоследнего слоя и так далее, пока не дойдем до самых первых нейронов, которые получали входящие данные. Каждый раз, когда мы будем узнавать, насколько ошибся данный нейрон, мы будем подкручивать его вес на величину ошибки в нужную сторону. Описанный здесь «на пальцах» алгоритм называется методом обратного распространения ошибки. Конечно, на практике он реализуется гораздо сложнее, но суть его остается таковой.
После того, как веса у всех нейронов будут подкручены, мы покажем нейросети еще одну картинку и опять посмотрим, насколько она ошиблась. Повторяя этот процесс тысячи, а то и миллионы раз, мы (теоретически) будем крутить веса нейронов в нужную сторону, то есть улучшать работу сети. В конце концов мы должны прийти к такому состоянию, когда ошибка в среднем настолько мала, что и подкручивать-то некуда. Тогда говорят, что сеть обучена и готова к работе, то есть на каждую картинку у нее есть готовый ответ: есть на ней котик, или нет.
Мы постарались описать все как можно более радужно, хотя на самом деле во всех этих операциях наряду с преимуществами кроются и самые страшные недостатки нейросетей. Во-первых, если сеть достаточно глубокая (то есть в ней много слоев нейронов), все вычисления — как прямые, так и обратные, когда распространяется ошибка, — становятся очень затратными. Конечно, существуют мощные компьютеры, но гонять сутками дорогостоящую систему, чтобы узнать, есть ли на картинке котик, никто не собирается. Другой недостаток заключается в том, что нейросеть дает нам окончательный ответ, но никогда не расскажет, как она до него дошла. Веса нейронов так и останутся числами, которые что-то значат только для самой программы. Ответов вроде «ну, конечно, это кот, посмотрите — вот у него усы, лапы и хвост в полосочку» мы не получим.
А вот еще вопрос: что это за страшные слова «сверточная нейросеть» и «глубокое обучение»?
Ничего в них страшного нет. Про глубокое (или глубинное, в оригинале deep learning) достаточно помнить, что это набор методов работы с многослойными нейросетями, которые пытаются уловить в данных шаблоны (их еще называют абстракциями) очень высокого уровня. Вот возьмем для примера все ту же игру го. В нее так сложно играть, потому что на поле 19×19 можно ходить практически в любое место на доске, поэтому число комбинаций так велико, что ни один суперкомпьютер не в состоянии его просчитать в обозримое время. При этом любой профессиональный игрок уже после двух-трех ходов скажет вам, хорошо стоят камни или нет, или что вот этот камень надо бы подвинуть на одну линию вниз. Как он об этом знает? Просто он интуитивно оценил очень высокий уровень абстракции, который в игре может вылиться в какой-то локально обозримый уровень только через сотню ходов.
Однослойная нейросеть принципиально не способна улавливать подобные абстракции, поэтому для глубинного обучения таких слоев нужно много. Тогда нейроны смогут обработать достаточное количество корреляций, чтобы понять нужный шаблон, даже высокоуровневый. Многослойные нейросети сложно обучать традиционными методами (например, обратным распространением ошибки), однако с каждым годом находятся все новые и новые приемы, поэтому программисты продолжают нас радовать феноменами вроде упоминавшихся уже проектов Google DeepDream (до покупки Google компания называлась DeepMind) и AlphaGo.
Методы глубокого обучения востребованы при анализе изображений, потому что где-где, а в них хватает абстракций высокого уровня. Возьмем картину какого-нибудь известного художника. Если смотреть на нее через газету, свернутую трубочкой, мы будем видеть лишь отдельные детали, но даже они могут сказать очень многое о картине. Вид и направление мазка, наиболее характерные геометрические формы — все это абстракции низкого уровня, «строительные блоки», из которых складывается изображение. Если мы развернем нашу газету чуть пошире, мы увидем уже отдельные композиционные элементы, цветовые пятна, сможем оценить игру светотени — это абстракции более высокого уровня. Если газету убрать совсем, то мы сможем оценить общую композицию и, например, цветовое настроение — абстракции самого высокого уровня.
Все это способны проделывать многослойные нейросети, однако, им для этого требуется еще один важный элемент, который мы пока не обсуждали. До этого, пытаясь понять, есть ли на картинке котик, мы передавали каждый пиксель одному отдельному нейрону, а все взаимоотношения между пикселями оставляли на усмотрение нейросети, не вдаваясь в подробности, как именно она их анализирует. Один из наиболее популярных способов — это операция свертки, во время которой квадрат из нескольких пикселей (например, 3×3 или 5×5) умножается на определенную матрицу, в результате чего получается одно значение. Если по одной и той же картинке «пройтись» разными матрицами, можно получать набор новых картинок, на каждой из которых акцентируются различные признаки. Такой набор «свернутых» картинок называется картой признаков.
Идея сверточной нейросети заключается в том, что каждую картинку из карты признаков последовательно уменьшают в размере (например, заменяя четыре соседних пикселя на один, соответствующий их среднему значению) и заново подвергают операции свертки. Для простого примера — картинка размером 32×32 пикселя последовательно превратится в картинку 16×16, потом 8×8 и так далее, пока не останется один пиксель. При этом благодаря матрицам свертки на каждом уровне из картинки будут извлекаться все разные и разные признаки — те самые абстракции различных уровней. В итоге, вдоволь «поиздевавшись» над картинкой, сверточная нейросеть узнает о ней все — и какая там форма мазка, и что там со светотенью, и есть ли там котик.
Особым преимуществом сверточных нейросетей является то, что исследователю даже не обязательно самому придумывать матрицы свертки — они подберутся сами собой в процессе обучения. Чаще всего эти матрицы просто кодируют линии и дуги под разными углами, таким образом раскладывая изображение на разные геометрическим фигуры. Но, как и в случае с любыми нейросетями, сказать точно «что имел ввиду» каждый отдельный нейрон невозможно.
Так все-таки, что же такое сделали в Яндексе, что у них получилось рисовать картинки в разы быстрее?
Для ответа на этот вопрос нам пригодится все, что мы обсуждали до этого. Давайте для начала разберемся, что делали авторы оригинальной работы. Они брали две картинки и от каждой из них «отрывали» только одну часть: от одной картинки — композицию, от другой — стиль, или, проще, — текстуру. Как вы уже догадались, достигалось это при помощи сверточной нейронной сети: она предоставляла на выходе набор абстракций разного уровня. Достаточно было взять нужные абстракции из разных картинок, а потом прогнать их в обратном порядке — от одного случайного пикселя к картинке нужного размера.
Конечно, это легко описать на словах, но трудно воплотить на деле. Узким местом оригинальной работы стала оптимизация получившегося изображения, которая работала аналогично методу обратного распространения ошибки. Прогонять нейросеть «туда-сюда» требовалось многократно, пока получившееся изображение не начинало с заданной точностью удовлетворять определенной статистике. Поскольку этот процесс требовалось повторять для каждой новой пары «эскиз-текстура», оригинальное решение не очень подходило на роль онлайн-сервиса, так как на одну картинку уходило достаточно много времени.
Команда из Яндекса решила упростить жизнь своей нейросети, обучив ее заранее на одной текстуре, а потом применив ее к любому заданному эскизу. Основной целью было снижение затрат на вычисление, поэтому авторы стремились создать сеть, которую нужно было прогонять только один раз — без обратной оптимизации. Эта задача выглядит практически неразрешимой, и вот почему.
Сверточные сети, создающие изображения, «разворачивая» их из картинок меньшего размера, хорошо известны. Однако для получения какой-то конкретной картинки сеть должна уметь оценивать, насколько она ошиблась, когда прошла очередной шаг увеличения размера. При этом ошибку эту, как мы уже обсудили, можно узнать, только пройдясь по сети в обратном направлении — от конца к началу. Отсюда и возникает трудозатратная оптимизация по принципу «туда-сюда».
Если стоит задача построить сеть, которую нужно бы было прогнать только один раз и только «вперед», кто-то извне должен ей сообщать на каждом шаге, насколько она ошиблась. Именно по этому пути и пошла команда Яндекса. Помимо основной, генерирующей сети (generative network), была создана дополнительная — описательная нейросеть (descriptor network). Ее задачей был расчет ошибки на каждом шаге создания изображения.
В итоге у авторов получился очень эффективный тандем: одна сеть создавала из случайного набора пикселей образец картинки, а другая — сообщала ей, насколько она ошиблась на каждом шаге. В процессе обучения параметры генерирующей сети подбирались так, чтобы минимизировать ошибку, которую рассчитывала описательная сеть. После этого генерирующая сеть была способна в результате одного прогона создавать удовлетворительные образцы текстур из случайного набора пикселей.
Все, что осталось, это соединить созданную текстуру с предложенным эскизом. Здесь опять на помощь приходит основной принцип сверточных нейросетей. Вначале генерировалось несколько наборов случайных пикселей разных размеров — от примитивных 2×2 до размеров того изображения, которое требовалось в итоге создать. Параллельно с этим заданный эскиз также последовательно уменьшали в размерах, пока из него не получалось столько же разных картинок, сколько было создано случайных наборов. Далее случайные пиксели объединяли с образцами эскизов, а на них при помощи генерирующей сети накладывали текстуру. В результате получалось изображение, сочетавшее в себе композицию от эскиза с нужным стилем. При этом варьируя исходное соотношение случайных пикселей и образца текстуры можно было определить, насколько в готовом изображении будет угадываться исходный эскиз.
Теперь вы можете на практике попробовать то, о чем прочитали выше.