Что может быть лучше для понимания чего-либо, чем (пере) изобрести / открыть это? Разберитесь с базовой интуицией и математикой, лежащими в основе нейронных сетей и обучения на основе ошибок, градиентов и обратного распространения ошибки, «по-хакерски»!

Это первая часть серии руководств, состоящей из 4 частей, в которую входят:

  • P1 - Нейронные сети - от био-вдохновения к рабочему обучению с обратным распространением [THIS]
  • P2 - Простое исчисление для вывода уравнений обратного распространения ошибки [coming soon…]
  • P3 - Обратное распространение: матричная форма и более эффективная реализация на Python [coming soon…]
  • P4 - Реализация пригодной для использования глубокой нейронной сети (с нуля, на Python) [coming later…]

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

P1 - Нейронные сети - от био-вдохновения к рабочему обучению с обратным распространением

Искусственные нейронные сети (ИНС, или обычно просто называемые НС) - это системы машинного обучения, которые, по мнению некоторых, вдохновлены биологией. Наверное, это было больше похоже на:

«Давайте быстро взглянем на те мягкие нейро-штучки, в которые выказывают биологические ботаники, и попробуем угадать, на какой простейшей математике они могли бы основываться… Затем выберите эту область математики, например, подобные уравнения и тому подобное, в качестве отправной точки. для нашего ИИ вещи. Потому что математика, знаете ли, чудовищна. ... и поскольку мы не очень хорошо понимаем, что делаем, если мы просто случайно выберем какую-нибудь математику для построения наших моделей, это, скорее всего, будет неправильным. О, и лучше будет проще. "Потому что ... на самом деле мы не так уж хороши в математике".

(воображаемая цитата после работы первых пионеров искусственного интеллекта, исследующих ИНС)

Природа и математика, мышление в дереве…

…neu-ral-net-work-i-n-g…

Представим себе действительно простой организм. Например, простейший организм, который вы можете себе представить, может иметь какой-то интеллект, хотя бы немного похожий на интеллект человека. Это должен быть какой-то многоклеточный организм (интеллект на одноклеточном уровне будет слишком отличаться от того, что мы ищем). Он должен уметь ощущать что-то в окружающей среде, может быть, что-то вроде концентрации «молекул пищи» по разным сторонам своего тела, а также уметь что-то делать, например двигаться. Итак, что-нибудь с датчиками, исполнительными механизмами и чем-то, способным выполнять некоторые базовые вычисления.

Допустим, у нас есть какой-то "червяк", перемещающийся в среде с различной концентрацией необходимой ему пищи (градиент концентрации пищи). В разных частях его тела размещены датчики, способные определять концентрацию пищи. Если он движется в правильном направлении, он может попасть в районы с большим количеством еды и иметь больше шансов на выживание и размножение. Мы пока не будем обращать внимание на то, «как он на самом деле движется», и сосредоточимся на «ощущении и решении, в каком направлении двигаться».

Картинка стоит тысячи червей, так что поехали:

(зеленые «пятнышки» обозначают «частицы пищи», и вы можете видеть, что здесь их концентрация выше вверху и ниже внизу)

А теперь давайте выясним, каким правилам этот червь может захотеть следовать, чтобы получить больше еды. Чтобы добраться до них, давайте представим некоторые возможные действия, из которых он может выбирать. Он мог бы «выбрать»:

  • изменить скорость:
    -
    двигаться вперед
    - не двигаться
  • изменить направление:
    -
    повернуть направо
    - повернуть налево
    - не поворачивать

Простая стратегия может звучать так: если сенсоры обнаруживают более высокую концентрацию пищи внутри (F), чем сзади (B), двигайтесь вперед, иначе не двигайтесь.

Чуть более сложный (возможно, не лучший, но лучше) мог бы выглядеть примерно так:

  • «Если впереди (F) концентрация пищи выше, чем сзади (B), и если разница между концентрацией пищи впереди (F) и концентрацией пищи позади (B) больше, чем в два раза, то разница между концентрацией пищи также слева (L) и ... вправо (R), двигаться вперед »- одним из способов выразить это в математических терминах было бы что-л. просто, например:
    if F — B − 2|R − L| > 0, then move forward (здесь |…| означает «абсолютное значение», то есть «забудьте знак, возьмите только значение / величину»)
  • «Если справа больше еды, чем слева, поверните направо»:
    if R − L > 0, then turn right
  • «Если слева есть еще еда, поверните налево»:
    if L − R > 0, then turn left

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

Но вот причина, по которой мы выбрали именно эти способы выражения таких правил: есть веские основания полагать, что даже самые простые системы, состоящие из связки взаимодействующих ячеек, могут вычислять такие вещи, как: (1) сложение - ячейка может быть затронута по сумме стимулов, которые она получает от клеток, каким-то образом связанных с ней, (2) умножение - клетка может каким-то образом стать более или менее чувствительной к входу, который она получает от другого (например, умножение его на фактор, даже отрицательный, чтобы изменить влияние) и (3) выяснить, превышает ли сумма его входов определенный порог, и отправить сигнал, если это так. Теперь давайте также добавим (4) принятие абсолютного значения чего-либо к смеси, поскольку это можно рассматривать, возможно, как некоторую клетку, принимающую все стимулы, которые она получает, как положительные. Мы можем смело предположить все это, потому что биологи знают, что даже простейшая клетка способна на waaaaay больше вычислений, чем описано выше. На самом деле даже отдельная ячейка (возможно, даже ее отдельные части) - это то, что мы называем полной по Тьюрингу или вычислительно универсальной, поэтому все вышеперечисленные вычисления могут выполняться даже в одной ячейке.

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

Давайте попробуем подумать о приведенных выше уравнениях как о каких-то сетях. Как схема, поскольку это будет и инженер-электрик придумывает это. А математические операторы и любые физические объекты, которые их реализуют, будут, очевидно, называться вентилем! Потому что именно так инженеры-электрики называют вещи в схемах, которые вычисляют вещи. В цифровой электрической схеме (или микрочипе, это 2019 ffs) «вентиль» будет чем-то вроде логического элемента ИЛИ, логического элемента И, логического элемента И-НЕ и т. Д., Который будет принимать двоичные входы и выводить результаты операции, которую они названы в честь. Поскольку нет причин (или желания) полагать (на данный момент), что такие схемы будут цифровыми, мы предположим, что это не так, поэтому у нас будут аналоговые схемы с проводами, по которым передаются сигналы с произвольными действительными значениями . Схемы реальных значений! Ура! Давайте посмотрим, как они могли бы выглядеть, если предположить, что они будут подключаться к некоторым исполнительным механизмам (то, что инженеры называют «вещи, которые делают что-то») или моторным системам (что биологи называют «вещи-действия-вещи»):

На изображении выше изображена схема, вычисляющая те же уравнения, написанные выше. Мы можем считать, что если вход в поле |MOVE FORWARD| положительный (F − B − 2|R − L| > 0), он активируется и каким-то образом производит движение вперед. Не беспокойтесь слишком сильно, если все вышеперечисленное еще не имеет особого смысла или не имеет совершенно правдоподобного сопоставления с биологией. Это просто невероятно сильно упрощенный пример, предназначенный для демонстрации связи биологии, математики и инженерии.

Но обратите внимание на одну вещь: некоторые входы в этой цепи (круглые элементы) явно поступают извне, например значения входных сигналов датчика (F, B, L и R), но некоторые из них являются внутренними константами (−1 и 2). И, может быть, эти «константы» в конце концов не должны оставаться такими постоянными… Может быть, наш червивый друг сможет «научиться» их настраивать? Может быть. Но не будем пока забегать вперед, впереди долгий путь ...

Реальные схемы / вычислительные графы - их кодирование и понимание

КРЕДИТЫ: то, что нас ждет впереди, в значительной степени вдохновлено сообщением в блоге Хакерское руководство по нейронным сетям Андрея Карпати (тогда он был аспирантом Стэнфордского университета, сейчас в 2019 году). ИИ в Tesla)

Самые простые вещи, которые вы можете себе представить

Итак, там мы зашли слишком далеко ... Давайте немного отступим и попробуем закодировать лишь крошечный кусочек такой схемы, а затем немного поиграемся с ней, чтобы понять ее поведение. Давайте выберем «схему», состоящую всего из одного элемента умножения (символ «•» - это точка умножения):

def forward_multiply_gate(x, y):
    return x * y
forward_multiply_gate(-2.5, 3.0)  #>> -7.5 (output)

Мы будем называть это «нормальное направление» распространение значений через вентиль «прямым распространением», отсюда и название forward_multiply_gate. Его выход - это продукт его входов.

Но допустим, нам не понравился его результат! Мы хотим, чтобы он был больше! … Как мы можем действовать, чтобы этого добиться?

Мы можем изменить только его входные данные, x и y. Но как их изменить?

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

def find_better_xy_rand(
    start_x, start_y, n_iter=1000, tweak_amount=0.01
):
    best_out = forward_multiply_gate(start_x, start_y)
    best_x, best_y = start_x, start_y
    for k in range(n_iter):
        x_try = start_x + tweak_amount * (random.uniform(0, 2) - 1)
        y_try = start_y + tweak_amount * (random.uniform(0, 2) - 1)
        out = forward_multiply_gate(x_try, y_try)
        if out > best_out:
            best_out = out
            best_x, best_y = x_try, y_try
    return best_x, best_y, best_out
find_better_xy_rand(-2.5, 3.0)
#>> (-2.490371157199204, 2.990574457956016, -7.447640373550305)

Мы видим, что после того, как мы проделали это 1000 раз, мы получили что-то примерно на 0,05 лучше (-7,45 больше, чем -7,50, оно ближе к нулю, здесь мы не рассматриваем абсолютные величины, а просто меньшие и большие реальные числа) . Немного. Но это что-то.

В любом случае, давай попробуем получше ...

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

def variation_wrt_x(f, x, y, h=1e-4):
    return (f(x + h, y) - f(x, y)) / h
def variation_wrt_y(f, x, y, h=1e-4):
    return (f(x, y + h) - f(x, y)) / h
(variation_wrt_x(forward_multiply_gate, -2.5, 3.0),
 variation_wrt_y(forward_multiply_gate, -2.5, 3.0))
#>> (3.000000000010772, -2.5000000000030553)

Воспоминания об исчислении

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

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

Да, и на всякий случай помните, что горизонтальная дробная линия в ∂f (x, y) / ∂x или df (x) / dx не деление (но другие есть), это просто стандартное обозначение для (частной) производной. Есть другие менее запутанные обозначения, но это именно то, что практически все использует. Так:

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

def grad(f, x, y, h=1e-4):
    return (
        (f(x + h, y) - f(x, y)) / h,
        (f(x, y + h) - f(x, y)) / h
    )
grad(forward_multiply_gate, -2.5, 3.0)
#>> (3.000000000010772, -2.5000000000030553)

И, конечно же, теперь, когда мы, надеюсь, вспомнили об исчислении, мы знаем, что просто глупо вычислять такие простые производные численным приближением. Мы знаем, что для нашей функции ∂f / ∂x = y и ∂f / dy = x:

def grad_forward_multiply_gate(x, y):
    return y, x

Детское градиентное восхождение

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

Да, и делайте это только в очень малых количествах, потому что мы знаем, что производная «выполняется» только для очень небольшого интервала вокруг (x, y) (следовательно, маленький h, который мы используем).

Код для этого (многократно) будет:

def find_better_xy_using_grad(x0, y0, n_steps=1, step_size=0.01):
    x, y = x0, y0
    old_out = forward_multiply_gate(x0, y0)
    for _ in range(n_steps):
        # dx, dy = grad(forward_multiply_gate, x, y)
        dx, dy = grad_forward_multiply_gate(x, y)
        new_x = x + step_size * dx
        new_y = y + step_size * dy
        out = forward_multiply_gate(new_x, new_y)
        if out > old_out:
            old_out = out
            x, y = new_x, new_y
    return x, y, out
find_better_xy_using_grad(-2.5, 3.0, 100)
#>> (-0.33038548114615374, 1.6827923958552788, -0.555970175373735)

Мы видим, что, повторяя эти «всего» 100 шагов, мы уже продвинулись намного дальше, чем случайная настройка в 1000 шагов (-0,55 против -7,45):

find_better_xy_rand(-2.5, 3.0, 1000)
#>> (-2.4904346786535663, 2.990088016412644, -7.446618888300503)

Эту «чудесную» стратегию, которую мы только что изобрели, можно также выразить в терминах движения вверх / вниз по градиенту. Мы даже можем представить градиент в определенной точке как «наклон» «ландшафта», охватывающий плоскость (x, y) возможных значений для наших входных значений ворот. И мы идем по этой плоскости вверх по склону, пытаясь найти более высокую точку.

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

Конечно, на данный момент, с схемой с 1 вентилем, у вас нет возможности увидеть, как это может иметь отношение к какому-либо «обучению». Мы даже не определили проблему, которую решаем должным образом ... Так что вперед!

Увеличь масштаб, детка!

Теперь давайте переместим одну из «схемы» с одним вентилем во что-нибудь более похожее на настоящую полезную схему. А пока сделаем две калитки - детские шажки:

def forward_add_gate(x, y):
    return x + y
def forward_circuit(x, y, z):
    s = forward_add_gate(x, y)
    return forward_multiply_gate(s, z)

Вы также увидите, что мы ввели дополнительную (бесполезную?) Промежуточную переменную / функцию, s(x, y) = x + y, так что мы также можем в качестве альтернативы написать f(s, z) = s * z. Это поможет с обозначениями чуть позже.

forward_circuit(-1, 5, -3)  #>> -12

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

… Но нет причин останавливаться на достигнутом! Мы можем вернуться на один шаг назад в нашей схеме и получить градиент s:

def grad_forward_add_gate(x, y):
    return 1, 1

Чтобы получить от производных s по x и y к производным от f относительно. x и y нам действительно необходимо запомнить еще один крошечный кусочек исчисления: правило цепочки. По сути, он дает нам формулу для вычисления производной композиции двух функций, поэтому, например, учитывая f (g) и f (x), мы d иметь:

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

Это также относится к многомерным функциям, и в нашем случае и для x это выглядит так:

В любом случае, давайте перейдем к коду, потому что он окажется более информативным:

# Notation: df_z == "partial deriv. of f w.r.t. z" (df/dz)
def grad_forward_circuit(x, y, z):  #= x=-1 y=5 z=-3
    s = forward_add_gate(x, y)  #= 4
    #= df_z=4 df_s=-3
    df_z, df_s = grad_forward_multiply_gate(z, s)
    #= ds_x=1 ds_y=1
    ds_x, ds_y = grad_forward_add_gate(x, y)
    # Here comes the chain rule:
    df_x = df_s * ds_x  #= -3
    df_y = df_s * ds_y  #= -3
    return df_x, df_y, df_z
grad_forward_circuit(-1, 5, -3)  #>> (-3, -3, 4)

Мы можем подтвердить, что это правильно, сравнив с частным производным, вычисленным непосредственно по правилам исчисления:

Давайте еще раз проверим себя, вычислив тот же градиент численно:

def num_grad(f, x, y, z, h=1e-4):
    v = f(x, y, z)
    return (
        (f(x + h, y, z) - v) / h,
        (f(x, y + h, z) - v) / h,
        (f(x, y, z + h) - v) / h
    )
num_grad(forward_circuit, -1, 5, -3)
#>> (-2.9999999999930083, -2.9999999999930083, 4.000000000008441)

Ура! И снова правильно!

Теперь давайте применим все эти результаты и найдем несколько лучших (x,y,z) значений (которые увеличивают производительность):

def find_better_xyz_using_grad(
    x0, y0, z0, n_steps=1, step_size=0.01
):
    x, y, z = x0, y0, z0
    old_out = forward_circuit(x0, y0, z0)
    for _ in range(n_steps):
        df_x, df_y, df_z = grad_forward_circuit(x, y, z)
        new_x = x + step_size * df_x
        new_y = y + step_size * df_y
        new_z = z + step_size * df_z
        out = forward_circuit(new_x, new_y, new_z)
        if out > old_out:
            old_out = out
            x, y, z = new_x, new_y, new_z
    return x, y, z, out
find_better_xyz_using_grad(-1, 5, -3, 100)
#>> (-2.75, 3.25, -1.05, -0.52)

Наш выпуск увеличился с -12,00 до -0,52. Хорошее улучшение!

Более глубокая интуиция - сила и тяга

Теперь есть причины, по которым мы не сразу применили простейшие правила вычисления к нашим схемам, чтобы просто вычислить эти градиенты напрямую:

  1. В реальных схемах некоторые компоненты схемы могут быть не такими простыми, а некоторые даже практически недифференцируемыми, поэтому у вас есть способ вычислять числовые аппроксимации градиентов и делать это для всей схемы путем распространения их в обратном направлении на самом деле очень важно для работы с этими схемами на практике
  2. Нам действительно нужен способ проверить, что наш код не содержит ошибок, поскольку в машинном обучении ошибочный код обычно не выдает ошибку, он просто дает немного неправильный результат! Возможность вычислить одно и то же как численно, так и аналитически (для сравнения результатов) - важнее, чем кажется на первый взгляд.
  3. То, что у нас здесь, мало! У нас пока еще нет ничего, хоть немного полезного на практике! И нам нужна более глубокая интуиция, чтобы сделать следующие шаги в правильном направлении! Маловероятно, что получится перейти от вычисления градиентов по схемам с действительными значениями с использованием правил исчисления к чему-либо полезному. Поскольку мы начали (поверхностно) размышлять о физических системах (помните мистера Уорми?), Может быть, нам стоит попытаться больше думать о наших схемах с точки зрения их «физики»

Поскольку градиент ворот по отношению к вход ворот - это «насколько увеличится выходное значение при небольшом увеличении этого входного значения», мы также можем думать об этом «в обратном порядке»: если бы выходное значение увеличилось на на определенную величину (при условии, что для этой достаточно малой величины наклон выходной функции остается очень похожим на то, что он есть в текущей точке), насколько должно измениться значение входных данных? Или насколько мы должны варьировать ввод, чтобы получить желаемый небольшой вариант вывода?

Или, что больше становится более физическим, насколько мы должны «тянуть» (добавлять) или «выталкивать» (вычитать) входные данные, чтобы получить наш вариант выходных данных.

Или даже: сколько толчка или тяги на входах преобразовалось бы в «приложение силы» к выходу схемы (толкая его вверх или вниз)?

Теперь мы кое-что уходим: это почти как если бы «подтягивание вывода функции» приводит к подтягиванию (разного знака; или «вытягиванию» и «выталкиванию», в зависимости от того, как вы это произнесете) на ее входах.

На самом деле, давайте попробуем обдумать это в более конкретных терминах, посмотрев на последнюю использованную нами схему:

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

Теперь вы можете видеть, что, хотя градиенты плюсовых ворот для x и y были бы равны 1, они умножаются на -3, текущие в обратном направлении от нисходящего потока. Как и ожидалось.

Но ждать! Что это за цифра 1 справа ?!

Что ж ... есть два способа увидеть вещи: во-первых, вы можете просто предположить, что цепь здесь не заканчивается, справа, ниже по потоку, есть больше вещей, от которых этот 1 градиент распространяется в обратном направлении, может быть, ∂y / ∂f = 1 или что-то в этом роде. Похоже, что это не имеет никакого значения, поскольку даже если мы умножим на 1, применяя цепное правило, ничего не произойдет.

Но помня о том, что «если бы значение выпуска увеличилось на определенную величину» из вышеизложенного, мы можем видеть, что это 1 на самом деле может быть нашей «определенной суммой». Он довольно большой, но это не имеет значения, потому что наша схема линейная. На самом деле, даже если бы он не был линейным, все, что имело бы значение, - это его положительный знак, поскольку мы все равно масштабируем на произвольный step_size (мы всегда можем установить его на меньшее значение, если «он прыгает слишком сильно»). Чтобы «сказать схеме, что нам нужны более низкие выходы» или «обратить вспять тягу на выходе», мы можем просто заменить 1 на -1.

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

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

Машинное обучение с реальными схемами

Помните, помните…

… Что мы можем видеть входные данные двух типов:

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

Возьмем очень простой пример. Допустим, у нас есть только один внешний вход, x (с этого момента мы будем использовать «вход» для обозначения «внешнего входа») и два «внутренних входа», которыми мы можем управлять, и которые мы вызовем параметры, a и b. И схема смоделирует уравнение: f(x, a, b) = a*x + b. (На самом деле мы могли бы даже записать его как f(x) = a*x + b, но это будет не очень полезно, поскольку это может обмануть нас, заставив поверить, что a и b являются константами, и затеняет возможность вычисления для них градиентов f, делая все это бесполезным.)

Мы не будем подробно останавливаться на этом конкретном примере линейной схемы, но вы можете видеть, что вход x играет совсем другую роль, чем параметры a и b. (Такая схема может быть своего рода «настраиваемым» линейным откликом, где на основе входного сигнала какого-то датчика робот пропорционально увеличивает выход, с настраиваемым наклоном отклика a, возможно, с некоторой «чувствительностью», а где-то своего рода порог, который должен быть достигнут для того, чтобы выходной сигнал был положительным, регулируемый путем настройки значения b. Или, может быть, мистер Уорми движется быстрее в ответ на какой-то звук, который он слышит, который может указывать на присутствие хищника, но только если интенсивность звука выше определенного порогового значения для обучения. Кто знает ... это примитивно, но может быть полезно во многих сценариях!)

Первая полезная схема

Одной из простейших полезных проблем, которые можно решить с помощью простой схемы с действительными значениями, является классификация двумерных входных данных на две категории. Мы можем построить такие входные данные как точки на плоскости (x, y) с цветом / символом в зависимости от их класса +1 или -1. Допустим, у нас есть этот образец правильно классифицированных входных данных (каждая строка data - это входные данные, первый столбец - x, второй y, третий - класс):

data = np.array([
    [3.74, 7, 1],
    [14, 9, 1],
    [-2.5, 12, 1],
    [-8, 8, -1],
    [2, -6.3, -1],
    [-6, 1, -1],
])

Мы можем легко визуализировать эти данные:

def plot_data(data, params=None):
    # using >0 condition for selecting classes will make this
    # work both with 1,-1 and 1,0 classes which will help later
    class_o = data[data[:, -1] > 0]
    plt.scatter(class_o[:, 0], class_o[:, 1],
                marker='o', color='g', label='+1')
    class_x = data[data[:, -1] <= 0]
    plt.scatter(class_x[:, 0], class_x[:, 1],
                marker='x', color='r', label='-1')
    plt.xlabel('x'); plt.ylabel('y')
    plt.xlim(-15, 15); plt.ylim(-15, 15)
    leg = plt.legend()
    xs = np.linspace(-15, 15)
    if params:
        a0, b0, c0 = params[0]
        ys0 = (-a0 / b0) * xs - c0 / b0
        plt.plot(xs, ys0,
                 color='m', alpha=1 if len(params) <= 2 else 0.1)
        yss0 = ys0 + (3 if b0 >= 0 else -3)
        plt.fill_between(xs, ys0, yss0, color='g', alpha=0.2)
        if len(params) >= 2:
            af, bf, cf = params[-1]
        for i, (a, b, c) in enumerate(params[1:-1]):
            plt.plot(xs, (-a / b) * xs - c / b,
                color='m',
                alpha=(i + 1) / (len(params) - 1) * 0.8,
                linewidth=1)
        if len(params) >= 2:
            ys = (-af / bf) * xs - cf / bf
            plt.plot(xs, ys, color='b')
            yss = ys + (3 if bf >= 0 else -3)
            plt.fill_between(xs, ys, yss, color='g', alpha=0.2)
plot_data(data, [
    (-5, 1, 0.2),  # some "arbitrary crappy" starting point
    (1.5, 1, 1.4)  # just some line that we *know* separates 100% the two sets
])

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

Поскольку эти точки могут быть разделены линией, мы говорим, что они «линейно разделимы» (в N-измерениях мы просто заменим «линию» на «(гипер) плоскость» и будем придерживаться той же концепции). Итак, давайте сделаем «линейную схему» для нашей задачи (пусть f(x, y, a, b, c) = a*x + b*y + c):

Теперь, когда у нас есть более четкое представление о том, как мы собираемся использовать нашу схему, давайте структурируем наш код более разумным образом. (Нет, мы не будем использовать «полное ООП» и строить каждый вентиль как объект с операциями по распространению значений вперед и градиентов назад и т. Д. - на самом деле это действительно плохая идея, она только повредит вашей интуиции, и это также не так, как любой производственный код ML когда-либо пишется, поскольку любая серьезная работа выполняется путем переформулирования схем в терминах матричных операций как для более быстрого выполнения, например, на (G / T) PU, так и для более компактной математической записи. )

Поскольку мы знаем, что x и y - это входные данные, поступающие извне, а a, b и c - внутренние параметры, давайте начнем строить нашу маленькую машину, учитывая это:

class Circuit:
    def __init__(self, a, b, c):
        self.a, self.b, self.c = a, b, c
    
    def predict(self, x, y):  # forward propagation of values
        return self.a * x + self.b * y + self.c
    # ...
baby_circuit0 = Circuit(-5, 1, 0.2)
print("Look Ma, I'm computing stuff:", baby_circuit0.predict(-8, 8))
#>> Look Ma, I'm computing stuff: 48.2

Смотрите, теперь мы сохраняем внутренние параметры внутри объекта схемы (мы устанавливаем их во время создания экземпляра объекта, но они должны быть изменены позже). И мы также использовали более удачное название для метода прямого (x, y) -> output распространения - поскольку мы используем его, чтобы угадывать / предсказывать класс чего-либо, мы назовем это predict вместо forward_whatever.... О, и да, намерение состоит в том, чтобы использовать его с помощью правила типа 1 if circuit.predic(x, y) >= 0 else -1, чтобы фактически получить класс из его вывода, но нам также может потребоваться фактический числовой вывод, поэтому оставим его так.

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

class Circuit:
    # ...
    def get_accurracy(self, data):
        num_correct = 0
        for x, y, true_label in data:
            predicted_label = 1 if self.predict(x, y) > 0 else -1
            if predicted_label == true_label:
                num_correct += 1
        return num_correct / len(data)
    # ...
baby_circuit0.get_accurracy(data)
#>> 0.3333333333333333

Это очень плохо. Нам обязательно нужно помочь нашему детскому контуру стать лучше! И мы знаем, как это сделать, верно?

Гм ... вроде как. Когда мы «использовали» наши предыдущие схемы, вычисляя градиенты, которые мы затем использовали для обновления входных данных, чтобы получить более высокий выход, мы были обеспокоены увеличением выхода в целом.

Здесь мы заботимся о том, чтобы заставить нашу схему производить определенный выход для определенного входа (фактически, для «набора входов (x,y,a,b,c), из которых x и y имеют определенные значения , и a, b и c - наши, которые мы можем настроить, чтобы получить желаемый результат). Теперь мы получаем некоторую ясность!

И мы также хотим сделать это для всех (input,output) пар примеров. Поэтому для нас важно, чтобы каждый раз после того, как мы настраивали наши параметры (a,b,c) для получения желаемого результата для определенного входа, у нас не было возможности нашей схемы снова выдавать нежелательные выходы для всех других входов. Мы не хотим, чтобы наша схема «забывала, как классифицировать предыдущие входные данные» каждый раз, когда мы показываем ей новый входной пример. Мы надеемся, что это возможно (и для этого примера у нас есть веские математические основания для наших надежд), например. мы надеемся, что можно получить некоторый набор параметров (a,b,c), которые заставят нашу схему правильно классифицировать все (или как можно больше ...) наших входов (мы говорим, что «input -> output отображение наших данных может быть изучено нашей схемой») . И мы также надеемся, что схема с параметрами, настроенными в этом ожидании, также сможет правильно классифицировать будущий невидимый ввод (мы говорим, что «она может обобщить то, что она узнала»).

Эти три идеи:

  • что на основе набора input|output примеров мы можем каким-то образом получить значения для параметров нашей внутренней схемы, которые заставят ее выдавать желаемые выходные данные для всех / большинства примеров входов
  • что наша схема может «изучить» шаблоны в нашем примере input|output data
  • что после того, как он «узнал» значения для своих внутренних параметров на основе input|output примеров, он также может производить правильные выходные данные для будущих входов (или неизвестных - например, в «в настоящий момент мы еще не знаем, какой правильный выход для такого входа будет быть »), например. что он может быть обобщен для получения правильных ответов на невидимые данные

… Формируют основу ВСЕГО машинного обучения!

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

class Circuit:
    # ...
    def learn_from(self, x, y, desired_class, learning_rate=0.001):
        # compute output
        out = self.predict(x, y)
        predicted_class = 1 if out >= 0 else -1
        # figure out pull
        pull = 0
        if desired_class != predicted_class:
            if desired_class == 1 and predicted_class == -1:
                pull = 1
            else:
                pull = -1
        # compute gradients - !!! not implemented yet !!!
        # the following computes self.df_a,df_b,df_c:
        self.compute_grads(x, y, pull)
        # update parameters
        self.a += learning_rate * self.df_a
        self.b += learning_rate * self.df_b
        self.c += learning_rate * self.df_c
    # ...

Приведенный выше код еще не используется (compute_grads еще не определен, но Python как прекрасный динамический язык, он позволяет нам двигаться дальше и доверяет нам позаботиться об этом позже). Но посмотрим, что он делает:

  • out = self.predict(x, y) - сначала он получает выходной сигнал схемы для этого примера (x,y)input перед обучением схемы для него
  • затем он вычисляет «тягу» - мы видим, что вместо простого использования 1 в качестве тяги (что дало бы просто математический градиент), мы проверяем, предсказывает ли наша схема desired_class, и, если нет, мы устанавливаем положительную тягу (+1, для увеличения выхода схемы или отрицательного (-1) для его уменьшения
  • мы еще не определили метод compute_grads для вычисления градиентов для нашей схемы, который будет вычислять его в определенной точке (x, y), а также будет распространять pull обратно на эти градиенты (для этой схемы он просто ... умножает градиент на тянуть, которое может быть 0, 1 или -1, здесь ничего особенного)
  • мы обновляем наши значения на основе вычисленных градиентов и «размера шага», который мы теперь назвали learning_rate (так как это можно сказать как бы «насколько быстро обучается схема» - в любом случае, это все еще » насколько велик наш размер шага, когда мы идем по градиентному ландшафту ') - это важно по двум причинам: (1) потому что мы знаем, что вычисленный градиент может быть правильным только для небольшой зоны около (x,y), и (2) потому что мы не хотим, чтобы схема придавала слишком большое значение одному конкретному обучающему примеру и обновляла свои параметры так резко, что `` забывала '' обновления, которые она сделала для предыдущих примеров (это на самом деле так важно, что мы бы предпочли очень малая скорость обучения, даже если это заставит нас повторять показ нашей схеме входные данные примера тысячи раз)

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

Таким образом, каждый набор параметров схемы (a,b,c) может быть графически представлен в виде линии, начерченной на плоскости (x,y) (линия с уравнением a*x + b*y + c = 0). Например, значение a*x + b*y + c будет положительным для точек выше синей линии и отрицательным для точек ниже. . Итак, в приведенном выше примере синяя линия представляет собой тройку значений (a,b,c), которые заставляют схему правильно классифицировать все входные данные нашего примера. Пурпурная / фиолетовая линия - нет, она правильно классифицирует только 2 балла из 6 (точность 33%).

Зеленые ореолы вокруг одной стороны линий представляют сторону, на которой схема будет выводить положительные значения. (Вспомните ваши базовые линейные уравнения средней школы: уравнение типа ax + by + c = 0 представляет собой полуплоскость, которая находится по одну сторону от линии y = -(a/b)x - (c/b). Или, точнее, это полуплоскость над линией функции y(x), если b > 0 или ниже, если b < 0. Также обратите внимание, что хотя для изображения прямой вам понадобятся только два числа, (m,n) из y = mx + n, если вы хотите изобразить полуплоскость вам понадобится 3 числа, например (a,b,c) в ax + by + c > 0, потому что, имея только два числа типа (m,n), вы не будете знать, на какой стороне линии у вас есть положительные значения, а на каких отрицательные!)

И вы видите, что может быть довольно много «правильных линий», таких как синяя (технически бесконечность). И вы можете надеяться, что не все они «одинаково хороши» для наших целей. Мы бы хотели, чтобы линия out правильно разделяла два набора точек, но мы также хотели бы, чтобы она располагалась как можно дальше от ближайших к ней точек, мы хотели бы как можно больше «пустой полосы» без точек. вокруг линии насколько это возможно, поскольку мы надеемся, что это увеличит вероятность того, что новый (еще невидимый) ввод попадет на правильную сторону линии. (Если ваша интуиция еще не согласна с этим, вы можете безопасно двигаться дальше, на самом деле это пока не так важно…).

Поместив это в код, проще всего было бы иметь произвольный «запас», и вместо сравнения вывода с 0, чтобы получить класс типа 1 if out >= 0 else -1, мы можем только внутри обучающего кода 'требовать 'выход будет > 1 для class 1 и < -1 для class -1, установив тягу соответственно. Это заставит код продолжать тянуть линии, которые нарисованы слишком (опасно) близко к точкам, и отодвигать их дальше от точек данных, что приведет к лучшему обобщению невидимых данных:

class Circuit:
    # ...
    def learn_from(
        self, x, y, desired_class, margin=1.0, learning_rate=0.001
    ):
        # compute output
        out = self.predict(x, y)
        # figure out pull
        pull = 0
        if desired_class == 1 and out < margin:
            pull = 1
        if desired_class == -1 and out > -1 * margin:
            pull = -1
        # compute gradients - !!! not implemented yet !!!
        self.compute_grads(x, y, pull)
        # update parameters
        self.a += learning_rate * self.df_a
        self.b += learning_rate * self.df_b
        self.c += learning_rate * self.df_c
    # ...

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

class Circuit:
    # ...
    def compute_grads(self, x, y, pull):
        """Computes gradients and sets them in
           self.df_a, self.df_b, self.df_c"""
        # backpropagate
        self.df_c = 1 * pull
        df_axpby = 1 * pull
        daxpby_ax = 1
        daxpby_by = 1
        dax_a = x
        dax_x = self.a
        dby_b = y
        dby_y = self.b
        self.df_a = df_axpby * daxpby_ax * dax_a  #= x*pull
        self.df_b = df_axpby * daxpby_by * dby_b  #= y*pull
    # ...

Это также можно сделать с небольшим улучшением: мы также хотели бы, чтобы значения параметров не становились слишком большими при одном обновлении. Установка небольшой скорости обучения помогает, но они все равно могут быть взорваны градиентом, который становится огромным в определенной точке. Чтобы найти хороший способ держать их под контролем (мы назовем этот процесс удержания значений под контролем регуляризацией), мы можем снова сформулировать его в терминах физики: возможно, мы хотели бы иметь какие-то пружины, которые тянут на a и b в сторону нуля . Мы знаем, что сила пружины пропорциональна ее деформированной длине (закон Гука), насколько далеко от 0 в нашем случае, и мы легко можем добавить для этого очень простой код:

class Circuit:
    # ...
    def compute_grads(self, x, y, pull):
        """Computes gradients and sets them in
           self.df_a, self.df_b, self.df_c"""
        # backpropagate
        self.df_c = 1 * pull
        df_axpby = 1 * pull
        daxpby_ax = 1
        daxpby_by = 1
        dax_a = x
        dax_x = self.a
        dby_b = y
        dby_y = self.b
        self.df_a = df_axpby * daxpby_ax * dax_a  #= x*pull
        self.df_b = df_axpby * daxpby_by * dby_b  #= y*pull
        # don't forget about regularization
        self.df_a -= self.a
        self.df_b -= self.b
    # ...

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

Теперь мы можем обучить схему на выборке и посмотреть, насколько хорошо она обучается:

baby_circuit1 = Circuit(-5, 1, 0.2)
print("initial accurracy:", baby_circuit1.get_accurracy(data))
baby_circuit1.learn_from(3.74, 7, 1)
print("accurracy after training once", baby_circuit1.get_accurracy(data))
baby_circuit1.learn_from(3.74, 7, 1, learning_rate=0.1)
print("accurracy after training once (increased LR)", baby_circuit1.get_accurracy(data))
for _ in range(1000):
    baby_circuit1.learn_from(3.74, 7, 1, learning_rate=0.1)
print("accurracy after training 100x (increased LR)", baby_circuit1.get_accurracy(data))
#>> OUTPUT:
initial accurracy: 0.3333333333333333
accurracy after training once 0.3333333333333333
accurracy after training once (increased LR) 0.3333333333333333
accurracy after training 100x (increased LR) 0.5

Мы видим, что он может учиться ... для изучения только одного обучающего примера требуется настроенная скорость обучения и 100 итераций. Мы могли бы немного улучшить, насколько хорошо он обучается на одном примере (настройка learning_rate, margin и т. Д.), Но то, как это происходит сейчас, на самом деле оказывается лучше для изучения нескольких примеров.

Один из способов обучить схему на нескольких примерах - показать ей один пример в случайном порядке и просто повторять это снова и снова. Кстати, на ML-жаргоне такая итерация называется… «эпоха». Мы также хотели бы собирать и возвращать (некоторые из) оценки и значения параметров, которые мы получаем по мере обучения, чтобы проверить / визуализировать их позже.

class Circuit:
    # ...
    def train(self, data, epochs=400, margin=1.0):
        scores = []
        training_params = []
        for epoch_idx in range(epochs):
            sample_idx = random.randint(0, len(data) - 1)
            x, y, label = data[sample_idx]
            self.learn_from(x, y, label, margin)
            if epoch_idx % 25 == 0:
                scores.append(self.get_accurracy(data))
                training_params.append((self.a, self.b, self.c))
        return scores, training_params
    # ...

--- WARNING: This is NOT how you would ever code such things in any real-life system. We use classes & objects for the circuits here, but actually you’d almost always end up representing them as multiplications of matrices and stuff like that. OOP is just something for larger scale encapsulation and for exposing a nice API to users of your library/framework. NOT for core training and prediction code!

Это был последний штрих, в котором нуждалась наша схема. Давайте попробуем!

final_circuit = Circuit(-5, 1, 0.2)
print("final_circuit initial accurracy:",
      final_circuit.get_accurracy(data))
scores, training_params = final_circuit.train(data, 1200)
plt.plot(np.arange(0, 1200, 25), scores)
plt.xlabel('iteration #'); plt.ylabel('accurracy')
plt.title("accuracy with training")
#>> final_circuit initial accurracy: 0.3333333333333333

Ух ты! Наша трасса может опереться! Всего за ~ 1000 итераций можно добиться 100% точности!

Красиво, но отчасти… непрозрачно: давайте заглянем внутрь и посмотрим, как (a,b,c) параметры менялись с течением времени:

Смотри, мама, SVM!

Возможно, вы этого не осознавали, но вы только что изобрели… SVM (машину опорных векторов)! Ну, своего рода ... «настоящая SVM», помимо того, что она определяется в (гораздо) более математически сложных терминах, имеет некоторые дополнительные уловки, такие как не просто использование жестко запрограммированного «поля», равное 1, лучшая обработка случаев, когда идеально правильная классификация невозможно или нежелательно и т. д. Но мы реализовали основную идею, мы достаточно близки, несмотря на то, что упустили некоторые части. (Мы также никогда не использовали идею стоимости / ошибки, которая может показаться важной для других людей, обсуждающих SVM, но на самом деле она нам не нужна, наша концепция push / pull - это то же самое, что, надеюсь, менее запутанные термины.)

SVM очень популярны в машинном обучении, и в некоторых конкретных приложениях они могут соперничать с глубоким обучением («нейронные сети с несколькими скрытыми слоями» - мы скоро до них доберемся, подождите), будучи менее ресурсоемкими. Фактически, до того, как глубокое обучение захватило области компьютерного зрения и распознавания звука, SVM были здесь на уровне искусства.

Если вы действительно обращаете внимание, это должно показаться вам ДЕЙСТВИТЕЛЬНО странным! В конце концов, как может модель, которую можно использовать только для линейной классификации (на самом деле, регрессии тоже с некоторые хитрости, но что угодно) быть современным в чем угодно ?! Что ж, умные люди сообразили, что на самом деле можно добавить несколько трюков поверх этого: один очень популярный трюк - добавить дополнительные инженерные входные переменные, такие как x**2 (x в квадрате) или y**3 и т. д. (некоторые называют их уловками ядра ). Таким образом, вы действительно можете превратить то, что по сути является линейной моделью, во что-то, что может иметь полиномиальные (или даже другие) границы. Представьте себе, что прямые линии на диаграммах выше могут изгибаться как кривые. Вы можете довольно далеко продвинуться с этим, даже если вы оставите операцию разработки функций за пределами схемы, по которой выполняется обратное распространение. В этом и заключалось машинное обучение в 90-х и начале 2000-х (тогда нейронные сети были не крутыми, и только неудачники, такие как Джеффри Хинтон или ВВС США, тратили на них свое время).

Кроме того, если вместо того, чтобы думать о нагромождении трюков в имеющейся у нас схеме, вы бы пошли в обратном направлении, удалили бит маржи и даже удалили классификацию на основе вывода и использовали вывод напрямую, например, для выполнения регрессии (что означает просто вывод / предсказание реального значения вместо класса), мы бы довольно быстро получили схему, которую можно было бы использовать для выполнения линейной регрессии. Звучит скучно, но линейная регрессия на самом деле не обязательно должна быть линейной. Уловка ядра, заключающаяся в вводе в качестве дополнительных переменных исходных входных данных, возведенных в разную степень, и другие подобные уловки поднимают ее выше ограничений линейности. Да, и если вы просто вставите логистическую функцию в конец схемы, вы получите логистическую регрессию. (Если бы Вольтер был жив и занимался машинным обучением, он, вероятно, назвал бы линейную регрессию Священной Римской империей машинного обучения »- это не обязательно линейная или что-то, что можно использовать только для регрессии, и это также старые / устаревшие / в основном неактуальные).

Общая нейронная цепь

Вот эти «схемы», которые у нас есть, если мы продолжаем думать о них как о схемах и не просто уравнениях, они также обладают некоторыми интересными «архитектурными» качествами:

  1. так же, как вентиль, ваш базовый компонент схемы, имеет входы и выход, то же самое верно для всей схемы
  2. на самом деле вся схема может быть представлена ​​как вентиль, и мы можем иметь схему, состоящую из схем, сделанных из цепей и т. д. (если мы решим представить «внутренние / настраиваемые параметры» схемы, превращенной в вентиль, как «вентиль с какое-то внутреннее состояние », или мы решаем вытащить их с точки зрения обозначений, это не имеет особого значения - подсказка: лучше вытащить их)
  3. весь бизнес «обратного распространения градиентов» по-прежнему работает с «схемами, состоящими из схем, сделанных из…», мы можем масштабировать и играть с «инкапсулированием» вещей сколько угодно, математика по-прежнему работает точно так же.

Теперь мы можем называть себя «схемотехниками» и начинать самодовольно. Обретя повышенную уверенность в себе, мы можем вернуться к нашим друзьям-биологам и спросить их кое-что о тех вещах, которые называются «нейронами». Скорее всего, они начнут кричать о том, что мы совершенно не понимаем, как работает мозг червя, но нам все равно, потому что из нашей неправильной модели биологически неправдоподобного червя мы получили мощные модели, которые помогают агентам по недвижимости определять цену. дома лучше, помогите своей страховой компании понять, что вы плохой водитель, поэтому вам следует платить больше за страховку, помогите Facebook обслуживать вас «лучше», добавьте и многое другое, что «сделало мир лучше». Поняв примерно 10% того, что они объясняют нам о том, как работают нейроны, мы спешим вернуться к нашим реальным схемам и коду и пытаемся реализовать недавно изученные вещи, надеясь сделать с ними другие социально полезные вещи, например, повышение цены акций. модели прогнозирования и удивительное программное обеспечение для распознавания лиц, которое может непрерывно отслеживать всех и везде.

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

Теперь, помимо использования ∑-gate, который может принимать несколько входов, вместо plus-gate, который может принимать только два, и немного другого рисования, он очень похож на наши старые схемы. Хм ... может, мы что-то забыли ...

Ах, биологи говорили о том, что нейрон становится деполяризованным / активным по сравнению с неактивным, как выход 0 или 1. Но все было не так просто. В любом случае, это была не просто линейная комбинация его входов. А, ладно, здесь нам нужна нелинейная функция, потому что это тоже была наша проблема, нам пришлось делать всевозможные трюки, такие как разработка функций и трюк с ядром, когда мы добавляем входные данные, возведенные в степень, да, да ... Но все, что биологи говорили об «активации», становилось слишком туманным, или мы не помним, и мы не хотим перезванивать им, чтобы спросить, чтобы мы не казались глупыми.

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

И ах, в наших предыдущих схемах тоже был этот c член, который не умножался ни на один вход. Есть много способов подумать об этом, некоторые называют это `` предвзятостью '', и это оказывается полезным в определенных ситуациях (представьте, что все входные данные малы, но нам нужно общее большое значение, превышающее определенный порог, без изменения взвешенного вклад входов). Так что давайте сохраним и это, и сделаем это с дополнительным «вводом», зафиксированным на 1, и сделаем так, чтобы наш старый c был w0 (сохранение одинаковых / однородных вещей поможет нам намного позже, когда мы перейдем к работе с матрицами).

Итак, это будет наша общая нейронная цепь (с N входами, здесь N = 3):

Мы даже удалили стрелки, так как знаем, в каком направлении движутся объекты (оба - помните, значения текут вперед, градиенты текут назад; и треугольная форма ворот в любом случае указывает путь вперед). В математической записи мы используем жирные символы для векторов, поэтому x - это вектор, составленный из [x0, x1, ...] (в коде, выделенном жирным шрифтом x обозначается xs - другой обычный код var обозначение заглавное X). Мы только приближаемся к тому, что стало сегодня «стандартной нотацией» в большинстве ресурсов по машинному обучению.

Перцептрон

Мы получаем простейший полезный нейрон, который мы можем себе представить, если f будет 1 if sum(x w) > 0 else 0 (математики тоже придумали для этого причудливое название «ступенчатая функция Хевисайда»…). Это согласуется с идеей биологии, что нейрон может быть активным или неактивным, а также позволяет избежать неприятной проблемы с «линейными нейронами», когда мы объединяем столько линейных нейронов в сеть, сколько мы хотим, мы все равно получим математический расчет. эквивалент одного нейрона (попробуйте вычислить выходное уравнение любой сети линейных нейронов, которую вы можете себе представить, если вы плохо владеете математикой и не доверяете мне).

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

Мы можем закодировать схему перцептрона следующим образом (поскольку сам перцептрон настолько «элегантен», мы оставим его в простом классе отдельно, а темную логику обучения реального мира сохраним в отдельном классе классификатора):

class PerceptronCircuit:
    def __init__(self, ws):
        self.ws = ws
    
    def predict(self, xs):
        self.a = np.sum(xs * self.ws)
        return 1 if self.a > 0 else 0
    
    def compute_grads(self, xs, d):
        # this just means the gradient for each weight is that input
        # times a given d (delta, 0 or 1)
        xs = np.asanyarray(xs)
        self.df_ws = xs * d
class PerceptronClassifier:
    def __init__(self, ws, learning_rate=0.01):
        # `numpy.asanyarray` ensures its argument is converted to a
        #    numpy array if it isn't already one (or a subclass of it)
        self.unit = PerceptronCircuit(np.asanyarray(ws))
        self.learning_rate = learning_rate
    
    def predict(self, xs):
        # add extra bias input of 1 to have:
        #  [1, x0, x1, ...]
        return self.unit.predict(np.r_[1, xs])
    def get_accurracy(self, data):
        data = np.asanyarray(data)
        n_correct = 0
        for sample in data:
            xs, true_y = sample[:-1], sample[-1]
            predicted_y = self.predict(xs)
            if predicted_y == true_y:
                n_correct += 1
        return n_correct / len(data)
    
    def train(self, data, epochs):
        data = np.asanyarray(data)
        scores = []
        training_params = []
        for epoch_idx in range(epochs):
            sample_idx = random.randint(0, len(data) - 1)
            xs, y = data[sample_idx, :-1], data[sample_idx, -1]
            assert xs.shape == (2,)
            self.learn_from(xs, y)
            if epoch_idx % 10 == 0:
                acc = self.get_accurracy(data)
                scores.append(acc)
                training_params.append(self.unit.ws[[1, 2, 0]])
        
        return scores, training_params
    
    def learn_from(self, xs, true_y):
        b_xs = np.r_[1, xs]
        pred_y = self.predict(xs)
        d = true_y - pred_y
        self.unit.compute_grads(b_xs, d)
        self.unit.ws += self.learning_rate * self.unit.df_ws

Давайте проверим, как это работает:

## confirm it does the same thing as the circuit before...
# before we had: `final_circuit = Circuit(-5, 1, 0.2)`
# but now we need to but the last w0 on the first position
perceptron_clf0 = PerceptronClassifier([0.2, -5, 1])
print("output:", perceptron_clf0.predict([-8, 8]),
      "sum:", perceptron_clf0.unit.a)
plot_data(
    data,
    # reorder weights bc. our plotting code expects w0 at the end
    [perceptron_clf0.unit.ws[[1, 2, 0]]]
)
#>> output: 1 sum: 48.2

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

perceptron_data = data.copy()
perceptron_data[:, -1] = [0 if x == -1 else 1
                          for x in perceptron_data[:, -1]]
perceptron_clf1 = PerceptronClassifier([0.2, -5, 1])
scores, training_params = perceptron_clf1.train(perceptron_data, 300)
plt.plot(np.arange(0, 300, 10), scores)
plt.xlabel('iteration #'); plt.ylabel('accurracy')
plt.title("accuracy with training")
plt.show()
plot_data(perceptron_data, training_params)
plt.title("MAGENTA: progress during training (transparency descreases with training time)\nBLUE: solution at the end");

Нейронные сети

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

data_nls = np.array([
    [-6, -4, 0],
    [5, -3, 1],
    [2, -4, 1],
    [6, 4, 0],
    [8, 9, 0],
    [-4, 5, 1],
    [-5, 4, 1],
])
plot_data(data_nls)

Этот набор данных особенный, и в очень «плохом» смысле: наши перцептроны просто не могут полностью сойтись на 100% правильной классификации для него. (Опять же, игнорируйте искусственность проблемы и то, как мы можем переобучаться как сумасшедшие, если вы знаете, что такое переобучение… это просто для того, чтобы показать вам механику наших «цепей мышления».)

(Вы также можете назвать это «набором данных в форме XOR», поскольку он имеет общую форму / распределение, аналогичное тому, которое вы получили бы, построив график результатов бинарной операции XOR с помощью x = {0,1} и y = {0,1}, и проблема создания простейшей нейронной сети, которая может выполнять XOR, была типичным примером ... но я хотел сделать это немного более реальным. )

perceptron_clf2 = PerceptronClassifier([0.2, -5, 1])
scores, training_params = perceptron_clf2.train(data_nls, 1000)
plt.plot(np.arange(0, 1000, 10), scores)
plt.xlabel('iteration #'); plt.ylabel('accurracy')
plt.title("accuracy with training")
plt.show()
plot_data(data_nls, training_params)
plt.title("MAGENTA: progress during training (transparency descreases with training time)\nBLUE: solution at the end");

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

Но если вы используете интеллектуальный файл cookie, вы можете понять, что можете разделить его более чем одной прямой линией. На самом деле вам нужно всего два. А чтобы получить две линии, разделяющие точки, вам понадобятся как минимум два нейрона!

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

class SimplestPerceptronNetworkCircuit:
    def __init__(self):
        w = 0.1
        self.p1 = PerceptronCircuit([w, w, w])
        self.p2 = PerceptronCircuit([w, w, w, w])
    
    def predict(self, xs):
        self.xs = xs
        self.y1 = self.p1.predict(xs)
        self.p2_inputs = np.r_[xs, self.y1]
        self.y2 = self.p2.predict(self.p2_inputs)
        return self.y2
    
    def compute_grads(self, d):
        # !! to be called after predict
        self.p2.compute_grads(self.p2_inputs, d)
        self.p1.compute_grads(self.xs, self.p2.df_ws[3])
    
    def update_grads(self, learning_rate):
        self.p2.ws += learning_rate * self.p2.df_ws
        self.p1.ws += learning_rate * self.p1.df_ws

class NeuralNetworkClassifier:
    def __init__(self, circuit, learning_rate=0.01):
        self.circuit = circuit
        self.learning_rate = learning_rate
    
    def predict(self, xs):
        # add extra bias input of 1 to have:
        #  [1, x0, x1, ...]
        return self.circuit.predict(np.r_[1, xs])
    def get_accurracy(self, data):
        data = np.asanyarray(data)
        n_correct = 0
        for sample in data:
            xs, true_y = sample[:-1], sample[-1]
            predicted_y = self.predict(xs)
            if predicted_y == true_y:
                n_correct += 1
        return n_correct / len(data)
    
    def train(self, data, epochs):
        data = np.asanyarray(data)
        scores = []
        for epoch_idx in range(epochs):
            sample_idx = random.randint(0, len(data) - 1)
            xs, y = data[sample_idx, :-1], data[sample_idx, -1]
            assert xs.shape == (2,)
            self.learn_from(xs, y)
            acc = self.get_accurracy(data)
            if epoch_idx % 25 == 0:
                scores.append(acc)
        
        return scores
    
    def learn_from(self, xs, true_y):
        pred_y = self.predict(xs)
        d = true_y - pred_y
        self.circuit.compute_grads(d)
        self.circuit.update_grads(self.learning_rate)

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

pn1 = NeuralNetworkClassifier(SimplestPerceptronNetworkCircuit())
scores = pn1.train(data_nls, 2000)
plt.plot(np.arange(0, 2000, 25), scores)
plt.xlabel('iteration #'); plt.ylabel('accurracy')
plt.title("accuracy with training")

Чтобы подтвердить, что обучение действительно является проблемой, мы можем вручную установить веса на значения, которые классифицируют набор данных со 100% точностью. (Мы можем получить такие значения, нарисовав 2 линии: одну, которая отделяет нижний отрицательный (красный) от остальных, так что последний / второй / выходной нейрон классифицирует все, кроме нижних отрицательных точек, как положительные, а вторую - так, чтобы первый нейрон активируется только для двух верхних негативов, но связан большим отрицательным весом со вторым, эффективно вычитая положительную область второго из отрицательной области первого.)

Вот доказательство того, что это работает:

pn2 = NeuralNetworkClassifier(SimplestPerceptronNetworkCircuit())
p1ws = [-5, 1.0, 1.0]
p2ws = [12, 2.0, 1.0, -100.0]
plot_data(data_nls, [
    [p1ws[1], p1ws[2], p1ws[0]],
    [p2ws[1], p2ws[2], p2ws[0]],
])
pn2.circuit.p1.ws = np.array(p1ws)
pn2.circuit.p2.ws = np.array(p2ws)
print("output:", pn2.predict([-8, 8]),
      "sum:", pn2.circuit.y2)
print("accurracy:", pn2.get_accurracy(data_nls))
#>> OUTPUTS:
output: 1 sum: 1
accurracy: 1.0

Так что обучение действительно является проблемой. И этому есть довольно простое объяснение:

Наш код на самом деле не может знать, когда нейрон работает немного лучше, скажем, во время тренировки для получения положительного результата (пока он выдает 0), пока он все еще выдает ноль. -100 кажется таким же хорошим, как -1.2, даже если наша цель - выйти выше 0. На самом деле, даже обучение по предыдущему более простому примеру сработало только потому, что набор данных был очень простым, и потому что в соответствии с выбранной скоростью обучения изменение наклона приводит к значительно разным разделам баллов по классам: нам повезло!

Или, говоря более математически, мы на самом деле не распространяем градиент. Функция f, которая переходит прямо с 0 на 1, не очень хороша для обучения!

Нам нужно что-то подобное, но может быть… непрерывное? Введите сигмовидную функцию:

def sgm(x):
    return 1 / (1 + np.exp(-x))
def dsgm(x):
    s = sgm(x)
    return s * (1 - s)
xs = np.linspace(-10, 10)
plt.plot(xs, sgm(xs), color='g',
         label=r"$\sigma(x) = 1 / (1+e^{-x})$")
plt.plot(xs, dsgm(xs), color='g', linestyle=':',
         label=r"$\sigma'(x) = \sigma(x) (1 - \sigma(x))$")
plt.legend()

С этой привлекательной непрерывной формой и с такой простой для вычисления производной (легко проверить формулу производной, используя основные правила исчисления), чего еще вы могли желать? Итак, давайте применим его в нашей сети из двух нейронов:

class SigmoidNeuronCircuit:
    def __init__(self, ws):
        self.ws = ws
    
    def predict(self, xs):
        self.a = np.sum(xs * self.ws)
        return sgm(self.a)
    
    def compute_grads(self, xs, d):
        self.df_ws = xs * d
class SigmoidNetworkCircuit:
    def __init__(self):
        ws = [np.random.uniform(-0.5, 0.5) for _ in range(7)]
        self.p1 = SigmoidNeuronCircuit([ws[0], ws[1], ws[2]])
        self.p2 = SigmoidNeuronCircuit([ws[3], ws[4], ws[5], ws[6]])
    
    def predict(self, xs):
        self.xs = xs
        self.y1 = self.p1.predict(xs)
        self.p2_inputs = np.r_[xs, self.y1]
        self.y2 = self.p2.predict(self.p2_inputs)
        return 1 if self.y2 >= 0.5 else 0
    def compute_grads(self, d):
        # !! to be called after predict
        d_p2 = d * dsgm(self.p2.a)
        self.p2.compute_grads(self.p2_inputs, d_p2)
        d_p1 = self.p2.ws[3] * d_p2 * dsgm(self.p1.a)
        self.p1.compute_grads(self.xs, d_p1)
    
    def update_grads(self, learning_rate):
        self.p2.ws += learning_rate * self.p2.df_ws
        self.p1.ws += learning_rate * self.p1.df_ws

Остановитесь и изучите приведенный выше код, чтобы по-настоящему понять, что он делает. И если вы заметили, что для выходного слоя мы вычитаем окончательный результат 0,1, а не значение сигмоидной функции: это просто заставляет сеть останавливать обучение после того, как она достигает правильного ответа.

np.random.seed(42)
sn0 = NeuralNetworkClassifier(SigmoidNetworkCircuit(), learning_rate=0.1)
print("accurracy before training:", sn0.get_accurracy(data_nls))
scores = sn0.train(data_nls, 10000)
plt.plot(np.arange(0, 10000, 25), scores)
plt.xlabel('iteration #'); plt.ylabel('accurracy')
plt.title("accuracy with training")
print("accuracy after training:", sn0.get_accurracy(data_nls))
plt.show()
#>> OUTPUTS:
accurracy before training: 0.5714285714285714
accuracy after training: 1.0

Ого!?! Это сработало!! Наша сеть, состоящая всего из двух нейронов с сигмовидной функцией активации, действительно может научиться классифицировать наш нелинейно разделимый набор данных.

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

Но ... ПОЗДРАВЛЯЕМ! Теперь вы вошли в сферу полезных обучаемых нейронных сетей. Да, вы только что подошли к концу, и есть что улучшить даже в приведенных выше примерах. Но исходя из элементарной интуиции из биологии и электронной инженерии, с помощью очень простой математики, вы успешно заново изобрели нейронные сети и алгоритм обучения обратного распространения ошибки. Если бы это был 1957 год, вы бы были известный!

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

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

* Эта статья также доступна в виде записной книжки Google Colab для jupyter:



Здравствуйте, намасте!

… И не забудьте хлопать мне в ладоши, если вам это понравилось! 😈