Первые впечатления от изучения PyTorch и машинного обучения на Python с использованием записных книжек Jupyter.

Недавно я прошел обучение машинному обучению с Python и хотел немного поразмышлять о том, как я это испытал, сравнивая его с моим опытом машинного обучения в Julia.

Сначала оговорка: я не эксперт в этой области. Я учусь, поэтому это написано с точки зрения новичка, а не с точки зрения эксперта. Я также гораздо более компетентен в использовании Джулии, но постараюсь дать справедливую оценку Python.

Преимущества использования Jupyter Notebook

Я разработчик старой школы и никогда не был большим поклонником таких ноутбуков, как Jupyter. Я предпочел использовать хороший текстовый редактор, такой как TextMate, Atom, Kakoune или VSCode, вместе с терминалом для Julia или Python REPL.

Какое-то время я пытался просмотреть материал, который мы рассмотрели, используя свой обычный подход. Однако я заметил, что, когда вы изучаете сложную тему с кучей математики, делать заметки о том, что происходит в обычном текстовом редакторе, не очень хорошо.

Вот где действительно блеснули ноутбуки. Возможность видеть прошлые результаты кода, который выполнялся ранее, как в форме чисел, так и в виде графиков, была бесценной. Вы можете что-то забыть, и вам придется прокручивать страницу назад для справки. Вся эта способность теряется, когда у вас есть простой текстовый файл кода, который нужно запускать повторно каждый раз, когда вы хотите посмотреть на конкретный результат.

Также полезно иметь возможность смотреть на сложные уравнения, красиво отрисованные. Ваша способность видеть более широкую картину сильно снижается, если вы смотрите на необработанный код LaTeX для уравнения.

Ключевые преимущества Python над Julia

Хотя я большой поклонник Джулии и буду утверждать, что у Джулии масса преимуществ перед Python, есть ряд областей, в которых Python явно имеет преимущество.

При работе с блокнотами и тренировках важно уметь быстро успевать. Ваш тренер может переключиться на другой блокнот, чтобы показать вам что-то, и вы захотите быстро попробовать то же самое.

Python - это интерпретируемый язык, поэтому код сразу начинает работать на полной скорости. Эта скорость может быть невысокой, но, по крайней мере, задержка очень мала. Это означает, что с Python вы очень быстро получаете графики и результаты при переключении ноутбуков.

У Джулии проблема с компиляцией Just in Time. Julia может быть на несколько порядков быстрее, чем Python, но у нее есть проблема с задержкой. В частности, продвигая первый сюжет, можно выйти медленно. Пока вы ждете своего сюжета, ваш тренер, возможно, перешел к другой теме.

Но становится еще хуже. Нередко что-то идет не так. Вся ваша записная книжка умирает, и вам необходимо перезапустить ядро. Ой, это действительно болезненно при использовании JIT.

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

Преимущества ноутбука Julia

Хотя задержка работала в пользу Python, я не думаю, что общий опыт работы с блокнотом на Python обязательно был лучше. Одна проблема, которую я заметил с Python, заключается в том, что в Python не весь код представляет собой выражения, и не все объекты имеют разумную визуализацию по умолчанию. Часто вам приходится создавать их самостоятельно.

Здесь я чувствую, что Джулия действительно сияет. Все является выражением, и почти каждый объект красиво проявляется. Часто специальные формы визуализации адаптируются к системе, в которой вы работаете. объект может выглядеть в записной книжке иначе, чем в REPL.

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

Тема, которую мы рассмотрели, была в высшей степени математической. Это означает, что мы обычно показывали уравнения, которые позже были реализованы в коде.

Здесь, на мой взгляд, у Джулии есть явное преимущество, так как она позволяет очень легко вводить символы Юникода, используемые в математике. Джулия использует нотацию LaTeX, которую научные программисты уже знают. Нажатие на вкладку просто завершит что-то, написанное в LaTeX, до его эквивалента в Юникоде. Здесь Джулия извлекает выгоду из того, что это современный язык, созданный после того, как юникод стал широко использоваться. Весь исходный код Julia должен быть в кодировке UTF-8.

ŷ обычно используется для обозначения прогноза. Таким образом, определение функции потерь в Julia может выглядеть так:

function loss(x, y)
  ŷ = predict(x)
  sum((y .- ŷ).^2)
end

Однако в Python вместо этого можно было бы написать y_hat, что создает несоответствие между рассматриваемым уравнением и исходным кодом, реализующим это уравнение.

Вот еще один пример определения простой нейронной сети в библиотеке Julia Flux ML.

layers = [Dense(10, 5, σ), Dense(5, 2), softmax]

Обратите внимание, как сигмоидальная функция σ использует тот же символ Юникода, что и математическое уравнение. Здесь Dense(10, 5, σ) используется для создания уровня нейронной сети с 10 входами, 5 выходами и сигмоидной функцией в качестве функции активации.

Функциональное и объектно-ориентированное программирование

Julia и Python - это языки, следующие совершенно разным парадигмам. Объектно-ориентированное мышление глубоко укоренилось в ДНК Python. Это естественно, поскольку он был разработан в период, когда популярность и шумиха вокруг объектно-ориентированного программирования были, возможно, на пике.

Джулия, напротив, - это язык, который появился в самом конце шумихи вокруг объектно-ориентированной рекламы, когда функциональное программирование начало набирать обороты. Эта разница проявляется при принципиально другом подходе к построению программ.

Настройка модели

Это особенно заметно при сравнении библиотек машинного обучения PyTorch и Julia’s Flux. В Python вы видите предпочтение для определения модели нейронной сети этим способом, взятым из общего руководства PyTorch:

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.pool = nn.MaxPool2d(2, 2)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1 = nn.Linear(16 * 5 * 5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = x.view(-1, 16 * 5 * 5)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x

net = Net()

Хотя PyTorch действительно содержит абстракции более высокого уровня, которые позволяют создавать модели, это напоминает мне подход, используемый Flux. Это пример из документации PyTorch. Сначала мы настраиваем некоторые входы:

import torch

N, D_in, H, D_out = 64, 1000, 100, 10

# Create random Tensors to hold inputs and outputs
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)

Эта часть очень похожа на настройку модели в Flux.

model = torch.nn.Sequential(
    torch.nn.Linear(D_in, H),
    torch.nn.ReLU(),
    torch.nn.Linear(H, D_out),
)

y_pred = model(x)

Здесь у нас есть пример настройки модели в Julia’s Flux. Обратите внимание, это разные модели. Я просто провожу здесь быстрое сравнение на основе примера, приведенного в официальных документах.

x = rand(10)
model = Chain(
  Dense(10, 5, σ),
  Dense(5, 2),
  softmax)

y_pred = model(x)

Хотя они могут выглядеть совершенно иначе, стоит отметить, что такие вещи, как torch.nn.ReLU(), не являются обычными функциями Python, а являются специальными узлами в графе PyTorch. Сравните это с σ и softmax в примере с Julia, которые являются просто нормальной функцией. Вы можете использовать их вне Flux. Фактически они не являются частью библиотеки Flux, а являются частью NNlib и могут использоваться в любой другой библиотеке машинного обучения.

Обучение

Давайте сравним, как проходит обучение. В нашем примере PyTorch мы начинаем с определения функции потерь и оптимизатора (алгоритма обучения).

Функция потерь - это мера того, насколько велика разница между тем, что ваша модель предсказывает при определенных входных данных, и ожидаемым выходом. Машинное обучение сводится к минимизации значения функции потерь.

Оптимизатор, напротив, представляет собой алгоритм того, как мы корректируем нашу модель, чтобы уменьшить потери на каждой итерации.

loss = torch.nn.MSELoss(reduction='sum')

learning_rate = 1e-4
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)

Вы можете видеть, что Flux внешне похож. Но ключевое отличие состоит в том, что вы можете видеть, что функция Julia loss на самом деле является обычной функцией Julia, тогда как в PyTorch это какой-то объект.

reduction и agg - это то, как мы уменьшаем или агрегируем значения в каждом пакете обрабатываемых данных. Обратите внимание, как в PyTorch она указана как строка 'sum', тогда как в Julia это просто обычная функция sum в стандартной библиотеке Julia.

loss(x, y) = Flux.Losses.mse(model(x), y, agg=sum)

learning_rate = 1e-4
optimizer = ADAM(learning_rate)

Наконец, у нас есть довольно стандартный цикл для выполнения обучения в PyTorch.

На каждой итерации мы вычисляем прогноз. С целевым выходом y мы вычисляем потерю в обучении.

Затем мы вычисляем градиент с обратным распространением, прежде чем использовать оптимизатор для обновления модели.

for t in range(500):
    # Forward pass: compute predicted y by passing x to the model.
    y_pred = model(x)

    # Compute and print loss.
    training_loss = loss(y_pred, y)
    if t % 100 == 99:
        print(t, training_loss.item())

    optimizer.zero_grad()

    # Backward pass: compute gradient of the loss with respect to model
    # parameters
    training_loss.backward()

    optimizer.step()

Есть некоторые сходства с Flux. Часто мы используем встроенные обучающие функции, но здесь мы делаем нашу собственную обучающую функцию, чтобы упростить сравнение происходящего.

Здесь мы можем более четко увидеть разницу между более объектно-ориентированным подходом PyTorch и более функциональным подходом Flux.

Flux больше следует схеме получения входных данных и создания выходных данных, которые затем передаются в другую функцию. Например. мы более явно вычисляем градиент и сохраняем его в gs. В PyTorch мы никогда не видим градиент. Все это скрыто в вызове training_loss.backward().

Затем в Julia мы обновляем параметры модели ps, вызывая update!. Опять же, мы очень четко указываем, что мы идем. Мы сообщаем ему, какой оптимизатор мы используем, каковы параметры модели и градиент gs.

function custom_train!(loss, model_params, data, optimizer)
  local training_loss
  ps = Params(model_params)
  for d in data
    gs = gradient(ps) do
      training_loss = loss(d...)
      return training_loss
    end
    
    update!(optimizer, ps, gs)
  end
end

custom_train!(loss, params(model), dataset, optimizer)

Преимущество функциональности

Лично я считаю главным преимуществом функционального подхода то, что мы ограничиваем мутации объектов, и мы можем более четко видеть, какие данные поступают и какие данные удаляются на каждом этапе.

Из-за этого сложнее нарушить последовательность операций. Например, в примере PyTorch ничто не мешает мне попытаться вызвать optimizer.step() в качестве первого элемента цикла.

В примере с Джулией аналог невозможен. Вы не можете вызвать update!(optimizer, ps, gs), пока не создадите градиент gs.

А для создания градиента необходимо создать training_loss. По сути, API Flux заставляет вас делать все в правильном порядке.

В PyTorch вы просто должны не забывать делать это в правильном порядке. Это то, что меня разочаровывает в объектно-ориентированном программировании в течение многих лет. Я часто видел такой код:

obj.foo()
obj.bar()
obj.qux()

Обратите внимание, что foo, bar и qux - это просто обычные бессмысленные имена для переменных и функций, любимых одними программистами и ненавидимых другими.

Дело в том, что неважно, какую именно функцию мы вызываем. Ключ в том, что мы продолжаем изменять объект obj, но на самом деле мы не знаем, вызываем ли мы эти методы по порядку.

Сравните это с функциональным подходом:

Object obj = ...

Thingy t = foo(obj)
Doodad d = bar(obj)
Result r = qux(t, d)

Я написал это, чтобы немного походить на C ++. Я хотел показать это в синтаксисе статического языка, поскольку это немного проясняет мою точку зрения.

В первом случае мы могли переместить obj.qux() в первую строку, и никто не мог сказать, что это явно неправильно.

Однако в функциональном примере невозможно вызвать qux в первой строке, потому что у нас нет входных значений типа Doodad и Thingy. И просматривая документы, мы можем обнаружить, что нам нужно вызвать foo и bar, чтобы получить такие значения.

Это довольно хорошо работает на динамическом языке, таком как Julia. Код выйдет из строя раньше, если вы не предоставите аргументы правильного типа. Но, что, возможно, более важно, вы могли легко найти в документации, какие входы и выходы у них были.

При объектно-ориентированном подходе все непонятно. Вы понятия не имеете, какие объекты obj.foo() могут создавать внутри, которые затем могут быть использованы другой функцией.

Это то, что я неоднократно наблюдал, просматривая примеры кода Python. Большой объем кода изменяет объекты, и вы не понимаете, что происходит на самом деле. Вы не можете четко увидеть, как данные проходят через систему или что нужно делать в первую очередь и в последнюю очередь.

На мой взгляд, это затрудняет понимание и ремонтопригодность.

Многословие Python

По сравнению с C ++, Java и, конечно, Objective-C, Python лаконичен. Но для такого разработчика Julia, как я, привыкшего к очень короткому коду, пример кода Python, на который я смотрю, часто выглядит шумным.

Я считаю, что такие вещи отвлекают. С точки зрения удобства использования у вас не должно быть идентичного текста в начале каждой строки, потому что это ограничивает вашу способность сканировать код вниз.

Это рекомендация, например, при написании маркированных списков в презентациях. Каждую строку следует начинать с уникальных отличительных слов. Однако здесь мы должны увидеть torch.nn. несколько раз в каждой строке, прежде чем мы действительно перейдем к сути.

model = torch.nn.Sequential(
    torch.nn.Linear(D_in, H),
    torch.nn.ReLU(),
    torch.nn.Linear(H, D_out),
)

Помните, что в случае с Джулией мы просто написали:

model = Chain(
  Dense(10, 5, σ),
  Dense(5, 2),
  softmax)

Это означает, что здесь гораздо меньше отвлекающего текста. В примере с Python большинство символов в каждой строке на самом деле является повторением.

Я знаю, что у разработчиков Julia и разработчиков Python будут некоторые разногласия по этому поводу. Я заметил, что мы просто читаем код совсем по-другому.

Разработчики Python просматривают исходный код в поисках таких вещей, как torch.nn, потому что они хотят знать, откуда взялась функция или тип.

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

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

Вот случайный пример из моего кода. Это нормализует числа в каждом столбце таблицы (DataFrame).

function _normalize(normalizer::Function, df::DataFrame)
    result = DataFrame()
    for colname in names(df)
        column = df[:, colname]
        if eltype(column) <: Real
            result[:, colname] = normalizer(column)
        else
            result[:, colname] = column
        end
    end
    result
end

Здесь ничего не сказано, из какого пакета что-то взято. Но при чтении этого кода я бы начал с ввода. Глядя на аннотации типов ::Function и ::DataFrame, я сразу же получаю четкое представление о том, с какими объектами работает эта функция.

Это помогает разобраться в остальной части кода. Если я не уверен, что это за типы, я могу найти их в справочной документации.

С таким мышлением я с трудом читаю код Python. Я смотрю на определение функции, но понятия не имею, что на самом деле входит в функцию. Часто вы можете догадаться по имени аргумента, но для более необычных типов это может быть сложно.

Разработчики Julia также не всегда комментируют, вносят свой вклад. Часто вы имеете дело с общими типами, такими как строки или числа, когда не так важно передать, что это за тип.

Но разработчик Python часто будет испытывать трудности с чтением кода Julia, потому что он сканирует пакеты. Из какого пакета берутся используемые функции.

Хотя для меня это не всегда имеет смысл. Большая часть кода Python объектно-ориентирована:

optimizer.step()

К какому пакету принадлежит метод step()? А какой тип optimizer и из какого пакета этот тип? Это невозможно сказать.

Острова функциональности Python против возможности компоновки Джулии

Когда я углубляюсь в ландшафт машинного обучения Python, я замечаю, что он состоит из нескольких отдельных вотчин или островов. У вас есть TensorFlow / Keras, PyTorch, Chainer и другие.

Это отдельные племена со своими последователями, и они не разговаривают друг с другом. Вы не берете часть функциональности, например оптимизатор или функцию потерь из PyTorch, и не помещаете ее в свою нейронную сеть Keras. Эти библиотеки принципиально несовместимы друг с другом и живут в разных реалиях.

Они не только удалены друг от друга, но и из других библиотек Python, таких как NumPy.

В мире Julia такие библиотеки, как Flux, не представляют собой острова. Многие функции машинного обучения используются разными библиотеками.

Функции активации, используемые в Flux, при желании можно использовать в другой библиотеке машинного обучения. Это просто обычные функции Джулии. Они даже не часть Flux, а NNlib.

И TensorFlow, и PyTorch используют специальные классы матриц, которые они называют тензорами, которые являются уникальными для них. Они отличаются друг от друга и от матриц NumPy. С Flux вы используете обычные матрицы Джулии. Следовательно, любая библиотека Julia, созданная для обработки стандартных матриц, может использоваться вместе с Flux.

Одним из примеров этого преимущества является запуск Flux на графическом процессоре. Рассмотрим, как это делается в PyTorch:

device = torch.device("cuda:0")
net.to(device)
inputs, labels = data[0].to(device), data[1].to(device)

Вы можете видеть, что функциональность графического процессора зависит от метода, реализованного на объекте, предоставленном PyTorch. Другими словами, PyTorch был специально создан для поддержки этого.

Хотя Flux содержит некоторые удобные функции, помогающие работать с процессором, вы действительно смогли бы это сделать, даже если бы создатели Flux не знали о пакете CUDA. В этом примере ни одна из функций Flux не знает, что имеет дело с массивами на GPU:

using CUDA
W = cu(rand(2, 5)) # a 2×5 CuArray
b = cu(rand(2))
predict(x) = W*x .+ b
loss(x, y) = sum((predict(x) .- y).^2)
x, y = cu(rand(5)), cu(rand(2)) # Dummy data
loss(x, y) # ~ 3

Функция cu используется для преобразования обычных массивов Julia в массивы CUDA, хранящиеся в памяти графического процессора. Однако код для функций predict и loss одинаков, а остальная часть Flux не знает и не заботится о том, что массивы находятся на графическом процессоре. Flux не предназначен для работы с каким-либо конкретным типом массива, только с конкретным интерфейсом массива, а массивы CUDA имеют тот же интерфейс, что и обычные массивы Julia.

Одно из преимуществ, которое я вижу в этом, заключается в том, что в экосистеме Julia можно меньше узнать, потому что вы больше повторно используете свои знания. Многие одни и те же пакеты повторно используются в разных контекстах. Переключитесь на другую библиотеку машинного обучения, и вы по-прежнему сможете использовать обычные массивы Julia, CUDA, NNlib и так далее.

Замечание

Многих из вас может заинтересовать более глубокое сравнение PyTorch и Flux, и я постараюсь сделать это в одной из будущих статей. В первую очередь это была история о том, каково это, или об опыте работы с машинным обучением Python в качестве ученика.