ML: Вычислительный граф
Введение
Машинное обучение обычно сводится к минимизации скалярной функции,
зависящей от множества переменных.
Простейшим примером является линейная модель
y=x⋅w+b
с параметрами w (матрица) и b (вектор).
По набору обучающих примеров {ˆx,ˆy}
вычисляется ошибка обучения L. Для mse-ошибки (mean squared error)
она равна среднему значению квадрата разности обучающих значений ˆy
и выходов модели y:

Ошибка модели зависит от её параметров L=L(w,b). Обучение модели означает поиск значений параметров при которых ошибка достигает своего минимума: w,b=argminL(w,b). Если параметров очень много, наиболее эффективен градиентный метод и различные его модификации. Для сложной модели аналитически вычислить градиент (производные по параметрам) непросто. Удобнее представить модель в виде вычислительного графа (computational graph). Граф имеет топологию дерева, корень которого - это искомая величина (ошибка L), а узлы выполняют элементарные вычисления. Если производные в каждом узле известны, то можно найти и градиент ошибки по параметрам модели, которые находятся в листьях дерева. Подобный подход лежит в основе всех современных фреймворков машинного обучения.
Общая постановка задачи
Пусть модель задана функцией y=f(x,p),
где x - вектор признаков объекта,
а p - вектор параметров.
Кроме этого, есть N обучающих примеров
{ˆx,ˆy}=(ˆx(1),ˆy(1)),...,(ˆx(N),ˆy(N)),
которые помечаются шляпкой.
Параметры p модели подбираются так, чтобы минимизировать среднюю ошибку L на один пример:
L = L(p) = 1NN∑i=1L(ˆy(i),y(i)), y(i)=f(ˆx(i),p).
Чтобы найти минимум L, необходимо сдвигать вектор параметров в направлении обратном к градиенту (частным производным)
L по p. Величина сдвига определяется скалярным гиперпараметром λ (скорость обучения):

Вычисления проводятся в два этапа. Сначала при прямом проходе (слева-направо)
вычисляется ошибка L при заданных ˆx, ˆy и p.
При этом определённые значения принимают все узлы вычислительного графа.
Затем запускается процедура обратного прохода.
Начальный градиент g в скалярном узле ошибки L является скаляром.
Проходя через узлы графа от ошибки справа-налево, он превращается в вектор g (в общем случае в тензор).
Изменения g на каждом узле происходят таким образом, что,
когда он "добирается" до параметров p,
то оказывается равным частным производным: g(p)=∂L/∂p.
Прямой и обратный проходы
Рассмотрим в качестве простого примера функцию z=f(x,y)=x⋅x+sin(2y) двух скалярных переменных x и y. Пусть x=4 и y=3.14159. Эти числа присваиваются в листовые узлы x,y и затем распространяются по вычислительному графу слева-направо. Так, в узел умножения входят две стрелки каждого аргумента операции. В первом таком узле они "несут" одинаковые значения 4, которые перемножаясь, дают на выходе узла число 16 (зелёный цвет). В результате, каждый узел, включая финальный (z=16), принимает определённое значение:
z=x⋅x+sin(2⋅y)

После прямого прохода по графу, запускается обратное (backward) распространение вычисления производных. Ниже на рисунке из корневого узла z выходит число gz (его полагают равным 1). Оно движется по графу к листовым узлам x,y. Проходя через каждый узел эта величина определённым образом изменяется:
gx=∂z∂x=2⋅x,gy=∂z∂y=2cos(2⋅y)

Необходимо так определить правила этих изменений, чтобы входящие в листовые узлы x,y величины gx,gy оказались частными производными z по x,y.
Спойлер
В этом документе встречается много громоздких формул. Чтобы не потеряться в них,
перечислим сначала основные правила обратного распространения:
- Входящий в любой узел A градиент gA, имеет смысл производной целевой величины (корня дерева) по значению в этом узле: gA=∂L/∂A.
- Тензорная размерность градиента, входящего в узел,
совпадает с размерностью
тензора, получаемого этим узлом на его выходе.
- Через узлы переменных градиент проходит без изменений, а в узлы констант "не заходит".
- Начальный градиент, входящий и выходящий из корня дерева L
равен единице,
так как по определению это: g=∂L/∂L=1. - Пройдя через узел, градиент расщепляется по всем входящим в узел рёбрам и умножается на производные между узлами, которые связывает ребро (ниже левый рисунок).
- Если в узел входит несколько градиентов, то они складываются (ниже правый рисунок), что является следствием правила вычисления производной от сложной функции (формула под рисунком).


Производные для скаляров
Рассмотрим сначала скалярные функции одной f(x) и двух f(x,y) переменных. В узлы которые их вычисляют входит одно и два ребра соответственно (аргументы функции). Пусть при обратном распространении в узел функции справа-налево попадает некоторое число g. Потребуем что-бы на выходе оно изменялось следующим образом:

(def1)
Проходя через узел, вычисляющий f(x), величина g умножается на производную функции f′(x).
Аналитическое выражение для f′(x) известно, т.к. предполагается, что функция узла вычислительного графа
достаточно простая.
Числовые значения f(x) и f′(x) зависят от значения аргумента x,
известного в результате прямого прохода по графу (слева направо).
Аналогичная ситуация для функции двух переменных.
Если граф состоит только из этих узлов, то при начальном градиенте g=1, мы получим правильные выражения для производных, входящих в листовые узлы x,y.
Применим эти определения к функции синуса и операциям сложения и умножения:

Таким образом, операция сложения не меняет проходящей через него градиент g, расщепляется его на две одинаковых величины. Проходя через узел умножения, градиент изменяется, умножаясь на "другой" аргумент.
Теперь несложно записать распространение производных для функции z=x∗x+sin(2∗y):

где учтено, что cos(2π)=1. Таким образом, в узел y входит двойка (∂z/∂y=2), а в узел x входит два раза четвёрка, т.е. ∂z/∂x=8.
Приведём пример вычисления этих градиентов на PyTorch:
from torch import tensor, sin x, y = tensor( 4. , requires_grad = True ), tensor( 3.14159265 , requires_grad = True ) z = x * x + sin( 2 * y) # граф с корнем: tensor(16., grad_fn=<AddBackward0>) z.backward() # запускаем вычисление градиентов (обратный проход) print (x.grad, y.grad) # tensor(8.) tensor(2.) |
Граф для составных функций
Рассмотрим в качестве примера обратное распространение для составных функций. Ниже слева z равна функции A, которая зависит от функции B(x). Производная z по x берётся как производная сложной функции (формула под рисунком). К этому же результату мы прийдём анализируя обратное распространение производной по графу. Из узла z выходит единица. Заходя в узел B она превращается в ∂A/∂B. После прохождения узла B эта производная умножается на ∂B/∂x:


Аналогично анализируется второй пример. В нём поток градиента расщепляется на узле A, а затем сходится на листе дерева x. Эти две производные необходимо просуммировать, что соответствует знаку плюс в формуле частной производной (под рисунком).
Градиенты для тензоров
В задачах машинного обучения корнем графа (дерева) обычно является скалярная величина (например ошибка модели), тогда как листовые узлы графа (параметры модели) - это тензоры. Частные производные корня по компонентам тензора собственно и называются градиентами.
Пусть из скалярной функции ошибки L на вход модели поступает градиент.
Он распространяется по графу и
входит в виде тензора g(Z)ij... справа в некоторый узел Z.
В этом узле происходит вычисление, меняющее тензор на g(X)αβ...
и он входит в узел X.
В зависимости от характера вычислений в Z, число индексов и их размерности тензора могут измениться. При этом размерность и форма градиента, входящего в узел всегда должны совпадать с размерностью и формой данных которые хранятся в этом узле. Так как L зависит от тензора Z, который зависит от тензора X, то производная сложной функции вычисляется следующим образом:
(def2)
Если корнем вычислительного графа выступает скалярная величина, то выходящий из неё градиент, как и раньше, полагают равным 1, т.к. ∂L/∂L=1.
☯ Приведенное определение можно обобщить на случай, когда в корне графа находится тензор, а не скаляр. В зависимости от целей, градиент в нём инициализируют тем или иным тензором (той-же размерности!). Например, если это тензор состоит из единиц: g(L)ij...=g(Z)ij...=1, то будет происходить суммирование всех компонент корневого тензора, что превратит его в скаляр:
g(X)αβ... = ∑i,j,...∂Zij...∂Xαβ... = ∂Z∂Xαβ..., Z = ∑i,j,...Zij...Чтобы получить частную производную непосредственно от компонент Zij..., необходимо взять g(Z)ij... состоящим из нулей, кроме единственного элемента с индексами i,j,..., равного единице (проекционный тензор). Впрочем для машинного обучения эти случаи не важны.
Сложение и умножение тензоров
Выясним как градиент изменяется при прохождении через различные вычислительные узлы. Предварительно стоит просмотреть напоминание по работе с символом Кронекера δij в этом документе.
✒ Простейший узел складывает два тензора с одинаковым числом индексов. Воспользовавшись определением (def2), имеем:
Zij...=Xij...+Yij..., g(X)αβ... = ∑ij...g(Z)ij...∂Zij...∂Xαβ... = ∑ij...g(Z)ij...δiαδjβ... = g(Z)αβ...
Таким образом, суммирование не меняет градиента,
расщепляя его на два одинаковых тензора, которые попадают в узлы каждого аргумента операции.
При вычитании Zij...=Xij...−Yij...
градиент, входящий в узел Y изменит свой знак,
а градиент в узел X попадёт без изменения.
✒ Если M - матрица формы (n,m), а v - вектор с m компонентами, то при их сложении происходит расширение (broadcasting) вектора v: (n,m)+(m,) = (n,m)+(1,m) = (n,m). В этом случае градиент g(v), входящий в узел векторного аргумента v, также является вектором:
Zij=Mij+vj, g(v)α=∑i,jg(Z)ij ∂Zij∂vα = ∑i,jg(Z)ij δjα = ∑ig(Z)iα.В матричный аргумент Mij узла сложения градиент g(Z)ij, как и раньше, попадает без изменения. В общем случае суммирование производится по всем индексам, которые подверглись процедуре расширения.
✒ Узел умножения тензоров (без свёртки) рассмотрим на примере двух матриц:
Zij=MijNij, g(M)αβ=∑i,jg(Z)ij ∂Zij∂Mαβ=∑i,jg(Z)ij δiαδiβNij=g(Z)αβN(Z)αβи аналогично для g(N)αβ Таким образом, изменение градиента полностью аналогично изменению производных скалярных величин при прохождении узла их произведения.
Свёртка тензоров
✒ Рассмотрим свёртку двух векторов в узле dot. Её результатом будет скаляр, поэтому и в узел входит скаляр g(z):
z=∑iuivi, g(u)α=g(z)∂z∂uα=g(z)vα,и аналогично для g(v)α Таким образом, при обратном распространении в узел dot попадает скаляр g(z), а при выходе из него он превращается в два вектора: g(u)=g(z)v и g(v)=g(z)u.
✒ Свёртка матрицы Mij и вектора vj, в фреймворке PyTorch,
обозначается как mv.
Результатом операции будет вектор, поэтому
и входящий в узел градиент будет вектором g(z)i:
Вторая формула - это просто свёртка вектора и матрицы: g(v)=g(z)M.
✒ Вычисления для свёртки двух матриц (результат снова матрица) проводится аналогичным образом:
Zij=∑kMikNkj, {g(M)αβ=∑i,jg(Z)ij ∂Zij∂Mαβ=∑i,j,kg(Z)ij δiαδkβNkj=∑jg(Z)αjN(Z)βj,g(N)αβ=∑i,jg(Z)ij ∂Zij∂Nαβ=∑i,j,kg(Z)ij Mikδkαδjβ=∑iM(Z)iαg(Z)iβ. Переставляя индексы в матрице при помощи операции транспонирования M⊤αβ=Mβα, эти формулы можно записать в безындексной форме: g(M)=g(Z)N⊤ и g(N)=M⊤g(Z).
Представим, полученные выше формулы в графическом виде, опуская индексы у матриц и векторов:

Пример вычисления градиента от скалярного произведения двух векторов z=u⋅v, для которого ∂z/∂u=v:
u, v = tensor([ 1. , 2. ], requires_grad = True ), tensor([ 3. , 4. ], requires_grad = True ) z = u.dot(v) # tensor(11., grad_fn=<DotBackward>) z.backward() # запускаем вычисление градиентов print (u.grad) # tensor([3., 4.]) print (v.grad) # tensor([1., 2.]) |
Функции от тензоров
Рассмотрим теперь функции от тензора. Если это "обычная" функция, применяемая независимо к каждому элементу тензора, то всё как и в скалярном случае:
Zij...=f(Xij...), g(X)αβ... = ∑i,j,...g(Z)ij... ∂f(Xij...)∂Xαβ... = ∑i,j,...g(Z)ij... f′(Xij...) δiαδjβ... = g(Z)αβ... f′(Xαβ...). Наиболее популярные в нейронных сетях функции имеют следующие производные: {y=σ(x)=11+e−x,σ′(x)=y(1−y) {y=tanh(x)=ex−e−xex+e−xtanh′(x)=1−y2 {y=relu(x)=max(0,x)relu′(x)=0 if x < 0 else 1Чуть сложнее с функциями, которые меняют размерность тензора. Рассмотрим например, суммирование тензора по первому индексу: X.sum(axis=0):
Zjk...=∑iXijk..., g(X)αβγ... = ∑j,k,...g(Z)jk... ∂Zjk...∂Xαβγ... = ∑i,j,k,...g(Z)jk... δiαδjβδkγ... = g(Z)βγ...Таким образом, для каждого значения индекса α в g(X)αβγ... происходит дублирование входящего в функцию градиента g(Z)βγ.... Когда суммируются все компоненты тезора X, входящий в него градиент (той же размерности) из узла суммирования будет состоять из единиц, умноженных на входящий в узел скалярную величину g(Z).
x = 5 * torch.eye( 2 , 3 ) # [ [5., 0., 0.], x.requires_grad = True # [0., 5., 0.] ] z = x. sum () z.backward() # запускаем вычисление градиентов print (x.grad) # [ [1., 1., 1.], # [1., 1., 1.] ] |
Софтмакс и ошибка по кросс-энтропии
В задачах классификации часто используют ошибку кросс-энтропии (CE, CrossEntropyLoss) перед которой стоит функция softmax. Эти два вычислительных узла являются стартовыми при обратном распространении градиента.
Функция softmax: y=sm(x) нормирует вектор x, так, чтобы сумма компонент вектора y на выходе функции была равна единице (обычно yi интерпретируются как вероятности классов).
yi=exi∑kexk, yi>0, ∑kyk=1, ∂yi∂xα=yiδiα −yiyα. Отметим, что софтмакс y=sm(x) неоднозначная функция. Если к каждой компоненте вектора x добавить одно и тоже число, то результат функции не изменится: sm(x+a)=sm(x). Градиент, g(y), проходя через софтмакс, преобразуется в градиент g(x): g(x)α = ∑ig(y)i∂yi∂xα = (g(y)α−∑ig(y)iyi)yα.Таким образом, из входящего в функцию y=sm(x) градиента g(y)α вычитается его "среднее" по всем компонентам с весами yi ("вероятности") и результат снова умножается на "вероятности": g(y)⊙y−(g(y)y)y.
Ошибка CE получает на вход "вероятности" классов yi, вычисленные моделью
и номер истинного класса c.
В качестве ошибки выступает логарифм с обратным знаком от вероятности правильного класса.
Минимизация CE-ошибки - это задача максимизации значения "вероятности"
yc правильного класса (т.к. благодаря софтмаксу все yi>0 и их сумма равна единице, максимизация
yc минимизирует вероятности остальных классов):
L=CE(y,c)=−logyc, g(y)i=∂L∂yi=−δicyc={0,...,0,−1yc,0,...,0}.
Градиент g(y)i, выходящий из функции ошибки - это вектор с нулевыми компонентами, кроме c-той компоненты истинного класса. Далее, проходя через софтмакс, он превращается в: g(x)α = yα−δαc = {yii≠c,yc−1i=c. Если бы xα были параметрами модели, то градиентый метод со скоростью обучения λ подправлял бы их следующим образом: xα↦xα−λg(x)α={xα−λyαα≠c - уменьшаем для неверных классовxc+λ(1−yc)α=c - увеличиваем для правильного класса Если "правильная вероятность" yc близка к единице (а остальные yi, соответственно, к нулю), параметры xi перестают изменяться. На практике, естественно, вектор x является не параметром, а выходом модели. Поэтому, при обратном распространении градиент g(x)i продолжает двигаться по узлам в начало графа (к его параметрам).
Мини-PyTorch
Наметим идею построения графа вычислений (детали можно найти в файле: ML_Comp_Graph.ipynb).
Создадим класс тензора, который будет хранить элементы в numpy массиве.
Синтаксис методов сделаем близким к синтаксису фреймворка PyTorch,
с обзором которого можно познакомиться по этому документу.
У класса Tensor определим конструктор и метод tensor который преобразует аргумент в экземпляр класса Tensor, если он им ещё не является:
import numpy as np class Tensor: def __init__( self , val, args = None , fn = None ): self .data = np.array(val) # хранимые данные self .grad = None # градиент, входящий в узел self .fn = fn # операция, вычисляемая узлом self .args = args # список аргументов операции def tensor( self , t): # для констант в выражениях return t if isinstance (t, Tensor) else Tensor(t) |
Основные операции с тензорами осуществляются при помощи библиотеки numpy. Кроме возвращения значения операции, запоминают имя функции и ссылки на её аргументы. Вызов t = self.tensor(t) делается, чтобы аргументом операции мог быть не только Tensor, но и константа в виде числа или массива numpy:
class Tensor: # продолжение def add( self , t): # сложение t = self .tensor(t) return Tensor( self .data + t.data, [ self , t], "add" ) def sub( self , t): # вычитание t = self .tensor(t) return Tensor( self .data - t.data, [ self , t], "sub" ) def mul( self , t): # умножение без свёртки t = self .tensor(t) return Tensor( self .data * t.data, [ self , t], "mul" ) def dot( self , v): # скалярное произведение векторов v = self .tensor(v) return Tensor(np.dot( self .data, v.data), [ self , v], "dot" ) def mv( self , v): # свёртка матрицы и вектора v = self .tensor(v) return Tensor(np.dot( self .data, v.data), [ self , v], "mv" ) def mm( self , n): # свёртка матриц n = self .tensor(n) return Tensor(np.dot( self .data, n.data), [ self , n], "mm" ) |
Ключевой метод backward вычисления градиентов при обратном распространении по графу имеет вид:
class Tensor: # продолжение def backward( self , grad = 1 ): if self .grad is None : self .grad = grad else : # накапливаем входящие градиенты self .grad + = grad if self .fn = = "add" : # сложение self .args[ 0 ].backward( grad) self .args[ 1 ].backward( grad) elif self .fn = = "sub" : # вычитание self .args[ 0 ].backward( grad) self .args[ 1 ].backward( - grad) elif self .fn in [ "dot" , "mul" ]: # свёртка векторов или умножение тензоров self .args[ 0 ].backward( grad * self .args[ 1 ].data) self .args[ 1 ].backward( self .args[ 0 ].data * grad) elif self .fn = = "mv" : # свёртка матрицы и вектора self .args[ 0 ].backward( np.outer(grad, self .args[ 1 ].data)) self .args[ 1 ].backward( np.dot(grad, self .args[ 0 ].data)) elif self .fn = = "mm" : # свёртка двух матриц self .args[ 0 ].backward( np.dot(grad, self .args[ 1 ].data.T)) self .args[ 1 ].backward( np.dot( self .args[ 0 ].data.T, grad)) |
Рассмотрим пример использования класса Tensor.
v = Tensor([ 1 , 2 ]) # создаём вектор m = Tensor(np.eye( 2 )) # создаём единичную матрицу n = Tensor(np.ones(( 2 , 2 ))) # создаём матрицу из единиц z = m.mm(n).mv(v).dot(v) # строим вычислительный граф z.backward() # вычисляем градиенты print (z) # 9.0 print (v.grad) # [6. 6.] print (m.grad) # [[3. 3.] # [6. 6.]] print (n.grad) # [[1. 2.] # [2. 4.]] |
