Привет, Хабр! Недавно я писал статью «Что такое искусственный интеллект (не нейросети) и какие у него есть виды», в которой кратко рассказал, какие разновидности искусственного интеллекта существуют и чем они отличаются. В этой статье я хочу более подробно рассмотреть нейросети: как они обучаются, как работают и из чего состоят.
Архитектура нейросети
Самым простым примером для начала погружения будет нейросеть, которая угадывает рукописные цифры, поэтому именно её мы и будем рассматривать.
Посмотрите на эти цифры:
Человеческий мозг легко распознаёт, где какая цифра изображена, даже несмотря на то что одна и та же цифра может быть написана очень по-разному, что хорошо видно при наложении:
Но как же заставить программу понимать рукописные цифры? Задача одновременно достаточно простая и довольно сложная. Думаю, понятно, что стандартные подходы алгоритмики тут бессильны, а нейросети будут довольно эффективным вариантом.
Что ж, давайте разберёмся, из чего состоит нейросеть. Как ни странно, нейросеть состоит из нейронов (neurons). У каждого нейрона есть набор входных весов (weights) и коэффициент сдвига (bias). Пока что мы просто объявим класс, а как именно нейрон считает – добавим чуть позже:
import randomclass Neuron: def __init__(self, num_inputs): self.weights = [random.uniform(-0.5, 0.5) for _ in range(num_inputs)] self.bias = random.uniform(-0.5, 0.5)
Для этого примера я буду использовать архитектуру многослойного перцептрона с двумя скрытыми слоями по 12 нейронов.
Перцептрон – это архитектура нейронной сети, в которой нейроны каждого слоя соединены со всеми нейронами следующего слоя. В классическом перцептроне три слоя, но в многослойном перцептроне (MLP) скрытых слоёв может быть несколько – в нашем случае их два. А что это вообще за слои такие?
-
Входной слой. В эти нейроны записываются исходные данные. В нашем примере рассматриваются картинки размером 28×28 пикселей в чёрно-белом формате. Это значит, что на входном слое у нас будет 28×28 = 784 нейрона, соответствующих каждому пикселю изображения: белый пиксель – 1, чёрный – 0, серый – градация серого от 0 до 1.
-
Скрытый слой. Слой, расположенный между входным и выходным, помогает извлекать более сложные признаки и закономерности. Нейронов на этом слое может быть столько, сколько пожелается.
-
Выходной слой. Каждый нейрон этого слоя соответствует одному из ответов, который может дать нейросеть. В нашем примере их будет 10, по числу цифр. Ответом нейросети считается нейрон с самым большим значением – чем оно больше, тем выше уверенность сети в том, что на картинке изображена именно эта цифра. Чтобы превратить выходные значения в вероятности, сумма которых равна 1, применяют функцию softmax, но о ней чуть позже.
Теперь, когда мы знаем, из каких слоёв состоит сеть, соберём нейроны в слой, а слои – в сеть:
class Layer: def __init__(self, num_neurons, num_inputs): self.neurons = [Neuron(num_inputs) for _ in range(num_neurons)]class NeuralNetwork: def __init__(self, sizes): self.layers = [] for i in range(len(sizes) - 1): self.layers.append(Layer(sizes[i + 1], sizes[i]))
Параметр sizes – это список чисел, где каждое число – количество нейронов в соответствующем слое. Например, [784, 12, 12, 10] означает: входной слой (784 нейрона), два скрытых слоя (по 12), выходной слой (10 нейронов). Конструктор NeuralNetwork проходит по парам соседних размеров и создаёт слой за слоем.
Как нейросеть считает: прямой проход
Разберём, как работает одна пара слоёв. Возьмём входной и скрытый слой: во входном уже есть исходные данные изображения, надо получить что-то на выходе скрытого слоя.
Для начала нужно посчитать взвешенную сумму (weighted sum) входного слоя для каждого нейрона скрытого слоя. Рассмотрим подсчёт взвешенной суммы на примере одного нейрона скрытого слоя. Между каждым нейроном входного слоя и рассматриваемого нейрона скрытого слоя есть связь, на картинке изображённая стрелочкой. Она, как и нейрон, характеризуется каким-то числом – весом. Для подсчёта взвешенной суммы нужно найти сумму произведений активации каждого нейрона предыдущего слоя на соответствующий вес связи.
Активация нейрона должна быть в определённом интервале, а взвешенная сумма может выходить за его пределы. Значит, нужно применить функцию, которая приведёт значение в нужный интервал. Эту функцию называют функцией активации (activation function) нейрона. Перед применением функции активации к взвешенной сумме прибавляется коэффициент сдвига (bias), который позволяет сдвигать функцию влево или вправо. Итого: функция активации применяется к сумме взвешенной суммы и коэффициента сдвига.
Наиболее распространены сигмоида, ReLU, Leaky ReLU, ELU, SiLU, гиперболический тангенс. Важно помнить, что область значений у них разная: сигмоида выдаёт числа от 0 до 1, tanh – от –1 до 1, а ReLU и её варианты – от 0 до +∞.
Возьмём для нашего примера сигмоиду и сразу напишем её на Python:
def sigmoid(x): return 1 / (1 + math.exp(-x))
Теперь мы можем добавить в наш нейрон метод forward – он принимает входные сигналы, вычисляет взвешенную сумму, прибавляет сдвиг и пропускает результат через функцию активации:
class Neuron: def __init__(self, num_inputs): self.weights = [random.uniform(-0.5, 0.5) for _ in range(num_inputs)] self.bias = random.uniform(-0.5, 0.5) def forward(self, inputs): total = self.bias for w, i in zip(self.weights, inputs): total += w * i return sigmoid(total)
Добавим forward и в слой, и в сеть:
class Layer: def __init__(self, num_neurons, num_inputs): self.neurons = [Neuron(num_inputs) for _ in range(num_neurons)] def forward(self, inputs): return [n.forward(inputs) for n in self.neurons]class NeuralNetwork: def __init__(self, sizes): self.layers = [] for i in range(len(sizes) - 1): self.layers.append(Layer(sizes[i + 1], sizes[i])) def forward(self, inputs): for layer in self.layers: inputs = layer.forward(inputs) return inputs
Всё вместе теперь работает так: NeuralNetwork.forward передаёт данные в первый слой, тот – во второй, и так до самого выхода. Каждый нейрон внутри слоя делает свою взвешенную сумму и применяет сигмоиду.
Проверим, как это работает (пусть даже со случайными весами предсказание будет бессмысленным – главное, что структура верна):
nn = NeuralNetwork([784, 12, 12, 10])# Сгенерируем случайные пиксели вместо реальной картинкиpixels = [random.random() for _ in range(784)]output = nn.forward(pixels)predicted_digit = output.index(max(output))print(f"Предсказанные вероятности: {[round(p, 3) for p in output]}")print(f"Нейросеть считает, что это цифра: {predicted_digit}")
Мы только что вручную прошагали по всем нейронам – каждый сделал взвешенную сумму, применил сигмоиду, передал результат дальше. Но вы наверняка заметили, что такой код работает очень медленно. В реальности все нейроны одного слоя считаются не по одному, а одновременно – с помощью матриц.
Для матричных операций используют библиотеку NumPy. Она позволяет записать всю сеть гораздо компактнее и быстрее:
import numpy as npdef sigmoid(x): return 1 / (1 + np.exp(-x))class NeuralNetwork: def __init__(self, sizes): self.weights = [np.random.randn(y, x) * 0.01 for x, y in zip(sizes[:-1], sizes[1:])] self.biases = [np.random.randn(y, 1) * 0.01 for y in sizes[1:]] def forward(self, X): for w, b in zip(self.weights, self.biases): X = sigmoid(w @ X + b) return X
Оператор @ в Python – это матричное умножение. Теперь вместо цикла по нейронам мы просто умножаем матрицу весов на вектор входов. Результат получается мгновенно.
Обучение нейросети
Откуда берутся веса и сдвиги?
Представьте, расставить все веса и сдвиги вручную! В нашем примере 784×12 + 12×12 + 12×10 = 9672 веса и 12 + 12 + 10 = 34 сдвига, суммарно 9706 параметров! Понятно, что столько параметров вручную подобрать невозможно – значит, нужно как-то заставить нейросеть самостоятельно подбирать эти параметры.
Для начала возьмём случайные значения всех параметров – результат нейросети будет отвратителен. В идеальном случае на выходном нейроне, соответствующем правильной цифре, должен быть 1, а на остальных – 0. Разницу между предсказанным результатом нейросети и идеальным значением измеряет функция потерь (loss function). В нашем примере, где мы хотим, чтобы выходные значения были 0 или 1, можно использовать среднеквадратичную ошибку (Mean Squared Error, MSE). Напишем её на Python:
def mse_loss(predicted, expected): return sum((p - e) ** 2 for p, e in zip(predicted, expected)) / len(predicted)# Пример: ожидаем, что на картинке цифра 3expected = [0, 0, 0, 1, 0, 0, 0, 0, 0, 0]loss = mse_loss(output, expected)print(f"Ошибка (MSE): {loss}")
Для задач классификации, таких как распознавание цифр, часто более эффективна кросс-энтропия (cross-entropy), которая лучше отражает «уверенность» модели. Чем меньше значение функции потерь, тем точнее работает нейросеть.
Во время обучения мы даём нейросети огромное количество данных (картинки и правильные ответы). Нейросеть прогоняет через себя картинку и вычисляет значение функции потерь. Теперь она знает, насколько плохо справилась, но как корректировать параметры?
Если немного упростить, то нейросеть – это функция, которая принимает 784 аргумента и возвращает 10 значений. А функция потерь принимает на вход 9706 параметров (веса и сдвиги) и возвращает число – ошибку. Оптимальны те параметры, при которых ошибка меньше, значит, лучшими весами будет минимум функции потерь. Но как же найти минимум такой сложной функции с 9706 параметрами?
Градиентный спуск
Здесь в силу вступает алгоритм обратного распространения ошибки (backpropagation). В нашей функции потерь 9706 параметров – такое тяжело представить, так что давайте упростим и представим, что у нас всего 3 параметра. Визуализировать трёхмерное пространство уже не так сложно. Представьте, что функция потерь, которую мы хотим минимизировать, – это ландшафт с горами и долинами. Наша цель – найти самую глубокую точку в этом ландшафте. Алгоритм обратного распространения, по сути, работает как слепой человек, который шагает по этому ландшафту: он делает маленький шаг в направлении наибольшего спуска, то есть туда, где склон самый крутой вниз.
Этот наибольший спуск называется градиентом (gradient). Для каждого веса и сдвига в нейросети алгоритм вычисляет, насколько сильно изменение этого параметра повлияет на общую ошибку.
Давайте добавим в наш класс на NumPy метод train, который делает один шаг градиентного спуска:
def sigmoid_derivative(x): return x * (1 - x)class NeuralNetwork: def __init__(self, sizes): self.weights = [np.random.randn(y, x) * 0.01 for x, y in zip(sizes[:-1], sizes[1:])] self.biases = [np.random.randn(y, 1) * 0.01 for y in sizes[1:]] def forward(self, X): self.activations = [X] for w, b in zip(self.weights, self.biases): X = sigmoid(w @ X + b) self.activations.append(X) return X def train(self, X, y, learning_rate=0.1): # Прямой проход output = self.forward(X) # Обратное распространение delta = (output - y) * sigmoid_derivative(output) for i in reversed(range(len(self.weights))): # Корректируем веса и сдвиги self.weights[i] -= learning_rate * delta @ self.activations[i].T self.biases[i] -= learning_rate * delta if i > 0: # Считаем ошибку для предыдущего слоя delta = self.weights[i].T @ delta * sigmoid_derivative(self.activations[i])
Разберёмся, что здесь происходит. В forward мы теперь сохраняем все промежуточные активации (входы и выходы каждого слоя) – они понадобятся, чтобы понять, какой вклад каждый вес внёс в ошибку. В train мы сначала делаем прямой проход, затем вычисляем дельту на выходном слое (насколько сильно ответ отличается от правильного) и последовательно идём назад, корректируя веса пропорционально их влиянию на общую ошибку.
Попробуем обучить сеть на простейшем примере – логическом XOR. Это классическая задача, с которой не справляется однослойный перцептрон:
# Функция потерь для NumPydef mse_loss(predicted, expected): return np.mean((predicted - expected) ** 2)# Обучающие данные: XORX_train = [ np.array([[0], [0]]), np.array([[0], [1]]), np.array([[1], [0]]), np.array([[1], [1]]),]y_train = [ np.array([[0]]), np.array([[1]]), np.array([[1]]), np.array([[0]]),]nn = NeuralNetwork([2, 4, 1])for epoch in range(10000): for X, y in zip(X_train, y_train): nn.train(X, y, learning_rate=0.5) if epoch % 1000 == 0: loss = sum(mse_loss(nn.forward(X), y) for X, y in zip(X_train, y_train)) print(f"Эпоха {epoch}, ошибка: {loss:.4f}")print("\nРезультаты после обучения:")for X, y in zip(X_train, y_train): pred = nn.forward(X)[0, 0] print(f"{X[0, 0]} XOR {X[1, 0]} = {pred:.3f} (ожидалось {y[0, 0]})")
Как это всё вместе работает на практике?
-
Прямой проход (forward pass): Нейросеть берёт входные данные (например, картинку с цифрой), пропускает их через все слои, вычисляет взвешенные суммы и применяет функции активации, пока не получит результат на выходном слое. Затем сравнивает этот результат с истинным ответом и вычисляет общую ошибку через функцию потерь.
-
Обратный проход (backward pass / backpropagation): Зная ошибку на выходе, алгоритм начинает двигаться назад – от выходного слоя к входному. Он вычисляет, как изменение каждого веса и сдвига на каждом слое повлияло на эту ошибку. Чем больше параметр вложился в большую ошибку, тем сильнее его нужно скорректировать.
-
Коррекция весов и сдвигов: На основе вычисленных градиентов каждый вес и сдвиг немного изменяются в сторону уменьшения ошибки. Величина этого изменения контролируется скоростью обучения (learning rate) – это как размер шага нашего слепого человека. Если шаг слишком большой, можно проскочить минимум; если слишком маленький – идти будем очень долго.
Этот процесс повторяется тысячи, а то и миллионы раз на огромном количестве обучающих примеров. Каждое полное прохождение всего обучающего набора называется эпохой (epoch). С каждой эпохой нейросеть становится всё точнее.
На XOR сеть обучается очень быстро – уже через несколько тысяч эпох ошибка падает практически до нуля. Для полноценного MNIST потребуется больше данных, нейронов и эпох, но принцип ровно тот же.
Возможные проблемы при обучении
Иногда в процессе обучения могут возникать проблемы. Например, нейросеть может застрять в локальном минимуме (local minimum) – это как маленький овраг на нашем ландшафте: самая низкая точка на небольшом участке, но не на всём ландшафте. Или ошибка может начать расти, если скорость обучения (learning rate) слишком велика, и наш человек делает слишком большие шаги, перепрыгивая через минимум. Для решения этих проблем существуют различные оптимизаторы (например, Stochastic Gradient Descent (SGD), Adam, RMSprop) и техники регуляризации (regularization), но их детальное рассмотрение – тема для более глубокого погружения.
Переобучение (Overfitting)
Мы разобрали, как нейросеть учится минимизировать ошибку на обучающих данных. Но что если она не учится обобщать, а просто запоминает? Представьте, что вы готовитесь к экзамену и вместо того, чтобы понять материал, просто заучиваете ответы из прошлых тестов. На старых тестах вы покажете идеальный результат, но, столкнувшись с новыми вопросами, окажетесь в тупике.
Точно так же может произойти и с нейросетью. Она может переобучиться (overfit), то есть запомнить обучающие примеры настолько досконально, что начнёт воспринимать не только общие закономерности (например, как выглядит цифра 2), но и случайный шум каждого конкретного изображения (царапинки, искажения из-за линзы камеры).
В чём проблема?
Переобученная нейросеть будет отлично работать на тех данных, на которых она обучалась, но, столкнувшись с новыми, немного отличающимися данными, её производительность резко упадёт. Она не сможет обобщать полученные знания.
Как это заметить?
Обычно переобучение проявляется так: на обучающих данных ошибка продолжает падать, а на тестовых (которые нейросеть не видела во время обучения) ошибка начинает расти или перестаёт улучшаться.
Что с этим делать?
Для борьбы с переобучением существует несколько стратегий:
-
Больше данных: Чем больше разнообразных примеров увидит нейросеть, тем сложнее ей будет запомнить их все досконально.
-
Регуляризация (regularization): Методы, которые штрафуют нейросеть за слишком сложные модели, заставляя её быть «ленивее» и не подгонять каждый шум.
-
Досрочная остановка (early stopping): Мы следим за ошибкой не только на обучающих данных, но и на валидационных. Как только ошибка на валидационных данных перестаёт падать, мы останавливаем обучение.
Заключение
Мы разобрали, из чего состоит нейросеть, как она выполняет прямой проход, что такое функция потерь и градиентный спуск – и параллельно написали свою собственную нейросеть на Python, от одного нейрона до полного цикла обучения. Надеюсь, эта статья помогла вам заглянуть под капот нейросетей и понять, что в их работе есть только математика и немного кода.
ссылка на оригинал статьи https://habr.com/ru/articles/1052400/