Авторы: Абхишек Дешпанде, Бхавья Санкхла, Аюш Шарма, Ритурадж Оджха.

Этот блог создается и поддерживается студентами программы магистра наук в области профессиональных компьютерных наук в Университете Саймона Фрейзера в рамках их курса обучения. Чтобы узнать больше об этой уникальной программе, посетите {sfu.ca/computing/mpcs}.

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

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

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

Это руководство представляет собой пошаговое руководство по частям популярного репозитория micrograd Андрея Карпаты (подход и код), который реализует крошечный скалярный движок autograd и библиотеку нейронной сети поверх него с API, похожий на PyTorch.

Обзор

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

Мы знаем, что нейронные сети — это просто ориентированные ациклические графы с множеством базовых операций умножения и накопления. Рассмотрим следующий надуманный пример, который показывает ряд возможных поддерживаемых операций:

a = Value(-4.0)
b = Value(2.0)
c = a + b
d = b * c
c += 1 + c + (-a)
d += d * 2 + (b + a).tanh()
g = c - d
print(f'{g.data:.4f}') # prints 13.9640, the outcome of this forward pass
g.backward()
print(f'{a.grad:.4f}') # prints -5.0707, i.e. the numerical value of dg/da
print(f'{b.grad:.4f}') # prints 1.9293, i.e. the numerical value of dg/db

Функциональность

В приведенном выше примере строится математическое выражение, где a и b преобразуются в c, а затем, в конце концов, в d и g. Скалярные значения (хранящиеся в a и b) заключены в объект Value. Вся функциональность движка и операции, которые он поддерживает (которые мы создадим с нуля), зависят от класса Value. Если у вас есть два объекта Value, вы можете складывать их, умножать, возводить в постоянную степень, применять функции активации, делить на константы и т. д. Таким образом, используя объекты Value, мы строим граф выражений с входными данными (a и b ) для создания выходного значения (g). Наш механизм (который, по сути, является классом Value) в фоновом режиме будет строить это математическое выражение, отслеживая дочерние узлы (такие как a и b родительского узла c) каждой вовлеченной операции (операция «сложение», примененная к и b для создания c) путем сохранения указателей на дочерние объекты Value.

Использование

Получив граф выражений описанным выше способом, мы можем получить информацию о разных узлах, используя разные атрибуты узла. Мы можем не только выполнить прямой проход и посмотреть значение g (доступ к которому можно получить, проверив g.data), но мы также можем вызвать g.backward() (как мы делаем в PyTorch), чтобы инициализировать обратное распространение в узле g и автоматически вычислить градиенты всех узлов. Это запустит обратный путь по графу выражений и рекурсивно применит цепное правило из исчисления, которое будет оценивать производную g по отношению ко всем внутренним узлам. Чтобы запросить производную g относительно любого узла, нам просто нужно вызвать ‹node›.grad. Эти производные предоставляют наиболее важную информацию, поскольку они говорят нам, как данный узел (скажем, a) влияет на выход (g) посредством этого математического выражения.

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

ПРИМЕЧАНИЕ. Создание скалярного движка autograd является излишним, поскольку в производственной среде вы всегда будете иметь дело с n-мерными тензорами. Это руководство построено так, чтобы вы могли понять и реорганизовать обратное распространение и правило цепочки, чтобы понять обучение нейронной сети. Базовая математика и фундаментальная логика в случае реализации с использованием тензоров высокой размерности остаются прежними; любые сделанные изменения (чтобы воспользоваться преимуществами параллелизма/ускорения работы) сделаны исключительно из соображений эффективности.

Хорошо! Итак, теперь, когда у нас есть четкое представление о том, с чем мы имеем дело, давайте погрузимся и реализуем все шаг за шагом.

Понимание производных

Мы знаем, что производная функции f(x) определяется выражением:

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

h = 0.0001

#inputs
a = 3.0
b = -2.0
c = 12.0

output1 = a * b + c
c+= h
output2 = a * b + c

print(f'output1 : {output1}') #prints output1: 6.0
print(f'output2 : {output2}') #prints output2: 6.0001
print(f'slope : {(output2 - output1)/h}') #prints slope : 0.9999999999976694

В приведенном выше примере мы подталкиваем один из входных параметров c и пересчитываем выходные данные функции. Затем мы вычисляем наклон (нормализованный рост по сравнению с пробегом), который выводит положительное значение 0,99. Мы можем интуитивно понять этот положительный наклон, взглянув на уравнение, поскольку c является положительным значением и является «дочерним узлом» оператора +. Таким образом, небольшое увеличение значения c немного увеличит общее значение функции. Та же интуиция может быть применена к рассуждениям с помощью вычисленных наклонов в случае увеличения других входных данных (и учета их знаков).

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

Объект значения

Начнем со скелетного класса Value:

class Value:
    
    def __init__(self, data):
        self.data = data
    
    def __repr__(self):
        return f"Value(data={self.data})"

a = Value(2.0)
print(a) #prints Value(data=2.0)

Таким образом, простой объект Value принимает одно скалярное значение, которое он упаковывает и отслеживает. Метод __init__ хранит скалярные данные. Python внутренне вызовет функцию __repr__, чтобы вернуть сохраненное значение в виде строки.

Скажем, мы хотим добавить два объекта Value. Обновим наш класс:

class Value:
    
    def __init__(self, data):
        self.data = data
    
    def __repr__(self):
        return f"Value(data={self.data})"

    def __add__(self, other):
        out = Value(self.data + other.data)
        return out

a = Value(3.0)
b = Value(-1.0)
print(a + b) #prints Value(data=2.0)

Если мы используем обычный оператор +, Python внутренне вызовет a.__add__(b). Вместо этого мы сделали возврат нового объекта Value, который просто обертывает добавление значений данных a и b.

Давайте также добавим умножение:

class Value:
    
    def __init__(self, data):
        self.data = data
    
    def __repr__(self):
        return f"Value(data={self.data})"

    def __add__(self, other):
        out = Value(self.data + other.data)
        return out

    def __mul__(self, other):
        out = Value(self.data * other.data)
        return out

a = Value(3.0)
b = Value(-2.0)
c = Value(12.0)
d = a*b + c

print(d) #prints Value(data=6.0)

Мы можем вычислять простые выражения, используя наш класс значений. Однако нам все еще не хватает «соединительной ткани» выражения — указателей, которые отслеживают, какие значения производят какие значения. Давайте добавим логику в наш класс Value для того же, введя новые переменные «_children» и «_prev»:

class Value:
    
    def __init__(self, data, _children=()): #_children is an empty tuple by default
        self.data = data
        self.prev = set(_children) #empty set by default
    
    def __repr__(self):
        return f"Value(data={self.data})"

    def __add__(self, other):
        out = Value(self.data + other.data, (self, other)) #add children tuple
        return out

    def __mul__(self, other):
        out = Value(self.data * other.data, (self, other)) #add children tuple
        return out

a = Value(3.0)
b = Value(-2.0)
c = Value(12.0)
d = a*b + c

print(d) #prints Value(data=6.0)
print(d._prev) #prints {Value(data=12.0), Value(data=-6.0)}

Вызов d._prev печатает значения дочерних узлов. Теперь мы знаем дочерние элементы каждого отдельного значения, но последняя недостающая часть информации — это то, какая операция создала это значение. Итак, мы добавляем еще один элемент в наш класс Value с именем «_op»:

class Value:
    
    def __init__(self, data, _children=(), _op=''): #_op is an empty string by default
        self.data = data
        self.prev = set(_children) #empty set by default
        self._op = op
        self.label = label #node labels
    
    def __repr__(self):
        return f"Value(data={self.data})"

    def __add__(self, other):
        out = Value(self.data + other.data, (self, other), '+') #add operator string 
        return out

    def __mul__(self, other):
        out = Value(self.data * other.data, (self, other), '*') #add operator string
        return out

a = Value(3.0)
b = Value(-2.0)
c = Value(12.0)
d = a*b + c

print(d) #prints Value(data=6.0)
print(d._prev) #prints {Value(data=12.0), Value(data=-6.0)}
print(d._op) #prints +

Теперь мы можем вычислить выражение, используя наш класс Value, отслеживая при этом все, что хотим.

Визуализация

Поскольку эти выражения скоро станут довольно большими, нам нужен инструмент для визуализации графика (аналогичный визуализации, представленной в разделе обзора ранее), который мы отслеживаем. Следующий фрагмент кода (взято из micrograd/trace_graph.ipynb на master · karpathy/micrograd · GitHub) поможет нам красиво визуализировать выражения, которые мы строим с помощью graphviz:

from graphviz import Digraph

def trace(root):
    # builds a set of all nodes and edges in a graph
    nodes,edges = set(), set()
    def build(v):
        if v not in nodes:
            nodes.add(v)
            for child in v._prev:
                edges.add((child,v))
                build(child)
    build(root)
    return nodes, edges
            
def draw_dot(root):
    dot = Digraph(format='svg', graph_attr={'rankdir': 'LR'}) #LR = left to right 
    
    nodes, edges = trace(root)
    for n in nodes:
        uid = str(id(n))
        #for any value in the graph, create a rectangular ('record') node for it
        dot.node(name=uid, label = "{data %.4f }" % (n.data, ), shape='record')
        if n._op:
            dot.node(name = uid + n._op, label = n._op)
            #and connect this node to it
            dot.edge(uid + n._op, uid)

    for n1, n2 in edges:
        #connect n1 to the op node of n2
        dot.edge(str(id(n1)), str(id(n2)) + n2._op)

    return dot

Вызов функции draw_dot на выходном узле (d) поможет нам визуализировать наше предыдущее выражение:

draw_dot(d)

Наш пас вперед выглядит хорошо. Теперь, чтобы инициализировать обратное распространение, нам нужен способ хранения наших градиентов по мере движения по графику выражений. Для этого мы добавим еще одну переменную/элемент в наш класс Value под названием «grad»:

class Value:
    
    def __init__(self, data, _children=(), _op=''): 
        self.data = data
        self.grad = 0.0 #initialize to zero
        self.prev = set(_children) 
        self._op = op
    
    def __repr__(self):
        return f"Value(data={self.data})"

    def __add__(self, other):
        out = Value(self.data + other.data, (self, other), '+') 
        return out

    def __mul__(self, other):
        out = Value(self.data * other.data, (self, other), '*') 
        return out

a = Value(3.0)
b = Value(-2.0)
c = Value(12.0)
d = a*b + c

print(d) #prints Value(data=6.0)
print(d._prev) #prints {Value(data=12.0), Value(data=-6.0)}
print(d._op) #prints +

Мы инициализируем self.grad значением 0.0, так как предполагаем, что изначально никакое значение не влияет на выходные данные. Мы также можем обновить наше определение метки в функции визуализации draw_dot, чтобы включить градиенты (и метки узлов):

def draw_dot(root, format='svg', rankdir='LR'):
    assert rankdir in ['LR', 'TB']
    nodes, edges = trace(root)
    dot = Digraph(format=format, graph_attr={'rankdir': rankdir}) #, node_attr={'rankdir': 'TB'})
    
    for n in nodes:
        #modified label definition
        dot.node(name=str(id(n)), label = "{ %s | data %.4f | grad %.4f }" % (n.label,           n.data, n.grad), shape='record')
        if n._op:
            dot.node(name=str(id(n)) + n._op, label=n._op)
            dot.edge(str(id(n)) + n._op, str(id(n)))
    
    for n1, n2 in edges:
        dot.edge(str(id(n1)), str(id(n2)) + n2._op)
    
    return dot

Обратное распространение

Рассмотрим следующий график (полученный из предыдущего выражения и значений, с которыми мы работали, но с добавленным отслеживанием градиента):

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

Дополнение

Выражение для приведенного выше графика: e = c+d. Из вычислений мы знаем, что de/dc (и de/dd) равно 1:

Умножение

Выражение для приведенного выше графика: c= a*b. Из исчисления мы знаем, что dc/dbявляется a (a.data в нашем случае).Аналогично, dc/ da равно b (или b.data).

Инициализация

В качестве базового случая мы инициализируем градиент (или node.grad) конечного выходного узла (в нашем случае e) равным 1 (поскольку производная переменной по самой себе равна 1).

Предположим, что градиент e, хранящийся на приведенном выше графике, равен 1. Таким образом, de/dc (или c.grad) станет равным локальной производной (de/dc=1) умножить на производную, отслеживаемую от конечного узла (в нашем случае e.grad, который мы принимаем равным 1). Итак, наш график теперь выглядит следующим образом:

Следующий шаг — вернуться назад и вычислить de/dc и de/dd. Мы замечаем, что сталкиваемся с оператором +, который (из нашего объяснения базового узла выше) говорит нам, что градиент для узлов c и d будет равен 1. Говоря более формально: оператор сложения направляет входящий градиент назад (поскольку входящий градиент от выходного узла просто умножается на 1). Итак, мы можем обновить c.grad и d.grad до 1 и обновить наш график:

Теперь мы идем дальше назад от нашего последнего посещенного узла, чтобы вычислить de/da и de/dб. Мы сталкиваемся с оператором умножения. Хотя он напрямую не связан с выходным узлом e (а через узел c), мы знаем (из нашего предыдущего объяснения базового случая умножения), что c оказывает локальное влияние на a и b. Ранее мы знали, что a.grad (или dc/da) будет равен b.data и, аналогично, b.grad будет равен a.data (оператор умножения направляет данные другого входного узла как градиент для первого входного узла). Это даст нам локальное влияние a и b. Чтобы выяснить влияние a и b на общий результат, мы просто умножаем (следуя цепному правилу) локальную производную (или «влияние») на общую производную, которая отслеживается на выходе, хранящемся в c.grad. Итак, a.grad = b.data * c.grad = -2 * 1 = -2. Точно так же b.grad становится a.data * c.grad = 3 * 1 = 1. Теперь у нас есть все наши градиенты, и наш обновленный график выглядит следующим образом:

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

Начнем с определения функции активации tanh в нашем классе Value:

def tanh(self):
    x = self.data
    t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
    out = Value(t, (self, ), 'tanh')

Здесь стоит отметить одну вещь: мы решили напрямую определить нашу функцию tanh, используя ее выражение, а не строить ее, используя существующие (+ и *) и дополнительные (возведение в степень и деление) операторы. Это сделано намеренно, чтобы показать, что, хотя мы можем создавать функции, используя операторы класса Value в качестве строительных блоков, нам не обязательно использовать самые «атомарные» части, и мы действительно можем создавать функции в произвольных точках абстракции. Это могут быть относительно сложные функции (например, tanh) или очень простые функции, такие как оператор +, и это полностью зависит от нас (и наших требований к оптимизации/эффективности). Единственное, что имеет значение, это то, что мы знаем, как дифференцировать определенную функцию (т. е. знаем, как создать локальную производную / как входные данные влияют на выходные данные определенной функции).

Давайте также изменим наше математическое выражение, чтобы оно напоминало нейрон. Строим его пошагово, чтобы у нас были указатели на все промежуточные узлы:

# inputs x1,x2
x1 = Value(2.0, label='x1')
x2 = Value(0.0, label='x2')
# weights w1,w2 (synaptic strengths)
w1 = Value(-3.0, label='w1')
w2 = Value(1.0, label='w2')
# bias
b = Value(7.0, label='b')
# x1*w1 + x2*w2 + b
x1w1 = x1 * w1; x1w1.label='x1*w1'
x2w2 = x2 * w2; x2w2.label='x2*w2'
x1w1x2w2 = x1w1 + x2w2; x1w1x2w2.label = 'x1*w1 + x2*w2'
n = x1w1x2w2 + b; n.label = 'n'
o = n.tanh()

draw_dot(o)

Теперь, чтобы автоматизировать нашу логику обратного распространения, мы добавляем функцию (_backward) к каждому определению оператора в классе Value, которая определяет, как мы связываем выходной градиент с входным градиентом. Начнем с функции сложения:

class Value:
    
    def __init__(self, data, _children=(), _op='', label=''):
        self.data = data
        self.grad = 0.0 #at initialization, op is not affected by Value object
        self._prev = set(_children)
        self._op = _op
        self.label = label
        self._backward = lambda: None # None for leaf nodes in the graph
        
    def __repr__(self):
        return f"Value(data={self.data})"
    
    def __add__(self, other):
        out = Value(self.data + other.data, (self, other), '+')
        def _backward(): # defining backward logic for addition
            self.grad += 1.0 * out.grad
            other.grad += 1.0 * out.grad
        out._backward = _backward
        return out

Мы инициализируем _backward как None, указывая, что изначально он ничего не делает. Это также делается для обработки случая, когда узел является конечным узлом (поскольку у него нет входных данных, которые необходимо привязать к выходному градиенту). Вышеприведенная функция _backward() определяет логику маршрутизации для оператора +, который мы обсуждали ранее. Мы вызываем out._backward (аналогично функции PyTorch reverse()), чтобы распространять градиент по мере того, как мы перемещаемся назад по графику. Давайте определим def _backward() для оставшихся операторов и функций активации и завершим наш класс Value:

class Value:
    
    def __init__(self, data, _children=(), _op='', label=''):
        self.data = data
        self.grad = 0.0 #at initializtion, op is not affected by Value object
        self._backward = lambda: None # None for leaf nodes in the graph
        self._prev = set(_children)
        self._op = _op
        self.label = label
        
    def __repr__(self):
        return f"Value(data={self.data})"
    
    def __add__(self, other):
        out = Value(self.data + other.data, (self, other), '+')
        def _backward():
            self.grad += 1.0 * out.grad
            other.grad += 1.0 * out.grad
        out._backward = _backward
        return out
    
    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), '*')
        def _backward():
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad
        out._backward = _backward
        return out

    def tanh(self):
        x = self.data
        t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
        out = Value(t, (self, ), 'tanh')
        
        def _backward():
            self.grad += (1 - t**2) * out.grad
        out._backward = _backward
        return out

Все, что нам нужно сделать сейчас, это вызвать .backward() (на нелистовых узлах), и все наши градиенты будут вычислены автоматически!

o._backward()
n._backward()
x1w1x2w2._backward()
x2w2._backward()
x1w1._backward()

draw_dot(o)

Вызов _backward() на каждом нелистовом узле по-прежнему довольно утомителен, поэтому давайте сделаем одно последнее дополнение к нашему классу Value, после чего один обратный вызов на выходном узле (например, PyTorch) — это все, что нам нужно для автоматического расчета всех градиентов в узле. график. Мы добавляем топологическую сортировку в наш класс Value для обратного распространения по всему графу выражений:

def backward(self):
    topo = []
    visited = set()
    def build_topo(v):
        if v not in visited:
            visited.add(v)
            for child in v._prev:
                build_topo(child)
            topo.append(v)
    build_topo(self)
    #print(topo)

    self.grad = 1.0  #base-case
    for node in reversed(topo):
        node._backward()

Используя этот метод сортировки, мы начинаем с O и посещаем узлы в топологическом порядке. O добавит себя в топографический список (в приведенном выше коде) только после того, как все дочерние элементы будут обработаны.

Собираем все вместе!

  • Окончательная версия класса Value (совет: ознакомьтесь с комментариями в следующем коде, которые исправляют небольшие ошибки для более надежной реализации):
class Value:
    
    def __init__(self, data, _children=(), _op='', label=''):
        self.data = data
        self.grad = 0.0 #at initializtion, op is not affected by Value object
        self._backward = lambda: None # None for leaf nodes in the graph
        self._prev = set(_children)
        self._op = _op
        self.label = label

        
    def __repr__(self):
        return f"Value(data={self.data})"
    

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        """
        ^ to work with non-value objects by wrapping them around Value and thereby providing them a data attribute
        eg: to perform a + 1 (where 1 is not a Value object)
        """
        out = Value(self.data + other.data, (self, other), '+')
        def _backward():
            self.grad += 1.0 * out.grad
            """
            using += instead of + to accumulate gradients instead of overwriting (in the case of adding a node to itself)
            """
            other.grad += 1.0 * out.grad
        out._backward = _backward
        return out
   
 
    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), '*')
        def _backward():
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad
        out._backward = _backward
        return out

  
    def __neg__(self): # -self
        return self * -1


    def __radd__(self, other): # other + self
        return self + other


    def __sub__(self, other): # self - other
        return self + (-other)


    def __rsub__(self, other): # other - self
        return other + (-self)


    def __rmul__(self, other): # other * self
        return self * other
    

    def tanh(self):
        x = self.data
        t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
        out = Value(t, (self, ), 'tanh')
        
        def _backward():
            self.grad += (1 - t**2) * out.grad
        out._backward = _backward
        return out
    

    def backward(self):
        topo = []
        visited = set()
        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build_topo(child)
                topo.append(v)
        build_topo(self)
        print(topo)
        self.grad = 1.0
        for node in reversed(topo):
            node._backward()
  • Окончательный вариант прямого и обратного прохода:
#Forward Pass
x1 = Value(2.0, label='x1')
x2 = Value(0.0, label='x2')
w1 = Value(-3.0, label='w1')
w2 = Value(1.0, label='w2')
b = Value(7, label='b')
x1w1 = x1 * w1; x1w1.label='x1*w1'
x2w2 = x2 * w2; x2w2.label='x2*w2'
x1w1x2w2 = x1w1 + x2w2; x1w1x2w2.label = 'x1*w1 + x2*w2'
n = x1w1x2w2 + b; n.label = 'n'
o = n.tanh(); o.label = 'o'

#Backward Pass
o.backward()

#Graph
draw_dot(o)

Подведение итогов

Вот и все! Это должно предоставить вам достаточный фон и шаблонный код, чтобы:

  • Расширьте возможности своей реализации (добавив больше операторов, функций активации и сложных математических выражений).
  • Изучите репозиторий Karpathy micrograd и изучите реализацию его библиотеки нейронных сетей, которая создает векторизованные абстракции для нейронов, слоев и MLP.
  • Завершите процесс обучения нейронной сети, углубившись в понимание и реализацию кода micrograd для расчета потерь и оптимизации градиентного спуска.

Счастливого обучения :)

Ссылки

  1. GitHub — karpathy/micrograd: крошечный скалярный движок автограда и нейросетевая библиотека поверх него с PyTorch-подобным API