PyTorch — современная библиотека глубокого обучения, развивающаяся под крылом Facebook. Она не похожа на другие популярные библиотеки, такие как Caffe, Theano и TensorFlow. Она позволяет исследователям воплощать в жизнь свои самые смелые фантазии, а инженерам с лёгкостью эти фантазии имплементировать. Данная статья представляет собой лаконичное введение в PyTorch и предназначена для быстрого ознакомления с библиотекой и формирования понимания её основных особенностей и её местоположения среди остальных библиотек глубокого обучения. PyTorch является аналогом фреймворка Torch7 для языка Python. Разработка его началась в недрах Facebook ещё в 2012 году, всего на год позже появления самого Torch7, но открытым и доступным широкой публике PyTorch стал лишь в 2017 году. С этого момента фреймворк очень быстро набирает популярность и привлекает внимание всё большего числа исследователей. Что же делает его таким популярным?  Для начала разберёмся, что же вообще такое фреймворк глубокого обучения. Под глубоким обучением как правило понимают обучение функции, представляющей собой композицию множества нелинейных преобразований. Такая сложная функция ещё называется потоком или графом вычислений. Фреймворк глубокого обучения должен уметь делать всего три вещи: Чем быстрее ты умеешь вычислять свою функцию и чем гибче твои возможности для её определения, тем лучше. Сейчас, когда каждый фреймворк умеет использовать всю мощь видеокарт, первый критерий перестал играть значительную роль. Что нас действительно интересует, так это доступные возможности для определения потока вычислений. Все фреймворки здесь можно разделить на три крупные категории.  Уверен, многие из нас начинали разбираться с глубоким обучением, используя только NumPy. Прямой проход написать на нём тривиально, а формулу обновления весов можно посчитать на листочке или вообще получить готовые веса из астрала. Выглядеть такой первый код мог так: Со временем архитектуры сетей становятся сложнее и глубже и возможностей NumPy, карандаша и бумаги уже перестаёт хватать. Если ваше соединение с астралом к этому моменту ещё не закрылось и вам есть откуда брать веса, вам повезло. В противном же случае невольно начинаешь задумываться о двух вещах: При этом не хочется менять привычный подход, хочется просто написать: Ну так вот, угадайте что? PyTorch ровно это и делает! Вот совершенно правильный код: Остаётся лишь применить уже посчитанные обновления параметров. В Theano и TensorFlow мы описываем граф на декларативном DSL, который затем компилируется в некоторый внутренний байткод и исполняется в монолитном ядре, написанном на C++, или же компилируется в код на C и исполняется как отдельный бинарный объект. Если в момент компиляции нам известен весь граф целиком, его с лёгкостью можно продифференцировать, например символьно. Однако так ли необходима стадия компиляции? Оказывается, нет. Ничто не мешает нам строить граф динамически одновременно с его вычислением! А благодаря технике автоматического дифференцирования (automatic differentiation, AD) мы можем взять и продифференцировать граф в любой момент времени в любом его состоянии. В компиляции графа нет совершенно никакой реальной необходимости. Что касается скорости, вызов лёгких нативных процедур из интерпретатора Python оказывается не медленнее, чем исполнение скомпилированного кода. Не ограниченные DSL и компиляцией, мы можем использовать все возможности Python и делать код по-настоящему динамическим. Например, применять различные функции активации по чётным и нечётным дням: Или мы можем создать слой, который каждый раз прибавляет к тензору только что введённое пользователем значение: Более полезный пример я покажу в конце статьи. Резюмируя, всё сказанное выше можно выразить следующей формулой:
PyTorch = NumPy + CUDA + AD.  Начнём с NumPy части. Тензорные вычисления — основа PyTorch, каркас, вокруг которого наращивается вся остальная функциональность. К сожалению, нельзя сказать, что мощь и выразительность библиотеки в данном аспекте совпадает с таковой у NumPy. Во всём, что касается работы с тензорами, PyTorch руководствуется принципом максимальной простоты и прозрачности, предоставляя тонкую обёртку над вызовами BLAS. Тип данных, хранимых тензором, отражается в имени его конструктора. Конструктор без параметров вернёт специальное значение — тензор без размерности, который нельзя использовать ни в каких операциях. Все возможные типы: Никакого автоматического определения типа или типа по-умолчанию не существует. torch.Tensor является сокращённым названием для torch.FloatTensor. Автоматического приведения типов как в NumPy также не осуществляется: В этом плане PyTorch строже и безопаснее: вы не наткнётесь на двукратное увеличение потребляемой памяти, перепутав тип константы. Явное приведение типов доступно с помощью методов с соответствующими названиями. x.type_as(y) вернёт тензор значений из x того же типа, что и y. Если передать конструктору тензора в качестве параметра список, будет построен тензор соответствующей размерности и с соответствующими данными. Неправильно сформированные списки не допускаются так же, как и в NumPy. Допускается построение тензора из значения любого типа последовательности, что весьма интуитивно и соответствует поведению NumPy. Другой возможный набор аргументов конструктора тензора — его размер. Количество аргументов при этом определяет размерность. Поддерживается стандартное индексирование Python: обращение по индексу и срезы. Также в качестве индексов могут выступать другие тензоры. Однако, возможности здесь всего две: Всю доступную информацию о тензоре помогут узнать функции x.dim(), x.size() и x.type(), а x.data_ptr() укажет на место в памяти, где находятся данные. Почти для любой immutable функции в PyTorch существует её менее чистый собрат. Однако бывает и так, что функция существует лишь в каком-то одном варианте. По понятным причинам, функции, изменяющие размер тензора всегда являются immutable.  Перечислять все доступные операции над тензорами я не буду, остановлюсь лишь на самых важных и разделю их на категории. Как правило, они используются для инициализации при создании новых тензоров заданного размера Так как mutable функции возвращают ссылку на объект, удобнее записывать объявление и инициализацию в одну строчку. Доступны также экспоненциальное и геометрическое распределения, распределение Коши, логарифм нормального распределения и ещё несколько более сложных вариантов инициализации тензора. Не стесняйтесь смотреть в документацию! Самая часто используемая группа. Если операция здесь не изменяет размер и тип тензора, то у неё существует inplace вариант. Естественно, присутствуют все основные тригонометрические операции в том виде, в каком вы ожидаете их увидеть. Перейдём теперь к менее тривиальным функциям. Существуют также полные аналоги BLAS функций со сложными сигнатурами, такие как addbmm, addmm, addmv, addr, baddbmm, btrifact, btrisolve, eig, gels и множество других. Операции редукции похожи друг на друга по сигнатуре. Почти все они своим последним необязательным аргументом принимают dim — размерность, по которой редукция проводится. Если аргумент не задан, операция действует на весь тензор целиком. Всевозможные операции сравнения (eq, ne, gt, lt, ge, le) также определены и возвращают в качестве результата своей работы маску типа ByteTensor . Операторы +, +=, -, -=, *, *=, /, /=, @ работают ровно так, как вы и ожидаете, вызывая соответствующие описанные выше функции. Однако из-за сложности и не полной очевидности API, я не рекомендую пользоваться операторами, а использовать вместо этого явный вызов нужных функций. По крайней мере не стоит смешивать два стиля, это позволит избежать ошибок навроде x += x.mul_(2). У PyTorch в запасе ещё много интересных функций вроде сортировки или поэлементного применения функции, но все они крайне редко используются в глубоком обучении. Если же вы захотите использовать PyTorch в качестве библиотеки тензорных вычислений, не забудьте перед этим заглянуть в документацию. Broadcasting — сложная тема. На мой взгляд, лучше бы его не было. Но он есть, хотя и появился лишь в одном из последних релизов. Многие операции в PyTorch теперь поддерживают broadcasting в привычном NumPy стиле. Если говорить в общем, то два непустых тензора называются broadcastable, если, начиная с последнего измерения, размеры обоих тензоров в этом измерении либо равны, либо размер одного из них равен единице, либо измерений в тензоре больше не существует. Легче понять на примерах. Тензор размерности torch.Size([1]), будучи скаляром, очевидным образом broadcastable с любым другим тензором. Размер тензора, получившегося в результате broadcasting, рассчитывается следующим образом: В примере размерность второго тензора была дополнена единицей в начале, а затем поэлементный максимум определил размерность результирующего тензора. Подвох кроется в inplace операциях. Broadcasting для них разрешён лишь в том случае, когда размер исходного тензора не изменится. Во втором случае тензоры очевидно broadcastable, однако inplace операция не разрешена, поскольку x в ходе неё изменил бы размер. Функции torch.from_numpy(n) и x.numpy() могут быть использованы, чтобы конвертировать тензоры одной библиотеки в тензоры другой. На этом все основные моменты тензорной библиотеки PyTorch предлагаю считать рассмотренными. Надеюсь, теперь читателю понятно, что реализовать прямой проход для произвольной функции на PyTorch ничуть не сложнее, чем сделать это с помощью NumPy. Нужно лишь свыкнуться с inplace операциями и запомнить имена основных функций. Для примера, линейный слой с функцией активации softmax:  Здесь всё просто: тензоры могут жить либо "на процессоре", либо "на видеокарте". Правда, они весьма привередливы и живут только на видеокартах от Nvidia, причём не на самых старых. По-умолчанию тензор создаётся на CPU.  Память видеокарты при этом пуста. Одним вызовом мы можем переместить тензор на GPU. При этом nvidia-smi покажет, что процесс python занял некоторое количество видеопамяти. Свойство x.is_cuda поможет понять где сейчас находится тензор x. Когда исчезнут все ссылки на тензор, находящийся в видеопамяти, PyTorch не удалит его моментально. Вместо этого при следующем выделении он либо переиспользует этот участок видеопамяти, либо очистит её. Если у вас несколько видеокарт, функция x.cuda(device=None) с радостью примет в качестве опционального аргумента номер видеокарты, куда стоит положить тензор, а функция x.get_device() покажет на каком устройстве тензор x находится. Функция x.cpu() скопирует тензор из видеокарты "на процессор". Вот, например, как можно перемножить два тензора на видеокарте и вернуть результат обратно в оперативную память: И всё это доступно прямо из интерпретатора! Представьте аналогичный код на TensorFlow, где вам придётся создать граф, сессию, скомпилировать граф, инициализировать переменные и запустить граф на сессии. С помощью PyTorch я могу даже отсортировать тензор на видеокарте одной строчкой кода! Тензоры можно не только копировать на видеокарту, но и создавать их прямо на ней. Для этого используется модуль torch.cuda. Контекстный менеджер torch.cuda.device(device) позволяет создавать все определённые внутри него тензоры на указанной видеокарте. Результаты операций над тензорами с других устройств останутся там, где и должны быть. Переданное в x.cuda(device=None) значение приоритетнее, чем то, которое диктует контекстный менеджер. Функция x.pin_memory(), доступная только для тензоров на CPU, копирует тензор в page-locked область памяти. Особенность её в том, что данные из неё могут быть очень быстро скопированы на GPU без участия процессора. Метод x.is_pinned() покажет текущий статус тензора. После того, как тензор окажется в page-locked памяти, мы можем передать именованный параметр async=True функции x.cuda(device=None, async=False), чтобы попросить её загружать тензор на видеокарту асинхронно. Таким образом, ваш код может не ждать завершения копирования и сделать за это время что-нибудь полезное. Как видите, адаптация произвольного вашего кода для вычислений на видеокарте требует лишь копирования всех тензоров в видеопамять. Большинству операций без разницы, где находятся ваши тензоры, если они находятся на одном устройстве.  Механизм автоматического дифференцирования, заключённый в модуле torch.autograd, является хоть и не главным, но, без сомнения, важнейшим компонентом библиотеки, без которого та потеряла бы всякий смысл. Вычисление градиента функции в заданной точке — центральная операция методов оптимизации, на которых, в свою очередь, держится всё глубокое обучение. Обучение здесь — синоним оптимизации. Существует три основных способа вычислить градиент функции в точке: Первым методом пользуются лишь для проверки результатов из-за его низкой точности. Символьное вычисление производной эквивалентно тому, что вы делаете вручную, используя бумагу и карандаш, и заключается в применении списка правил к дереву символов. Про автоматическое же дифференцирование я расскажу в следующих параграфах. Библиотеки вроде Caffe и CNTK используют заранее предпосчитанную производную функции в символьном виде. Theano и TensorFlow используют комбинацию методов 2 и 3. Автоматическое дифференцирование (AD) — достаточно простая и весьма очевидная техника вычисления градиента функции. Если вы, не используя интернет, попытаетесь решить задачу дифференцирования функции в заданной точке, вы совершенно точно придёте к AD. Вот как AD работает. Любую из интересующих нас функций можно выразить как композицию некоторых элементарных функций, производные которых нам известны. Затем, используя правило дифференцирования сложной функции, мы можем подниматься всё выше и выше, пока не придём к искомой производной. Например, рассмотрим функцию двух переменных   Переобозначим           Каждая из получившихся функций является элементарной функцией — мы можем с лёгкостью вычислить её производную. Допустим, нас интересует   По правилу дифференцирования сложной функции можем записать   Все компоненты этого уравнения — производные элементарных функций и начальные значения — нам известны. Осталось лишь подставить их и вычислить результат. Однако подстановку мы будем совершать не в произвольном порядке, а начнём с начала — самых вложенных элементов.             Вот так за один проход мы можем вычислить производную функции в заданной точке. И необходимо для этого ровно такое же количество операций, как и для вычисления самой функции. Обратите внимание, что икс со звёздочкой — не символ, а некоторое числовое значение. Поэтому на каждом шаге рассмотренного алгоритма мы сохраняем не символьное выражение, а одно единственное число — текущее значение производной. Чтобы лучше понять AD, мы можем реализовать простейший его вариант всего в 20 строчек кода на чистом Python! Будем вычислять значение функции и её производную в одной и той же точке одновременно. Первым делом запомним значение переменной в точке и её производную. При сложении двух переменных мы сконструируем новую переменную, значение которой будет равняться сумме исходных переменных, а производная будет вычисляться по правилу вычисления производной суммы двух функций. Аналогично для умножения и возведения в степень Теперь мы с лёгкостью можем одновременно вычислить и значение нашей функции и её частную производную по переменной x1 за один проход. Ровно таким же поведением обладает класс Variable из модуля torch.autograd. Конечно, в отличии от нашей наивной реализации, он хорошо оптимизирован, поддерживает тензоры PyTorch и все возможные с ними дифференцируемые операции. К тому же, он вычисляет производную не по одному аргументу за раз, а по всем сразу. Давайте рассмотрим пример. И снова нам никак не нужно изменять наш код для вычислений: достаточно лишь обернуть тензоры в Variable и он сам позаботится о сохранении всех методов и свойств, а также добавит парочку новых. x.backward() выполнит обратный проход и посчитает производную по всем переменным, при создании которых мы указали requires_grad=True. Производная при этом запишется в свойство x.grad. Получить же свой тензор обратно мы можем используя свойство x.data. Свойство x.requires_grad покажет, нуждается ли узел графа в вычислении градиента. Правило такое: если хоть у одного дочернего узла это свойство установлено, оно будет установлено и у родителя. Что стоит обсудить отдельно, так это inplace операции. Далеко не все такие операции, доступные для тензора, доступны для Variable, так как исходное значение тензора может быть необходимо для вычисления производной. Общее правило: пока у вас нет серьёзных ограничений по памяти, лучше всегда использовать immutable операции. В противном случае придётся рассматривать каждую отдельно взятую операцию на предмет возможности её замены mutable версией. Волноваться здесь не стоит: PyTorch никогда не разрешит вам сделать что-то, что молча приведёт к неправильному результату вычислений.  В качестве примера мы не будем обучать однослойный перцептрон и даже не будем строить FaceResNet-1337. С этими задачами PyTorch, как и остальные фреймворки, справляется играючи, а массу подобных примеров легко найти в сети. Мы посмотрим на задачу, от которой остальные фреймворки в ужасе разбегутся. Допустим, вы прочитали статью Deep Function Machines: Generalized Neural Networks for Topological Layer Expression и решили попробовать реализовать её на практике. Статья обобщает слой нейронной сети как отображение между произвольными Хаусдорфовыми пространствами. Таким образом мы можем построить слой нейронной сети с непрерывным ядром, отображающий одно функциональное пространство в другое. Автор доказывает теорему, о том, что произвольному такому слою соответствует дискретный слой стандартной нейронной сети, причём единственный. Отношение это определяется следующим образом:           Без вывода и доказательства результат кажется достаточно простым. w — непрерывное параметризованное ядро, W — дискретное ядро, которое мы строим. Осталось лишь для каждого прохода вычислить W, применить его к каким-нибудь данным, посчитать производные, распространить ошибку через интеграл и обновить параметры w. Звучит легко? Для PyTorch — да. В качестве ядра используем волновое ядро из статьи. Быстро реализуем простейшую схему интегрирования. Теперь так же быстро накидаем всё остальное. Инициализируем наши веса. Подготовим данные. И обучим наш прототип. Запустим и увидим в терминале стройную колонну убывающих значений. Ниже показано как ядро меняется в процессе обучения.  Я даже не вспотел. Я разбирался с 3d графиками в matplotlib дольше, чем писал этот код. Да, он не идеален, но это работающий прототип, созданный за 15 минут. Считать вручную все эти производные нереально. TensorFlow… Я не представляю, как реализовать это на TensorFlow. PyTorch же наплевал на сложность и безумность вашей идеи. Если функция дифференцируема, PyTorch её дифференцирует. Если вам нужно перемножить тензоры на видеокарте, PyTorch не заставит вас определять граф, он позволит вам просто перемножить тензоры на видеокарте. Рассмотрим код обучения поближе. Этот код оборачивает наши тензоры в переменные, чтобы все дальнейшие вычисления строили за собой граф. Каждый раз это необходимо делать из-за того что наш граф динамический и, следовательно, одноразовый: совершить обратный проход по нему можно лишь единожды. Этот вызов, как вам уже должно быть известно, считает обратный проход по графу. Здесь мы берём тензор из посчитанного градиента (переменной), домножаем его на некоторое маленькое по модулю отрицательное число и прибавляем к тензору исходных весов на месте. Таким образом совершается один шаг градиентного спуска. Вызов backward() не обнуляет градиент, поэтому нужно сделать это вручную. Естественно, вам не придётся каждый раз вручную писать весь этот код: для стандартных задач в PyTorch предусмотрены все те же высокоуровневые абстракции, что и, например, в Keras.  В данной статье мы рассмотрели основы PyTorch: тензорные вычисления, cuda вычисления и автоматическое дифференцирование. Теперь вы с лёгкостью можете взять PyTorch и начать заниматься глубоким обучением с его помощью. Читателю может показаться, что PyTorch слишком низкоуровневый. Кажется, что для продуктивной работы над всем этим ещё предстоит написать удобную обёртку. С одной стороны это так: никто не запрещает вам написать поверх PyTorch ту обёртку, которая удобна лично вам. Вы можете написать её в модульном стиле, как в Caffe, а можете описать свой собственный декларативный DSL как в TensorFlow. PyTorch низкоуровневый настолько, насколько это только возможно и позволяет вам создавать поверх него те инструменты, с которыми будет максимально удобно работать лично вам. С другой стороны, существуют официальные реализации таких высокоуровневых обёрток, заключённые в модулях torch.nn и torch.optim. Модуль torch.utils.data позволяет крайне удобно асинхронно работать с данными. Отдельный пакет torchvision содержит всё необходимое для быстрого старта работы с изображениями. Обо всём этом поговорим в следующей статье. PyTorch великолепен. Он предоставляет вам различные уровни абстракции и позволяет с лёгкостью реализовывать свои. Он не загоняет вас в рамки какой-то одной парадигмы. Он быстрый, эффективный и распределённый. Попробуйте его и он вам непременно понравится. Спасибо за прочтение! Ставьте лайки, подписывайтесь на профиль, оставляйте комментарии, не тратьте время на борьбу с инструментами. Дополнения приветствуются.