В математике и компьютерной алгебре автоматическое дифференцирование ( auto-дифференциация , autodiff или AD ) , также называемое алгоритмическим дифференцированием , вычислительным дифференцированием , [1] [2] представляет собой набор методов для вычисления частной производной функции, заданной компьютером. программа.
Автоматическое дифференцирование использует тот факт, что каждое компьютерное вычисление, каким бы сложным оно ни было, выполняет последовательность элементарных арифметических операций (сложение, вычитание, умножение, деление и т. д.) и элементарных функций ( exp , log , sin , cos и т. д.). Повторно применяя правило цепочки к этим операциям, частные производные произвольного порядка могут быть вычислены автоматически с точностью до рабочей точности и с использованием не более небольшого постоянного коэффициента большего количества арифметических операций, чем в исходной программе.
Автоматическое дифференцирование отличается от символического дифференцирования и числового дифференцирования . Символьное дифференцирование сталкивается с трудностями преобразования компьютерной программы в одно математическое выражение и может привести к неэффективному коду. Численное дифференцирование (метод конечных разностей) может вносить ошибки округления в процесс дискретизации и отмены. Оба этих классических метода имеют проблемы с вычислением высших производных, где увеличивается сложность и ошибки. Наконец, оба этих классических метода медленны при вычислении частных производных функции по множеству входных данных, что необходимо для алгоритмов оптимизации на основе градиента . Автоматическая дифференциация решает все эти проблемы.
Автоматическая дифференциация особенно важна в сфере машинного обучения . Например, это позволяет реализовать обратное распространение ошибки в нейронной сети без производной, вычисляемой вручную.
Основой автоматического дифференцирования является разложение дифференциалов, обеспечиваемое цепным правилом частных производных сложных функций . Для простой композиции
Обычно представлены два различных режима автоматического дифференцирования.
Прямое накопление указывает, что правило цепочки проходит изнутри наружу (то есть сначала вычисляется , а затем и наконец ), тогда как обратное накопление предполагает обход снаружи внутрь (сначала вычисляется , а затем и наконец ). Более кратко,
Значение частной производной, называемой семенем , распространяется вперед или назад и изначально равно или . Прямое накопление оценивает функцию и вычисляет производную по одной независимой переменной за один проход. Поэтому для каждой независимой переменной необходим отдельный проход, при котором производная по этой независимой переменной устанавливается равной единице ( ), а всем остальным — нулю ( ). Напротив, обратное накопление требует оценки частичных функций для частных производных. Поэтому обратное накопление сначала оценивает функцию и вычисляет производные по всем независимым переменным за дополнительный проход.
Какой из этих двух типов следует использовать, зависит от количества разверток. Вычислительная сложность одной проходки пропорциональна сложности исходного кода.
Обратное распространение ошибок в многослойных персептронах — метод, используемый в машинном обучении , — является частным случаем обратного накопления. [2]
Прямое накопление было предложено Р.Э. Венгертом в 1964 году. [3] По словам Андреаса Гриванка, обратное накопление предлагалось с конца 1960-х годов, но изобретатель неизвестен. [4] Сеппо Линнаинмаа опубликовал результаты обратного накопления в 1976 году. [5]
При прямом накоплении AD сначала фиксируется независимая переменная , относительно которой выполняется дифференцирование, и рекурсивно вычисляется производная каждого подвыражения . При ручном расчете это включает в себя многократную подстановку производной внутренних функций в цепное правило:
По сравнению с обратным накоплением прямое накопление является естественным и простым в реализации, поскольку поток производной информации совпадает с порядком оценки. К каждой переменной добавляется ее производная (хранящаяся в виде числового значения, а не символьного выражения).
Используя цепное правило, если в вычислительном графе есть предшественники:
В качестве примера рассмотрим функцию:
Выбор независимой переменной, по которой проводится дифференцирование, влияет на начальные значения ẇ 1 и ẇ 2 . Учитывая интерес к производной этой функции по x 1 , начальные значения должны быть установлены следующим образом:
Если заданы начальные значения, значения распространяются с использованием правила цепочки, как показано. На рис. 2 показано графическое изображение этого процесса в виде вычислительного графа.
Чтобы вычислить градиент этой примерной функции, для которой требуется не только , но и , выполняется дополнительная прогонка по вычислительному графику с использованием начальных значений .
Прямое накопление вычисляет функцию и производную (но только для одной независимой переменной) за один проход. Связанный вызов метода ожидает , что выражение Z будет получено относительно переменной V. Метод возвращает пару оцениваемой функции и ее производной. Метод рекурсивно обходит дерево выражений до тех пор, пока не будет достигнута переменная. Если запрашивается производная по этой переменной, ее производная равна 1, в противном случае — 0. Затем вычисляются частичная функция и частная производная. [6]
кортеж < float , float > AssessmentAndDerive ( Expression Z , Variable V ) { if isVariable ( Z ) if ( Z = V ) return { valueOf ( Z ), 1 }; еще возврат { valueOf ( Z ), 0 }; иначе, если ( Z = A + B ) { a , a ' } = AssessmentAndDerive ( A , V ); { b , b ' } = AssessmentAndDerive ( B , V ); возврат { а + б , а ' + б ' }; иначе, если ( Z = A - B ) { a , a ' } = AssessmentAndDerive ( A , V ); { b , b ' } = AssessmentAndDerive ( B , V ); вернуть { а - б , а' - б ' } ; иначе, если ( Z = A * B ) { a , a ' } = AssessmentAndDerive ( A , V ); { b , b ' } = AssessmentAndDerive ( B , V ); return { a * b , b * a ' + a * b ' }; }
класс ValueAndPartial : def __init__ ( self , value , parts ): self . значение = значение себя . частичный = частичный def toList ( self ): return [ self . ценность , сам . частичный ] Выражение класса : def __add__ ( self , Other ): return Plus ( self , Other ) def __mul__ ( self , other ): return Multiply ( self , other ) Переменная класса ( Выражение ): def __init__ ( self , значение ): self . значение = значение def AssessmentAndDerive ( self , переменная ): частичный = 1 , если сам == переменная , иначе 0 return ValueAndPartial ( self . value , частичный )класс Plus ( Выражение ): def __init__ ( self , выражениеA , выражениеB ): self . выражениеA = выражениеA self . выражениеB = выражениеB def AssessmentAndDerive ( self , переменная ): valueA , partsA = self . выражение А. AssessmentAndDerive ( переменная ) . toList () valueB , частичныйB = self . выражениеБ . AssessmentAndDerive ( переменная ) . toList () возвращает ValueAndPartial ( значениеA + значениеB , частичноеA + частичноеB )класс Multiply ( Expression ): def __init__ ( self , выражениеA , выражениеB ): self . выражениеA = выражениеA self . выражениеB = выражениеB def AssessmentAndDerive ( self , переменная ): valueA , partsA = self . выражение А. AssessmentAndDerive ( переменная ) . toList () valueB , частичныйB = self . выражениеБ . AssessmentAndDerive ( переменная ) . toList () возвращает ValueAndPartial ( valueA * valueB , valueB * partsA + valueA * partsB )# Пример: нахождение частей z = x * (x + y) + y * y в (x, y) = (2, 3) x = Variable ( 2 ) y = Variable ( 3 ) z = x * ( x + y ) + y * y xPartial = z . оценитьAndDerive ( x ) . частичный yPartial = z . оценитьAndDerive ( y ) . частичная печать ( "∂z/∂x =" , xPartial ) # Вывод: ∂z/∂x = 7 print ( "∂z/∂y =" , yPartial ) # Вывод: ∂z/∂y = 8
#include <iostream> struct ValueAndPartial { значение с плавающей запятой , частичное ; }; структурная переменная ; struct Expression { virtual ValueAndPartial AssessmentAndDerive ( Переменная * переменная ) = 0 ; }; struct Variable : public Expression { значение с плавающей запятой ; Переменная ( значение с плавающей запятой ) : значение ( значение ) {} ValueAndPartial AssessmentAndDerive ( Переменная * переменная ) { float частичное = ( это == переменная ) ? 1,0ф : 0,0ф ; возврат { значение , частичное }; } }; struct Plus : public Expression { Expression * a , * b ; Плюс ( Выражение * a , Выражение * b ) : a ( a ), b ( b ) {} ValueAndPartial AssessmentAndDerive ( Переменная * переменная ) { auto [ valueA , PartialA ] = a -> AssessmentAndDerive ( переменная ); auto [ valueB , частичныйB ] = b -> AssessmentAndDerive ( переменная ); return { значениеA + значениеB , частичныйA + частичныйB }; } }; struct Multiply : public Expression { Expression * a , * b ; Умножить ( Выражение * a , Выражение * b ) : a ( a ), b ( b ) {} ValueAndPartial AssessmentAndDerive ( Переменная * переменная ) { auto [ valueA , PartialA ] = a -> AssessmentAndDerive ( переменная ); auto [ valueB , частичныйB ] = b -> AssessmentAndDerive ( переменная ); return { значениеA * значениеB , значениеB * частичное A + значениеA * частичноеB }; } }; int main () { // Пример: нахождение частей z = x * (x + y) + y * y at (x, y) = (2, 3) Переменная x ( 2 ), y ( 3 ); Плюс p1 ( & x , & y ); Умножьте m1 ( & x , & p1 ); Умножьте m2 ( & y , & y ); Плюс z ( & m1 , & m2 ); плавающий xPartial = z . оценитьAndDerive ( & x ). частичный ; плавать yPartial = z . оценитьAndDerive ( & y ). частичный ; std :: cout << "∂z/∂x = " << xPartial << ", " << "∂z/∂y = " << yPartial << std :: endl ; // Вывод: ∂z/∂x = 7, ∂z/∂y = 8 return 0 ; }
При обратном накоплении AD зависимая переменная , которую необходимо дифференцировать, фиксируется, а производная вычисляется рекурсивно относительно каждого подвыражения . При бумажном расчете производная внешних функций неоднократно подставляется в цепное правило:
При обратном накоплении процентная сумма представляет собой сопряженную величину , обозначенную чертой ; это производная выбранной зависимой переменной по отношению к подвыражению :
Используя цепное правило, если в вычислительном графе есть преемники:
Обратное накопление проходит по цепному правилу снаружи внутрь или, в случае вычислительного графа на рисунке 3, сверху вниз. Пример функции является скалярным, поэтому для вычисления производной используется только одно начальное значение, и для вычисления (двухкомпонентного) градиента требуется только один проход вычислительного графа. Это только половина работы по сравнению с прямым накоплением, но обратное накопление требует хранения промежуточных переменных w i , а также инструкций, которые их создали, в структуре данных, известной как «лента» или список Венгерта [7] ( однако Венгерт опубликовал прямое накопление, а не обратное накопление [3] ), что может занимать значительный объем памяти, если вычислительный граф большой. Это можно в некоторой степени смягчить, сохраняя только подмножество промежуточных переменных, а затем реконструируя необходимые рабочие переменные путем повторения оценок - метод, известный как рематериализация . Контрольные точки также используются для сохранения промежуточных состояний.
Операции по вычислению производной с использованием обратного накопления показаны в таблице ниже (обратите внимание на обратный порядок):
Графиком потока данных вычисления можно манипулировать для расчета градиента исходного расчета. Это делается путем добавления присоединенного узла для каждого основного узла, соединенного присоединенными ребрами, которые параллельны основным ребрам, но текут в противоположном направлении. Узлы сопряженного графа представляют собой умножение на производные функций, рассчитанных узлами простого графа. Например, сложение в первичном элементе приводит к разветвлению в присоединенном; разветвление в первичном вызывает сложение в сопряженном; [a] унарная функция y = f ( x ) в основных причинах x̄ = ş f ′ ( x ) в сопряженных; и т. д.
Обратное накопление требует двух проходов: при прямом проходе сначала вычисляется функция, а частичные результаты кэшируются. При обратном проходе вычисляются частные производные, а полученное ранее значение подвергается обратному распространению. Соответствующий вызов метода ожидает, что выражение Z будет производным и начальным значением будет производное значение родительского выражения. Для верхнего выражения Z, полученного относительно Z, это значение равно 1. Метод рекурсивно обходит дерево выражений до тех пор, пока не будет достигнута переменная, и добавляет текущее начальное значение к производному выражению. [8] [9]
void Derivative ( Выражение Z , семя с плавающей запятой ) { if isVariable ( Z ) partsialDerivativeOf ( Z ) += семя ; иначе , если ( Z = A + B ) получить ( A , семя ); получить ( B , семя ); иначе , если ( Z = A - B ) получить ( A , семя ); получить ( B , - семя ); иначе, если ( Z = A * B ) получить ( A , valueOf ( B ) * семя ); получить ( B , valueOf ( A ) * семя ); }
Выражение класса : def __add__ ( self , other ): return Plus ( self , other ) def __mul__ ( self , other ): return Multiply ( self , other ) Переменная класса ( Выражение ): def __init__ ( self , значение ): self . значение = значение себя . частичный = 0 def оценить ( self ): пройти def получить ( self , семя ): self . частичный += семякласс Plus ( Выражение ): def __init__ ( self , выражениеA , выражениеB ): self . выражениеA = выражениеA self . выражениеB = выражениеB self . значение = Нет def оценить ( self ): self . выражение А. оценить () себя . выражениеБ . оценить () себя . значение = я . выражение А. ценность + сам . выражениеБ . ценить def получить ( self , семя ): self . выражение А. получить ( семя ) себя . выражениеБ . вывести ( семя )класс Multiply ( Expression ): def __init__ ( self , выражениеA , выражениеB ): self . выражениеA = выражениеA self . выражениеB = выражениеB self . значение = Нет def оценить ( self ): self . выражение А. оценить () себя . выражениеБ . оценить () себя . значение = я . выражение А. значение * сам . выражениеБ . ценить def получить ( self , семя ): self . выражение А. получить ( self.выражениеB.значение * семя ) self . _ _ _ _ выражениеБ . получить ( self . выражениеA . значение * семя ) # Пример: нахождение частей z = x * (x + y) + y * y в (x, y) = (2, 3) x = Variable ( 2 ) y = Variable ( 3 ) z = x * ( x + y ) + y * y z . Assessment () print ( "z=" , z.value ) # Вывод: z = 19 z . получить ( 1 ) print ( "∂z/∂x =" , x . частичное ) # Вывод: ∂z/∂x = 7 print ( "∂z/∂y =" , y . частичное ) # Вывод: ∂z/ ∂у = 8
#include <iostream> struct Expression { значение с плавающей запятой ; виртуальная недействительная оценка () = 0 ; виртуальная пустота получения ( floatseed ) = 0 ; _ }; struct Variable : public Expression { float частичное ; Переменная ( float _value ) { value = _value ; частичный = 0 ; } void Assessment () {} void Derivate ( seed с плавающей запятой ) { частичное += семя ; } }; struct Plus : public Expression { Expression * a , * b ; Плюс ( Выражение * a , Выражение * b ) : a ( a ), b ( b ) {} void Assessment () { a -> Assessment (); б -> оценить (); значение = a -> значение + b -> значение ; } void Derivate ( floatseed ) { a -> Derivate ( seed ) ; б -> получить ( семя ); } }; struct Multiply : public Expression { Expression * a , * b ; Умножить ( Выражение * a , Выражение * b ) : a ( a ), b ( b ) {} void Assessment () { a -> Assessment (); б -> оценить (); значение = a -> значение * b -> значение ; } void Derivate ( floatseed ) { a - > Derivate ( b -> value * seed ); b -> получить ( a -> значение * семя ); } }; int main () { // Пример: нахождение частей z = x * (x + y) + y * y at (x, y) = (2, 3) Переменная x ( 2 ), y ( 3 ); Плюс p1 ( & x , & y ); Умножьте m1 ( & x , & p1 ); Умножьте m2 ( & y , & y ); Плюс z ( & m1 , & m2 ); з . оценивать (); std :: cout << "z = " << z . значение << std :: endl ; // Вывод: z = 19 z . вывести ( 1 ); std :: cout << "∂z/∂x = " << x . частичный << ", " << "∂z/∂y = " << y . частичный << std :: endl ; // Вывод: ∂z/∂x = 7, ∂z/∂y = 8 return 0 ; }
Прямое и обратное накопление — это всего лишь два (крайних) способа обойти правило цепочки. Задача вычисления полного якобиана f : Rn → Rm с минимальным количеством арифметических операций известна как задача оптимального накопления якобиана ( OJA), которая является NP-полной . [10] Центральное место в этом доказательстве занимает идея о том, что алгебраические зависимости могут существовать между локальными партиалами, обозначающими ребра графа. В частности, две или более метки ребер могут быть признаны равными. Сложность проблемы остается открытой, если предположить, что все метки ребер уникальны и алгебраически независимы.
Автоматическое дифференцирование в прямом режиме осуществляется путем расширения алгебры действительных чисел и получения новой арифметики . К каждому числу добавляется дополнительный компонент, представляющий производную функции от этого числа, а все арифметические операторы расширяются для расширенной алгебры. Расширенная алгебра — это алгебра двойственных чисел .
Замените каждое число числом , где — действительное число, но является абстрактным числом со свойством ( бесконечно малым ; см. Плавный бесконечно малый анализ ). Используя только это, обычная арифметика дает
Теперь полиномы можно вычислять с помощью этой расширенной арифметики. Если , то
Новая арифметика состоит из упорядоченных пар элементов, записанных с обычной арифметикой для первого компонента и арифметикой дифференцирования первого порядка для второго компонента, как описано выше. Распространение приведенных выше результатов о полиномах на аналитические функции дает список базовой арифметики и некоторых стандартных функций для новой арифметики:
Когда базовая двоичная арифметическая операция применяется к смешанным аргументам — паре и действительному числу — действительное число сначала увеличивается до . Производная функции в точке теперь находится путем вычисления с использованием приведенной выше арифметики, что дает результат.
Ниже приведен пример реализации, основанной на подходе двойного числа.
Двойной плюс (Двойной А, Двойной Б) { возвращаться { реальнаяЧасть(A) + реальнаяЧасть(B), бесконечно малаяЧасть(A) + бесконечно малаяЧасть(B) };}Двойной минус (Двойной А, Двойной Б) { возвращаться { реальнаяЧасть(А) - реальнаяЧасть(В), бесконечно малая часть (A) - бесконечно малая часть (B) };}Двойное умножение (Двойной A, Двойной B) { возвращаться { реальнаяЧасть(A) * реальнаяЧасть(B), реальнаяЧасть(B) * бесконечно малаяЧасть(A) + реальнаяЧасть(A) * бесконечно малаяЧасть(B) };}Х = {х, 0};Y = {у, 0};Эпсилон = {0, 1};xPartial = бесконечно малаяЧастьOf(f(X + Эпсилон, Y));yPartial = бесконечно малаяЧастьOf(f(X, Y + Эпсилон));
class Dual : def __init__ ( self , RealPart , InfinitsimalPart = 0 ): self . RealPart = RealPart self . бесконечно малая часть = бесконечно малая часть def __add__ ( self , other ) : return Dual ( self.realPart + other.realPart , self.infinitesimalPart + other.infinitesimalPart ) _ _ _ _ _ _ _ def __mul__ ( self , other ) : return Dual ( self.realPart * other.realPart , other.realPart * self.infinitesimalPart + self.realPart * other.infinitesimalPart ) _ _ _ _ _ _ _ _ _ _ _ # Пример: нахождение частей z = x * (x + y) + y * y at (x, y) = (2, 3) def f ( x , y ): return x * ( x + y ) + y * y x = Двойной ( 2 ) y = Двойной ( 3 ) эпсилон = Двойной ( 0 , 1 ) a = f ( x + эпсилон , y ) b = f ( x , y + эпсилон ) print ( "∂z/∂x =" , a . InfinitesimalPart ) # Вывод: ∂z/∂x = 7 print ( "∂z/∂y =" , b . InfinitesimalPart ) # Вывод: ∂z/∂y = 8
#include <iostream> struct Dual { float RealPart , InfinitesimalPart ; Dual ( float realPart , float InfinitesimalPart = 0 ) : RealPart ( realPart ), InfinitesimalPart ( InfinitesimalPart ) {} Двойной оператор + ( Dual Other ) { return Dual ( realPart + Other . RealPart , InfinitesimalPart + Other . InfinitsimalPart ); } Двойной оператор * ( Двойной другой ) { return Dual ( realPart * Other . RealPart , Other . RealPart * InfinitesimalPart + RealPart * Other . InfinitesimalPart ); } }; // Пример: нахождение частей z = x * (x + y) + y * y в (x, y) = (2, 3) Dual f ( Dual x , Dual y ) { return x * ( x + y ) + y * y ; } int main () { Dual x = Dual ( 2 ); Двойной у = Двойной ( 3 ); Двойной эпсилон = Двойной ( 0 , 1 ); Двойной a = f ( x + эпсилон , y ); Двойной b = f ( x , y + эпсилон ); std :: cout << "∂z/∂x = " << a . бесконечно малая часть << ", " << "∂z/∂y = " << b . бесконечно малая часть << станд :: конец ; // Вывод: ∂z/∂x = 7, ∂z/∂y = 8 return 0 ; }
Многомерные функции можно обрабатывать с той же эффективностью и механизмами, что и одномерные функции, используя оператор производной по направлению. То есть, если достаточно вычислить , производная от at в направлении может быть рассчитана с использованием той же арифметики, что и выше. Если все элементы желательны, то требуются оценки функций. Обратите внимание, что во многих приложениях оптимизации производная по направлению действительно достаточна.
Приведенную выше арифметику можно обобщить для расчета производных второго порядка и более высоких функций многомерных функций. Однако арифметические правила быстро усложняются: сложность квадратична в высшей степени производной. Вместо этого можно использовать усеченную алгебру полиномов Тейлора . Получающаяся в результате арифметика, определенная на обобщенных двойственных числах, позволяет эффективно выполнять вычисления с использованием функций, как если бы они были типом данных. Если известен полином Тейлора функции, производные легко извлекаются.
AD в прямом режиме реализуется посредством нестандартной интерпретации программы, в которой действительные числа заменяются двойными числами, константы преобразуются в двойные числа с нулевым эпсилон-коэффициентом, а числовые примитивы повышаются для работы с двойственными числами. Эта нестандартная интерпретация обычно реализуется с использованием одной из двух стратегий: преобразования исходного кода или перегрузки операторов .
Исходный код функции заменяется автоматически сгенерированным исходным кодом, который включает операторы для вычисления производных, чередующиеся с исходными инструкциями.
Преобразование исходного кода может быть реализовано для всех языков программирования, и компилятору также легче выполнять оптимизацию времени компиляции. Однако реализация самого инструмента AD более сложна, а система сборки более сложна.
Перегрузка операторов возможна для исходного кода, написанного на поддерживающем ее языке. Объекты для действительных чисел и элементарных математических операций должны быть перегружены, чтобы обеспечить расширенную арифметику, описанную выше. Это не требует изменения формы или последовательности операций в исходном исходном коде для дифференциации функции, но часто требует изменений в базовых типах данных для чисел и векторов для поддержки перегрузки и часто также включает вставку специальных операций пометки. Из-за присущих каждому циклу накладных расходов на перегрузку операторов этот подход обычно демонстрирует более низкую производительность по скорости.
Перегруженные операторы можно использовать для извлечения графика оценки с последующим автоматическим созданием AD-версии основной функции во время выполнения. В отличие от классического ОО ААД, такая AD-функция не меняется от одной итерации к следующей. Следовательно, для каждой выборки Xi возникают какие-либо накладные расходы во время выполнения объектно-ориентированной интерпретации или интерпретации ленты.
Поскольку AD-функция генерируется во время выполнения, ее можно оптимизировать, чтобы учесть текущее состояние программы и предварительно вычислить определенные значения. Кроме того, его можно сгенерировать таким образом, чтобы последовательно использовать встроенную векторизацию ЦП для обработки 4(8)-двойных фрагментов пользовательских данных (ускорение AVX2\AVX512 x4-x8). С учетом многопоточности такой подход может привести к окончательному ускорению порядка 8 × #Cores по сравнению с традиционными инструментами AAD. Эталонная реализация доступна на GitHub. [11]
{{cite book}}
: CS1 maint: multiple names: authors list (link){{cite book}}
: CS1 maint: multiple names: authors list (link){{cite journal}}
: CS1 maint: multiple names: authors list (link)