Простое объяснение того, как они работают и как реализовать их с нуля на Python.

Нейронные сети с нуля в Python

Удивительно, но нейронные сети — не такая уж сложная вещь! Термин «нейронная сеть» часто используется как модное словечко, но на самом деле они зачастую гораздо проще, чем люди себе представляют.

Этот пост предназначен для абсолютных новичков и предполагает НУЛЕВЫЕ предварительные знания машинного обучения. Мы разберемся, как работают нейронные сети, и реализуем одну из них с нуля на Python.

Приступим!

Базовые блоки: нейроны

Сначала поговорим о нейронах, основных единицах нейронной сети. Нейрон принимает входные данные, выполняет с ними математические операции и выдает один выход. Вот как выглядит нейрон с двумя входами:

Здесь происходят 3 вещи. Во-первых, каждый вход умножается на вес:

x₁ → x₁ * w₁

x₂ → x₂ * w₂

Затем все взвешенные входы суммируются вместе со смещением b:

(x₁ * w₁) + (x₂ * w₂) + b

Наконец, сумма пропускается через функцию активации:

y = f(x₁ * w₁ + x₂ * w₂ + b)

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

Сигмоидная функция выводит только числа в диапазоне (0, 1). Можно представить, что она сжимает (−∞, +∞) до (0, 1) — большие отрицательные числа становятся ~0, а большие положительные числа становятся ~1.

Простой пример

Предположим, у нас есть нейрон с двумя входами, который использует сигмоидную функцию активации и имеет следующие параметры:

w = [0, 1]
 b = 4

w = [0, 1] — это просто способ записи w₁ = 0, w₂ = 1 в векторной форме. Теперь давайте подадим на вход нейрона x = [2, 3]. Мы будем использовать скалярное произведение для более краткой записи:

(w ⋅ x) + b = ((w₁ * x₁) + (w₂ * x₂)) + b = 0 * 2 + 1 * 3 + 4 = 7

y = f(w ⋅ x + b) = f(7) = 0.999

Нейрон выдает 0.999 при входных данных x = [2, 3]. Вот и все! Этот процесс передачи входных данных вперед для получения выходных данных называется прямой связью (feedforward).

  1. Программирование нейрона

Время реализовать нейрон! Мы будем использовать NumPy, популярную и мощную вычислительную библиотеку для Python, чтобы помочь нам с математикой:

import numpy as np

def sigmoid(x):
    """Наша функция активации: f(x) = 1 / (1 + e^(-x))"""
    return 1 / (1 + np.exp(-x))

class Neuron:
    def __init__(self, weights, bias):
        self.weights = weights
        self.bias = bias

    def feedforward(self, inputs):
        # Взвешиваем входы, добавляем смещение, затем используем функцию активации
        total = np.dot(self.weights, inputs) + self.bias
        return sigmoid(total)

weights = np.array([0, 1])  # w1 = 0, w2 = 1
bias = 4                    # b = 4
n = Neuron(weights, bias)

x = np.array([2, 3])        # x1 = 2, x2 = 3
print(n.feedforward(x))     # 0.9990889488055994

Узнаёте эти числа? Это пример, который мы только что рассмотрели! Мы получаем тот же ответ — 0.999.

  1. Объединение нейронов в нейронную сеть

Нейронная сеть — это не что иное, как набор нейронов, соединенных вместе. Вот как может выглядеть простая нейронная сеть:

Эта сеть имеет 2 входа, скрытый слой с 2 нейронами (h₁ и h₂) и выходной слой с 1 нейроном (o₁). Обратите внимание, что входами для o₁ являются выходы из h₁ и h₂ — вот что делает ее сетью.

Скрытый слой — это любой слой между входным (первым) слоем и выходным (последним) слоем. Может быть несколько скрытых слоев!

Пример: Прямая связь

Давайте использовать изображенную выше сеть и предположим, что все нейроны имеют одинаковые веса w = [0, 1], одинаковое смещение b = 0 и одинаковую сигмоидную функцию активации. Пусть h₁, h₂, o₁ обозначают выходы нейронов, которые они представляют.

Что произойдет, если мы передадим вход x = [2, 3]?

h₁ = h₂ = f(w ⋅ x + b) = f((0 * 2) + (1 * 3) + 0) = f(3) = 0.9526

o₁ = f(w ⋅ [h₁, h₂] + b) = f((0 * h₁) + (1 * h₂) + 0) = f(0.9526) = 0.7216

Выход нейронной сети для входа x = [2, 3] равен 0.7216. Довольно просто, правда?

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

Программирование нейронной сети: Прямая связь

Давайте реализуем прямую связь для нашей нейронной сети. Вот еще раз изображение сети для справки:

import numpy as np

# ... код из предыдущего раздела здесь

class OurNeuralNetwork:
    '''
    Нейронная сеть с:
    - 2 входами
    - скрытым слоем с 2 нейронами (h1, h2)
    - выходным слоем с 1 нейроном (o1)
    Каждый нейрон имеет одинаковые веса и смещение:
    - w = [0, 1]
    - b = 0
    '''
    def __init__(self):
        weights = np.array([0, 1])
        bias = 0

        # Класс Neuron здесь из предыдущего раздела
        self.h1 = Neuron(weights, bias)
        self.h2 = Neuron(weights, bias)
        self.o1 = Neuron(weights, bias)

    def feedforward(self, x):
        out_h1 = self.h1.feedforward(x)
        out_h2 = self.h2.feedforward(x)

        # Входы для o1 - это выходы из h1 и h2
        out_o1 = self.o1.feedforward(np.array([out_h1, out_h2]))

        return out_o1

network = OurNeuralNetwork()
x = np.array([2, 3])
print(network.feedforward(x))  # 0.7216325609518421

Мы снова получили 0.7216! Похоже, работает.

Допустим, у нас есть следующие измерения:

Имя

Вес (фунты)

Рост (дюймы)

Пол

Алиса

133

65

Ж

Боб

160

72

М

Чарли

152

70

М

Диана

120

60

Ж

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

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

Имя

Вес (минус 135)

Рост (минус 66)

Пол

Алиса

-2

-1

1

Боб

25

6

0

Чарли

17

4

0

Диана

-15

-6

1

Я произвольно выбрал значения сдвига (135 и 66), чтобы числа выглядели красиво. Обычно сдвиг осуществляется на среднее значение.

Функция потерь

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

Мы будем использовать среднеквадратичную ошибку (MSE) в качестве функции потерь:

MSE = (1/n) * Σ(y_true - y_pred)²

Давайте разберемся:

  • n — количество образцов, которое равно 4 (Алиса, Боб, Чарли, Диана).

  • y представляет переменную, которую нужно предсказать, то есть пол.

  • y_true — истинное значение переменной («правильный ответ»). Например, y_true для Алисы будет 1 (женщина).

  • y_pred — предсказанное значение переменной. Это то, что выдает наша сеть.

  • (y_true - y_pred)² известно как квадратичная ошибка. Наша функция потерь просто берет среднее значение по всем квадратичным ошибкам (отсюда и название среднеквадратичная ошибка). Чем лучше наши предсказания, тем меньше наши потери!

Лучшие предсказания = Меньшие потери.

Обучение сети = попытка минимизировать ее потери.

Пример расчета потерь

Допустим, наша сеть всегда выдает 0 — другими словами, она уверена, что все люди — мужчины ?. Каковы будут наши потери?

Имя

y_true

y_pred

(y_true - y_pred)²

Алиса

1

0

1

Боб

0

0

0

Чарли

0

0

0

Диана

1

0

1

MSE = (1/4) * (1 + 0 + 0 + 1) = 0.5

Код: MSE Loss

Вот код для расчета потерь:

import numpy as np

def mse_loss(y_true, y_pred):
    """y_true и y_pred — массивы NumPy одинаковой длины."""
    return ((y_true - y_pred) ** 2).mean()

y_true = np.array([1, 0, 0, 1])
y_pred = np.array([0, 0, 0, 0])

print(mse_loss(y_true, y_pred))  # 0.5

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

Отлично. Двигаемся дальше!

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

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

Для простоты давайте представим, что в нашем наборе данных есть только Алиса:

Имя

Вес (минус 135)

Рост (минус 66)

Пол

Алиса

-2

-1

1

Тогда среднеквадратичная ошибка — это просто квадратичная ошибка Алисы:

MSE = (1/1) * Σ(y_true - y_pred)² = (y_true - y_pred)² = (1 - y_pred)²

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

Тогда мы можем записать потери как многомерную функцию:

L(w₁, w₂, w₃, w₄, w₅, w₆, b₁, b₂, b₃)

Представьте, что мы хотели бы настроить w₁. Как изменится L, если мы изменим w₁? На этот вопрос может ответить частная производная ∂L/∂w₁. Как ее вычислить?

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

Для начала давайте перепишем частную производную через ∂L/∂y_pred * ∂y_pred/∂w₁:

∂L/∂w₁ = ∂L/∂y_pred * ∂y_pred/∂w₁

Это работает благодаря правилу цепочки.
Мы можем вычислить ∂L/∂y_pred, потому что мы вычислили L = (1 - y_pred)² выше:

∂L/∂y_pred = ∂(1 - y_pred)²/∂y_pred = -2(1 - y_pred)

Теперь давайте разберемся, что делать с ∂y_pred/∂w₁. Как и раньше, пусть h₁, h₂, o₁ будут выходами нейронов, которые они представляют. Тогда

y_pred = o₁ = f(w₅h₁ + w₆h₂ + b₃)

f — это сигмоидная функция активации, помните?
Поскольку w₁ влияет только на h₁ (а не на h₂), мы можем написать

∂y_pred/∂w₁ = ∂y_pred/∂h₁ * ∂h₁/∂w₁

∂y_pred/∂h₁ = w₅ * f'(w₅h₁ + w₆h₂ + b₃)

Еще раз правило цепочки.
Мы делаем то же самое для ∂h₁/∂w₁:

h₁ = f(w₁x₁ + w₂x₂ + b₁)

∂h₁/∂w₁ = x₁ * f'(w₁x₁ + w₂x₂ + b₁)

Вы угадали, правило цепочки.
x₁ здесь — вес, а x₂ — рост. Это второй раз, когда мы видим f'(x) (производную сигмоидной функции)! Давайте выведем ее:

f(x) = 1 / (1 + e⁻ˣ)
 f'(x) = e⁻ˣ / (1 + e⁻ˣ)² = f(x) * (1 - f(x))

Мы будем использовать эту красивую форму для f'(x) позже.

Мы закончили! Нам удалось разбить ∂L/∂w₁ на несколько частей, которые мы можем вычислить:

∂L/∂w₁ = ∂L/∂y_pred * ∂y_pred/∂h₁ * ∂h₁/∂w₁

Эта система вычисления частных производных путем обратного распространения ошибки называется обратным распространением ошибки, или «backprop».

Фух. Это было много символов — ничего страшного, если вы все еще немного запутались. Давайте рассмотрим пример, чтобы увидеть это в действии!

Пример: Вычисление частной производной

Мы продолжим делать вид, что в нашем наборе данных есть только Алиса:

Имя

Вес (минус 135)

Рост (минус 66)

Пол

Алиса

-2

-1

1

Давайте инициализируем все веса равными 1, а все смещения равными 0. Если мы выполним прямой проход через сеть, мы получим:

h₁ = f(w₁x₁ + w₂x₂ + b₁) = f(-2 + -1 + 0) = 0.0474
h₂ = f(w₃x₁ + w₄x₂ + b₂) = 0.0474
 o₁ = f(w₅h₁ + w₆h₂ + b₃) = f(0.0474 + 0.0474 + 0) = 0.524

Сеть выдает y_pred = 0.524, что не дает сильного предпочтения ни мужчине (0), ни женщине (1). Давайте вычислим ∂L/∂w₁:

∂L/∂w₁ = ∂L/∂y_pred * ∂y_pred/∂h₁ * ∂h₁/∂w₁

∂L/∂y_pred = -2(1 - y_pred) = -2(1 - 0.524) = -0.952

∂y_pred/∂h₁ = w₅ * f'(w₅h₁ + w₆h₂ + b₃) = 1 * f'(0.0474 + 0.0474 + 0) = f(0.0948) * (1 - f(0.0948)) = 0.249

∂h₁/∂w₁ = x₁ * f'(w₁x₁ + w₂x₂ + b₁) = -2 * f'(-2 + -1 + 0) = -2 * f(-3) * (1 - f(-3)) = -0.0904

∂L/∂w₁ = -0.952 * 0.249 * -0.0904 = 0.0214

Напоминание: мы вывели f'(x) = f(x) * (1 - f(x)) для нашей сигмоидной функции активации ранее.

Мы сделали это! Это говорит нам о том, что если бы мы увеличили w₁, L немного увеличился бы в результате.

Обучение: Стохастический градиентный спуск

Теперь у нас есть все инструменты, необходимые для обучения нейронной сети! Мы будем использовать алгоритм оптимизации, называемый стохастическим градиентным спуском (SGD), который говорит нам, как изменять наши веса и смещения, чтобы минимизировать потери. Это, по сути, просто уравнение обновления:

w₁ ← w₁ - η * ∂L/∂w₁

η — это константа, называемая скоростью обучения, которая контролирует, насколько быстро мы обучаемся. Все, что мы делаем, это вычитаем η * ∂L/∂w₁ из w₁:

  • Если ∂L/∂w₁ положительно, w₁ уменьшится, что приведет к уменьшению L.

  • Если ∂L/∂w₁ отрицательно, w₁ увеличится, что приведет к уменьшению L.

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

Наш процесс обучения будет выглядеть так:

  1. Выберите один образец из нашего набора данных. Это то, что делает его стохастическим градиентным спуском — мы работаем только с одним образцом за раз.

  2. Вычислите все частные производные потерь по отношению к весам или смещениям (например, ∂L/∂w₁, ∂L/∂w₂ и т.д.).

  3. Используйте уравнение обновления, чтобы обновить каждый вес и смещение.

  4. Вернитесь к шагу 1.

Давайте посмотрим на это в действии!

Код: Полная нейронная сеть

Наконец-то пришло время реализовать полную нейронную сеть:

Имя

Вес (минус 135)

Рост (минус 66)

Пол

Алиса

-2

-1

1

Боб

25

6

0

Чарли

17

4

0

Диана

-15

-6

1

      import numpy as np

def sigmoid(x):
    """Сигмоидная функция активации: f(x) = 1 / (1 + e^(-x))"""
    return 1 / (1 + np.exp(-x))

def deriv_sigmoid(x):
    """Производная сигмоиды: f'(x) = f(x) * (1 - f(x))"""
    fx = sigmoid(x)
    return fx * (1 - fx)

def mse_loss(y_true, y_pred):
    """y_true и y_pred — массивы NumPy одинаковой длины."""
    return ((y_true - y_pred) ** 2).mean()

class OurNeuralNetwork:
    """
    Нейронная сеть с:
    - 2 входами
    - скрытым слоем с 2 нейронами (h1, h2)
    - выходным слоем с 1 нейроном (o1)

    *** ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ ***:
    Код ниже предназначен для простоты и обучения, а НЕ для оптимальности.
    Реальный код нейронной сети совсем не похож на этот. НЕ используйте этот код.
    Вместо этого прочитайте/запустите его, чтобы понять, как работает эта конкретная сеть.
    """
    def __init__(self):
        # Веса
        self.w1 = np.random.normal()
        self.w2 = np.random.normal()
        self.w3 = np.random.normal()
        self.w4 = np.random.normal()
        self.w5 = np.random.normal()
        self.w6 = np.random.normal()

        # Смещения
        self.b1 = np.random.normal()
        self.b2 = np.random.normal()
        self.b3 = np.random.normal()

    def feedforward(self, x):
        # x — массив NumPy с 2 элементами.
        h1 = sigmoid(self.w1 * x[0] + self.w2 * x[1] + self.b1)
        h2 = sigmoid(self.w3 * x[0] + self.w4 * x[1] + self.b2)
        o1 = sigmoid(self.w5 * h1 + self.w6 * h2 + self.b3)
        return o1

    def train(self, data, all_y_trues):
        """
        - data — массив NumPy (n x 2), n = количество образцов в наборе данных.
        - all_y_trues — массив NumPy с n элементами.
          Элементы в all_y_trues соответствуют элементам в data.
        """
        learn_rate = 0.1
        epochs = 1000  # количество проходов по всему набору данных

        for epoch in range(epochs):
            for x, y_true in zip(data, all_y_trues):
                # --- Выполняем прямой проход (эти значения понадобятся нам позже)
                sum_h1 = self.w1 * x[0] + self.w2 * x[1] + self.b1
                h1 = sigmoid(sum_h1)

                sum_h2 = self.w3 * x[0] + self.w4 * x[1] + self.b2
                h2 = sigmoid(sum_h2)

                sum_o1 = self.w5 * h1 + self.w6 * h2 + self.b3
                o1 = sigmoid(sum_o1)
                y_pred = o1

                # --- Вычисляем частные производные.
                # --- Обозначения: d_L_d_w1 означает "частная производная L по w1"
                d_L_d_ypred = -2 * (y_true - y_pred)

                # Нейрон o1
                d_ypred_d_w5 = h1 * deriv_sigmoid(sum_o1)
                d_ypred_d_w6 = h2 * deriv_sigmoid(sum_o1)
                d_ypred_d_b3 = deriv_sigmoid(sum_o1)

                d_ypred_d_h1 = self.w5 * deriv_sigmoid(sum_o1)
                d_ypred_d_h2 = self.w6 * deriv_sigmoid(sum_o1)

                # Нейрон h1
                d_h1_d_w1 = x[0] * deriv_sigmoid(sum_h1)
                d_h1_d_w2 = x[1] * deriv_sigmoid(sum_h1)
                d_h1_d_b1 = deriv_sigmoid(sum_h1)

                # Нейрон h2
                d_h2_d_w3 = x[0] * deriv_sigmoid(sum_h2)
                d_h2_d_w4 = x[1] * deriv_sigmoid(sum_h2)
                d_h2_d_b2 = deriv_sigmoid(sum_h2)

                # --- Обновляем веса и смещения
                # Нейрон h1
                self.w1 -= learn_rate * d_L_d_ypred * d_ypred_d_h1 * d_h1_d_w1
                self.w2 -= learn_rate * d_L_d_ypred * d_ypred_d_h1 * d_h1_d_w2
                self.b1 -= learn_rate * d_L_d_ypred * d_ypred_d_h1 * d_h1_d_b1

                # Нейрон h2
                self.w3 -= learn_rate * d_L_d_ypred * d_ypred_d_h2 * d_h2_d_w3
                self.w4 -= learn_rate * d_L_d_ypred * d_ypred_d_h2 * d_h2_d_w4
                self.b2 -= learn_rate * d_L_d_ypred * d_ypred_d_h2 * d_h2_d_b2

                # Нейрон o1
                self.w5 -= learn_rate * d_L_d_ypred * d_ypred_d_w5
                self.w6 -= learn_rate * d_L_d_ypred * d_ypred_d_w6
                self.b3 -= learn_rate * d_L_d_ypred * d_ypred_d_b3

            # --- Вычисляем общие потери в конце каждой эпохи
            if epoch % 10 == 0:
                y_preds = np.apply_along_axis(self.feedforward, 1, data)
                loss = mse_loss(all_y_trues, y_preds)
                print("Эпоха %d потери: %.3f" % (epoch, loss))


# Определяем набор данных
data = np.array([
    [-2, -1],  # Алиса
    [25, 6],   # Боб
    [17, 4],   # Чарли
    [-15, -6], # Диана
])
all_y_trues = np.array([
    1, # Алиса
    0, # Боб
    0, # Чарли
    1, # Диана
])

# Обучаем нашу нейронную сеть!
network = OurNeuralNetwork()
network.train(data, all_y_trues)


# Делаем несколько предсказаний
emily = np.array([-7, -3])  # 128 фунтов, 63 дюйма
frank = np.array([20, 2])   # 155 фунтов, 68 дюймов
print("Эмили: %.3f" % network.feedforward(emily)) # 0.951 - Ж
print("Фрэнк: %.3f" % network.feedforward(frank)) # 0.039 - М
    

Наши потери постоянно уменьшаются по мере обучения сети.

Теперь мы можем использовать сеть для предсказания пола.

Что теперь?

Вы справились! Краткий обзор того, что мы сделали:

  • Представили нейроны, строительные блоки нейронных сетей.

  • Использовали сигмоидную функцию активации в наших нейронах.

  • Увидели, что нейронные сети — это просто нейроны, соединенные вместе.

  • Создали набор данных с весом и ростом в качестве входных данных (или признаков) и полом в качестве выходных данных (или метки).

  • Узнали о функциях потерь и среднеквадратичной ошибке (MSE).

  • Поняли, что обучение сети — это просто минимизация ее потерь.

  • Использовали обратное распространение ошибки для вычисления частных производных.

  • Использовали стохастический градиентный спуск (SGD) для обучения нашей сети.

Еще многое предстоит сделать:

  • Построить свою первую нейронную сеть с Keras.

  • Поэкспериментировать с большими/лучшими нейронными сетями, используя библиотеки машинного обучения, такие как Tensorflow, Keras и PyTorch.

  • Поиграть с нейронной сетью в браузере.

  • Открыть для себя другие функции активации, кроме сигмоиды, например, Softmax.

  • Открыть для себя другие оптимизаторы, кроме SGD.

  • Изучить сверточные нейронные сети (CNN).

  • Изучить рекуррентные нейронные сети (RNN).

Спасибо за чтение! Если было интересно, то подписывайся на мой ТГК с новостями ИИ — Друг Опенсурса

Комментарии (5)


  1. kest70
    05.12.2024 15:14

    А нету ссылок на научные статьи, которые лежат в основе нейросетей?? Можете привести парочку?


    1. BogdanDruk Автор
      05.12.2024 15:14

      здесь много полезного - https://www.researchgate.net/topic/Neural-Networks/publications


  1. gurovic
    05.12.2024 15:14

    в формулах квадратики


    1. BogdanDruk Автор
      05.12.2024 15:14

      Не подскажешь где именно?


  1. VAF34
    05.12.2024 15:14

    Очень интересно и до конца понято. Несколько вопросов по тексту

    1. В разделе "Прямая связь" вторая формула не та, нужна с результатом 0.72

    2. Хотелось бы понять выбор метода уменьшения ошибки, особенности возможных вариантов.

    3.Этот текст хотелось бы видеть в виде одной из первых глав книжки, в которой также понятно объясняли бы

    - работу с текстом. Например, схему сети для перевода двухбуквенных английских предлогов на русский.

     - структуру KAN метода объединяющего точное знание с распознаванием.

    - схему глубокого обучения.