Градиентный спуск — алгоритм, который на первый взгляд кажется тривиальным, но если копнуть глубже, то обнаруживаешь, что он напоминает упрямого альпиниста, спускающегося с горы в тумане. Только вместо компаса у него — частные производные, а вместо карты — функция потерь. Если отбросить метафоры, то в основе градиентного спуска лежит простая идея: найти минимум функции, двигаясь против её градиента. Звучит просто, но дьявол, как всегда, в деталях.
Основное уравнение
Базовая формула градиентного спуска выглядит следующим образом:

где:
— параметры модели (вектор весов),
— скорость обучения (learning rate),
— градиент функции потерь по параметрам модели.
Эта формула отражает суть: параметры обновляются путём движения в направлении, противоположном градиенту. Почему? Потомучто градиент указывает направление наиболее крутого подъёма функции. А нам нужен спуск, не так ли?
Реализация этапа изменения весов модели методом стохастического градиентного спуска Здравствуйте. Я прохожу курс по программированию нейросетей на языке Python. Получил задание с... Алгоритм градиентного спуска на Python для нахождения минимума функции озьмем функцию $f(x, y) = (1 - x)^2 + 100(y - x^2)^2$. Это... Метод градиентного спуска для функции Пытался написать метод градиентного спуска для функции x^2+y^2,вроде код более менее верно... Сделать для градиентного спуска остановку алгоритма при условии. На какой итерации остановится градиентный спуск? Помогите, пожалуйста решить задачу:
Сделайте для градиентного спуска остановку алгоритма, если...
Анатомия градиентного спуска
Аналогия с горой здесь работает идеально. Представьте, что вы находитесь в горах, полностью окутанных туманом. Ваша цель — спуститься в самую низкую точку. Как это сделать, если видимость практически нулевая? Можно определить направление наиболее крутого спуска в текущей точке и сделать шаг в этом направлении. Потом повторить. И ещё раз повторить. Итак, пока не перестанете спускаться. Вот эту простую стратегию и реализует градиентный спуск. Он итеративно вычисляет градиент функции потерь и обновляет параметры модели, пытаясь минимизировать ошибку.
Виды градиентного спуска
Практикующих специалистов градиентный спуск балует разнообразием. В зависимости от того, какой объём данных используется для вычисления градиента, выделяют три основных вида:
1. Пакетный градиентный спуск (Batch Gradient Descent) — использует весь датасет для вычисления градиента на каждом шаге. Это как если бы наш альпинист учитывал информацию со всей горы перед каждым шагом. Точно, но медленно.
2. Стохастический градиентный спуск (Stochastic Gradient Descent) — использует один случайный пример для обновления параметров. Это очень шумно, но иногда помогает выбраться из локальных минимумов. Представьте альпиниста, который смотрит только под ноги и делает быстрые, иногда хаотичные шаги.
3. Мини-пакетный градиентный спуск (Mini-batch Gradient Descent) — золотая середина, использующая подмножество примеров. Этот подход сочетает в себе преимущества обоих предыдущих методов.
Математическая сущность градиента
Градиент функции в точке — это вектор частных производных:

Для функции потерь в задаче линейной регрессии с MSE (Mean Squared Error) это выглядит так:

где — предсказания модели.
Градиент этой функции:

Заметте небольшую оптечатку в формуле выше — это не умышлено, а просто результат быстрого набора.
Проблемы и ограничения
Градиентный спуск не идеален и сталкивается с несколькими препятствиями:
1. Выбор скорости обучения ( ) — слишком маленькое значение приводит к медленной сходимости, слишком большое — к расходимости или "перепрыгиванию" минимума.
2. Застревание в локальных минимумах — если функция потерь не выпуклая, градиентный спуск может застрять в локальном минимуме, не достигнув глобального.
3. Плато и седловые точки — в многомерном пространстве алгоритм может длительно "блуждать" в областях, где градиент близок к нулю.
4. Масштабирование признаков — если признаки имеют разные масштабы, градиентный спуск может быть неэффективным, "раскачиваясь" между измерениями.
Эти проблемы привели к разработке улучшеных версий алгоритма — от добавления момента и адаптивных скоростей обучения до более сложных оптимизаторов типа Adam или RMSProp. В контексте машинного обучения градиентный спуск — как швейцарский нож: простой, универсальный и иногда раздражающе туповатый. Но понимание его математической сути открывает дверь к глубокому пониманию более сложных алгоритмов оптимизации.
Сходимость градиентного спуска на разных типах функций
Когда мы говорим об эфективности градиентного спуска, стоит учитывать тип функции, с которой приходится работать. Представьте, что ваш альпинист бродит по разным горным местностям. Результаты его спусков будут варьироваться в зависимости от рельефа. На выпуклых функциях (convex functions) градиентный спуск ведёт себя как примерный ученик. При правильно подобранной скорости обучения он гарантированно найдёт глобальный минимум. Это связано с тем, что у выпуклых функций есть лишь один минимум — глобальный.
Другое дело — невыпуклые функции, которые в глубоком обучении встречаются чаще, чем хотелось бы. На таких "горных хребтах" алгоритм может застрять в локальных впадинах, думая, что достиг цели. Как отметил Ян Лекун в своей работе "Efficient BackProp", эта проблема особенно актуальна для многослойных нейронных сетей.
Визуализация процесса градиентного спуска
Визуализация — мощный инструмент для понимания того, что происходит под капотом алгоритма.
Представим трёхмерную поверхность, где высота точки соответствует значению функции потерь. Градиентный спуск можно изобразить как тропинку, по которой скатывается шарик (наш набор параметров).
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
# Создаём сетку для функции
x = np.linspace(-5, 5, 100)
y = np.linspace(-5, 5, 100)
X, Y = np.meshgrid(x, y)
# Определяем функцию потерь (например, функция Розенброка)
Z = (1 - X)**2 + 100 * (Y - X[B]2)[/B]2
# Инициализируем точку
path_x, path_y = [2], [2]
learning_rate = 0.001
iterations = 1000
# Градиентный спуск
for i in range(iterations):
# Вычисляем градиент
gradient_x = -2 * (1 - path_x[-1]) + 200 * (path_y[-1] - path_x[-1]**2) * (-2 * path_x[-1])
gradient_y = 200 * (path_y[-1] - path_x[-1]**2)
# Обновляем параметры
new_x = path_x[-1] - learning_rate * gradient_x
new_y = path_y[-1] - learning_rate * gradient_y
# Сохраняем путь
path_x.append(new_x)
path_y.append(new_y)
# Визуализация
fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection='3d')
ax.plot_surface(X, Y, Z, cmap='viridis', alpha=0.8)
ax.plot(path_x, path_y, [Z[int((y+5)*10), int((x+5)*10)] for x, y in zip(path_x, path_y)], 'r-', linewidth=2)
ax.set_xlabel('θ1')
ax.set_ylabel('θ2')
ax.set_zlabel('J(θ)')
plt.title('Визуализация градиентного спуска на функции Розенброка')
plt.show() |
|
Этот код демонстрирует путь градентного спуска на известной функции Розенброка, которая имеет форму изогнутой долины и часто используется для тестирования алгоритмов оптимизации.
Математические доказательства сходимости
Теоретическая сходимость градиентного спуска лежит в основе его надёжности. Для выпуклых функций с липшицевой постоянной (максимальная "крутизна" функции) и скоростью обучения , можно доказать, что:

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

где — случайный шум с нулевым математическим ожиданием.
Исследования, проведенные Леоном Ботту и его командой, показали, что этот шум может быть полезен для избегания некоторых локальных минимумов. В их работе "Entropy-SGD: Biasing Gradient Descent Into Wide Valleys" была предложена модификация SGD, которая специально добавляет контролируемый шум для улучшения генерализации модели. Таким образом, градиентный спуск — не просто примитивный алгоритм спуска с горы, а тонкий математический инструмент со множеством нюансов и вариаций, который лежит в основе большинства современных алгоритмов оптимизации в машином обучении.
Реализация с нуля
Теперь, когда мы разобрались с математическими понятиями, настала пора написать наш собственный градиентный спуск на Python. Не испытывая нехватки в готовых библиотеках для машинного обучения, мы всё равно возвращаемся к реализации алгоритмов с нуля. Почему? Потому что только так можно по-настоящему пощупать все шестерёнки механизма и почувствовать себя демиургом машинного обучения.
Готовим ингредиенты
Для нашего алгоритмического блюда потребуются самые базовые ингредиенты:
Python | 1
2
3
4
5
6
7
| import numpy as np
import matplotlib.pyplot as plt
# Готовим данные для линейной регрессии
np.random.seed(42) # Фиксируем результат для воспроизводимости
X = 2 * np.random.rand(100, 1)
y = 4 + 3 * X + np.random.randn(100, 1) # Линейная зависимость y = 4 + 3x + шум |
|
Этот код создаёт простейший синтетический набор данных с линейной зависимостью и случайным шумом. По-сути, мы сами задаем правильный ответ (коэфициенты 4 и 3), а потом будем просить наш алгоритм их "угадать".
Функции потерь и их градиенты
В машинном обучении мы часто говорим о минимизации ошибки. Для линейной регрессии стандартная функция потерь — среднеквадратичная ошибка (MSE):
Python | 1
2
3
4
5
6
7
8
| def predict(X, theta):
return X.dot(theta)
def compute_cost(X, y, theta):
m = len(y)
predictions = predict(X, theta)
cost = (1 / (2 * m)) * np.sum((predictions - y) ** 2)
return cost |
|
Для градиентного спуска необходимо вычисление градиента, который для MSE в линеной регресии имеет аналитическое решение:
Python | 1
2
3
4
5
| def compute_gradient(X, y, theta):
m = len(y)
predictions = predict(X, theta)
gradient = (1 / m) * X.T.dot(predictions - y)
return gradient |
|
Эти функции — фундамент нашего алгоритма. Функция compute_cost измеряет, насколько модель "промахивается" мимо фактических значений, а compute_gradient указывает направление, в котором нужно корректировать параметры для уменьшения ошыбки.
Базовый градиентный спуск
Теперь реализуем саму сердцевину — функцию градиентного спуска:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
| def batch_gradient_descent(X, y, theta, learning_rate, iterations):
m = len(y)
cost_history = np.zeros(iterations)
theta_history = np.zeros((iterations, len(theta)))
for i in range(iterations):
gradient = compute_gradient(X, y, theta)
theta = theta - learning_rate * gradient
theta_history[i] = theta.T
cost_history[i] = compute_cost(X, y, theta)
return theta, cost_history, theta_history |
|
Этот код выполняет итеративное обновление параметров модели, сдвигая их в направлении, противоположном градиенту. По пути он сохраняет историю параметров и значений функции потерь — это пригодится для визуализации и анализа конвергенции.
Запускаем и смотрим
Для использования градиентного спуска нам нужно подготовить данные и определить начальные параметры:
Python | 1
2
3
4
5
6
7
8
9
10
| # Добавляем столбец единиц для свободного члена
X_b = np.c_[np.ones((100, 1)), X]
theta = np.random.randn(2, 1) # Случайная инициализация параметров
# Запускаем градиентный спуск
learning_rate = 0.1
iterations = 1000
theta_final, cost_history, theta_history = batch_gradient_descent(X_b, y, theta, learning_rate, iterations)
print("Найденные параметры:", theta_final.ravel()) |
|
Результат этого кода должен быть близок к нашим заданным коэфициентам (4 и 3). Но вместо сухих чисел интереснее взглянуть на процесс визуально:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| # Визуализация снижения ошибки
plt.figure(figsize=(10, 6))
plt.plot(range(iterations), cost_history)
plt.xlabel('Итерации')
plt.ylabel('Функция потерь')
plt.title('Градиентный спуск в действии')
plt.grid(True)
plt.show()
# Визуализация найденной прямой
plt.figure(figsize=(10, 6))
plt.scatter(X, y)
plt.plot(X, X_b.dot(theta_final), 'r-')
plt.xlabel('X')
plt.ylabel('y')
plt.title('Найденная модель')
plt.grid(True)
plt.show() |
|
Скорость обучения и её влияние
Скорость обучения (learning rate) — критический гиперпараметр градиентного спуска. Представьте, что вы спускаетесь с горки на санках — маленький толчок, и вы едва сдвинетесь с места; слишком сильный — и рискуете перевернуться.
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| # Эксперимент с разными скоростями обучения
learning_rates = [0.001, 0.01, 0.1, 1.0]
plt.figure(figsize=(12, 8))
for i, lr in enumerate(learning_rates):
# Инициализация теми же параметрами для честного сравнения
theta_init = np.random.randn(2, 1)
theta_final, cost_history, _ = batch_gradient_descent(X_b, y, theta_init, lr, iterations=100)
plt.subplot(2, 2, i+1)
plt.plot(range(100), cost_history)
plt.title(f'Learning rate = {lr}')
plt.xlabel('Итерации')
plt.ylabel('Функция потерь')
plt.grid(True)
plt.tight_layout()
plt.show() |
|
Этот эксперимент наглядно покажет, что слишком малая скорость обучения ведёт к медленной сходимости, а слишком большая может вызвать расходимость алгоритма.
Векторизация вычислений
Одна из ключевых причин использования NumPy — векторизация вычислений. Сравните две реализации вычисления градиента:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| # Неэффективная реализация с циклами
def compute_gradient_naive(X, y, theta):
m, n = X.shape
gradient = np.zeros((n, 1))
predictions = X.dot(theta)
for i in range(m):
for j in range(n):
gradient[j] += (predictions[i] - y[i]) * X[i, j]
return gradient / m
# Векторизованная реализация
def compute_gradient_vectorized(X, y, theta):
m = len(y)
predictions = X.dot(theta)
gradient = (1 / m) * X.T.dot(predictions - y)
return gradient |
|
На больших объёмах данных разница в производительности может быть колоссальной — порядка сотен и тысяч раз!
ООП-подход: создаем класс оптимизатора
Для удобства и расширяемости можно обернуть наш алгоритм в класс:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| class GradientDescentOptimizer:
def __init__(self, learning_rate=0.01, iterations=1000):
self.learning_rate = learning_rate
self.iterations = iterations
self.theta = None
self.cost_history = []
def fit(self, X, y):
m, n = X.shape
self.theta = np.zeros((n, 1))
self.cost_history = np.zeros(self.iterations)
for i in range(self.iterations):
gradient = self._compute_gradient(X, y)
self.theta = self.theta - self.learning_rate * gradient
self.cost_history[i] = self._compute_cost(X, y)
return self
def predict(self, X):
return X.dot(self.theta)
def _compute_cost(self, X, y):
m = len(y)
predictions = self.predict(X)
cost = (1 / (2 * m)) * np.sum((predictions - y) ** 2)
return cost
def _compute_gradient(self, X, y):
m = len(y)
predictions = self.predict(X)
gradient = (1 / m) * X.T.dot(predictions - y)
return gradient |
|
Использовать такой класс гораздо удобнее:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
| # Создаем и обучаем оптимизатор
optimizer = GradientDescentOptimizer(learning_rate=0.1, iterations=1000)
optimizer.fit(X_b, y)
# Выводим результаты
print("Найденные параметры:", optimizer.theta.ravel())
print("Конечное значение функции потерь:", optimizer.cost_history[-1])
# Визуализация результатов
plt.scatter(X, y)
plt.plot(X, optimizer.predict(X_b), 'r-')
plt.show() |
|
Такой объектно-ориентированный подход не только упрощает использование, но и даёт возможность легко расширять функциональность, добавляя новые методы или параметры.
Стохастический и мини-пакетный варианты
Реализация стохастического градиентного спуска отличается тем, что на каждой итерации используется только один случайный пример:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| def stochastic_gradient_descent(X, y, theta, learning_rate, iterations):
m = len(y)
cost_history = np.zeros(iterations)
for i in range(iterations):
# Выбираем случайный пример
random_index = np.random.randint(m)
xi = X[random_index:random_index+1]
yi = y[random_index:random_index+1]
gradient = compute_gradient(xi, yi, theta)
theta = theta - learning_rate * gradient
cost_history[i] = compute_cost(X, y, theta)
return theta, cost_history |
|
А мини-пакетный вариант работает с небольшыми подвыборками:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| def mini_batch_gradient_descent(X, y, theta, learning_rate, iterations, batch_size):
m = len(y)
cost_history = np.zeros(iterations)
for i in range(iterations):
# Перемешиваем данные
indices = np.random.permutation(m)
X_shuffled = X[indices]
y_shuffled = y[indices]
# Проходим по мини-пакетам
for j in range(0, m, batch_size):
end = min(j + batch_size, m)
X_batch = X_shuffled[j:end]
y_batch = y_shuffled[j:end]
gradient = compute_gradient(X_batch, y_batch, theta)
theta = theta - learning_rate * gradient
# Для мониторинга считаем ошибку на всём наборе
cost_history[i] = compute_cost(X, y, theta)
return theta, cost_history |
|
Эти варианты особенно полезны на больших объёмах данных, где полный проход по всему датасету на каждой итерации становится вычислительно затратным.
Написав градиентный спуск с нуля, мы не только лучше понимаем его внутренное устройство, но и получаем возможность экспериментировать с разными модификациями, адаптируя алгоритм под конкретные задачи. Это как собрать двигатель своими руками — после этого уже не страшно заглядывать под капот.
Продвинутые техники оптимизации
Классический градиентный спуск — как старенький "Запорожец": доедет до цели, но медленно, шумно и с кучей проблем по дороге. Продвинутые алгоритмы оптимизации — это современные спортивные авто с адаптивной подвеской, турбонаддувом и системой навигации. Давайте апгрейдим наш алгоритмический транспорт.
Градиентный спуск с моментом (Momentum)
Представьте, что наш алгоритм — шарик, катящийся по холмистой поверхности. Классический градиентный спуск действует так, словно шарик не имеет инерции — на каждом шаге скорость сбрасывается до нуля. А в реальности? Шарик набирает скорость на спуске и по инерции может преодолеть небольшой подъем. Этот физический принцип вдохновил создание градиентного спуска с моментом. Формула обновления выглядит так:
Python | 1
2
3
4
5
6
7
8
| # Инициализация накопленного градиента
v = np.zeros_like(theta)
# Обновление параметров
for i in range(iterations):
gradient = compute_gradient(X, y, theta)
v = beta * v + (1 - beta) * gradient # Накопление момента
theta = theta - learning_rate * v # Обновление параметров |
|
Гиперпараметр beta (обычно 0.9) определяет, какую долю предыдущего градиента сохранять. Момент помогает:
1. Ускорить сходимость в пологих областях
2. Сгладить шумные обновления в SGD
3. Преодолевать локальные минимумы и седловые точки
Полная реализация выглядит так:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
| def gradient_descent_with_momentum(X, y, theta, learning_rate, iterations, beta=0.9):
m = len(y)
cost_history = np.zeros(iterations)
v = np.zeros_like(theta)
for i in range(iterations):
gradient = (1 / m) * X.T.dot(X.dot(theta) - y)
v = beta * v + (1 - beta) * gradient
theta = theta - learning_rate * v
cost_history[i] = (1 / (2 * m)) * np.sum((X.dot(theta) - y) ** 2)
return theta, cost_history |
|
Адаптивный градиентный спуск (AdaGrad)
Один размер редко подходит всем. Почему для всех параметров должна быть одна скорость обучения? AdaGrad позволяет каждому параметру иметь свою скорость, адаптирующуюся в процессе обучения. Основная идея: параметры, которые часто обновляются, должны получать меньшие обновления, а те, что меняются редко — большие.
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
| def adagrad(X, y, theta, learning_rate, iterations, epsilon=1e-8):
m = len(y)
cost_history = np.zeros(iterations)
G = np.zeros_like(theta) # Накопленные квадраты градиентов
for i in range(iterations):
gradient = (1 / m) * X.T.dot(X.dot(theta) - y)
G += gradient**2
adjusted_lr = learning_rate / (np.sqrt(G) + epsilon)
theta = theta - adjusted_lr * gradient
cost_history[i] = (1 / (2 * m)) * np.sum((X.dot(theta) - y) ** 2)
return theta, cost_history |
|
Однако AdaGrad имеет недостаток: накопленные квадраты градиентов (G ) только растут, что может привести к преждевременному замедлению обучения.
RMSProp: улучшение AdaGrad
RMSProp решает проблему AdaGrad с помощью экспоненциально взвешенного среднего:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
| def rmsprop(X, y, theta, learning_rate, iterations, beta=0.9, epsilon=1e-8):
m = len(y)
cost_history = np.zeros(iterations)
G = np.zeros_like(theta)
for i in range(iterations):
gradient = (1 / m) * X.T.dot(X.dot(theta) - y)
G = beta * G + (1 - beta) * gradient**2
adjusted_lr = learning_rate / (np.sqrt(G) + epsilon)
theta = theta - adjusted_lr * gradient
cost_history[i] = (1 / (2 * m)) * np.sum((X.dot(theta) - y) ** 2)
return theta, cost_history |
|
Вместо накопления всех предыдущих квадратов градиентов, RMSProp сохраняет их экспоненциально затухающее среднее, что предотвращает резкое снижение скорости обучения.
Adam: комбинация лучшего из двух миров
Adam (Adaptive Moment Estimation) объединяет момент и адаптивную скорость обучения. Это как супергеройский кроссовер, где каждый привносит свои уникальные способности:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| def adam(X, y, theta, learning_rate, iterations, beta1=0.9, beta2=0.999, epsilon=1e-8):
m = len(y)
cost_history = np.zeros(iterations)
v = np.zeros_like(theta) # Первый момент (момент)
s = np.zeros_like(theta) # Второй момент (RMSProp)
for i in range(iterations):
gradient = (1 / m) * X.T.dot(X.dot(theta) - y)
# Обновляем оценки моментов
v = beta1 * v + (1 - beta1) * gradient
s = beta2 * s + (1 - beta2) * gradient[B]2
# Корректируем смещение
v_corrected = v / (1 - beta1[/B](i+1))
s_corrected = s / (1 - beta2**(i+1))
# Обновляем параметры
theta = theta - learning_rate * v_corrected / (np.sqrt(s_corrected) + epsilon)
cost_history[i] = (1 / (2 * m)) * np.sum((X.dot(theta) - y) ** 2)
return theta, cost_history |
|
Сравнение оптимизаторов
Теория теорией, но что работает лучше на практике? Проведём сравнительную битву оптимизаторов:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| def compare_optimizers(X, y, theta_init, iterations=200):
optimizers = {
'GD': batch_gradient_descent,
'Momentum': gradient_descent_with_momentum,
'AdaGrad': adagrad,
'RMSProp': rmsprop,
'Adam': adam
}
results = {}
plt.figure(figsize=(12, 8))
for name, optimizer in optimizers.items():
theta = theta_init.copy()
if name == 'GD':
theta_final, cost_history = optimizer(X, y, theta, 0.01, iterations)
elif name == 'Momentum':
theta_final, cost_history = optimizer(X, y, theta, 0.01, iterations, 0.9)
elif name == 'AdaGrad':
theta_final, cost_history = optimizer(X, y, theta, 0.1, iterations)
elif name == 'RMSProp':
theta_final, cost_history = optimizer(X, y, theta, 0.01, iterations)
elif name == 'Adam':
theta_final, cost_history = optimizer(X, y, theta, 0.01, iterations)
results[name] = {'theta': theta_final, 'cost_history': cost_history}
plt.plot(range(iterations), cost_history, label=name)
plt.xlabel('Итерации')
plt.ylabel('Функция потерь')
plt.title('Сравнение оптимизаторов')
plt.legend()
plt.grid(True)
plt.show()
return results |
|
Запустим сравнение на нашем наборе данных:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| # Инициализация параметров
theta_init = np.random.randn(2, 1)
# Сравнение
results = compare_optimizers(X_b, y, theta_init)
# Визуализация найденных моделей
plt.figure(figsize=(10, 6))
plt.scatter(X, y)
for name, result in results.items():
plt.plot(X, X_b.dot(result['theta']), label=f'Model: {name}')
plt.xlabel('X')
plt.ylabel('y')
plt.title('Модели, найденные разными оптимизаторами')
plt.legend()
plt.grid(True)
plt.show() |
|
Автоматический выбор скорости обучения
Если подбор скорости обучения вручную каждый раз утомляет, можно использовать алгоритмы для автоматического её поиска:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| def find_optimal_learning_rate(X, y, theta_init, min_lr=1e-5, max_lr=1.0, n_steps=100):
learning_rates = np.logspace(np.log10(min_lr), np.log10(max_lr), n_steps)
losses = []
for lr in learning_rates:
theta = theta_init.copy()
gradient = (1 / len(y)) * X.T.dot(X.dot(theta) - y)
theta_new = theta - lr * gradient
loss = (1 / (2 * len(y))) * np.sum((X.dot(theta_new) - y) ** 2)
losses.append(loss)
plt.figure(figsize=(10, 6))
plt.semilogx(learning_rates, losses)
plt.xlabel('Скорость обучения (log scale)')
plt.ylabel('Функция потерь')
plt.title('Выбор оптимальной скорости обучения')
plt.grid(True)
plt.show()
# Находим минимум
optimal_idx = np.argmin(losses)
optimal_lr = learning_rates[optimal_idx]
print(f"Оптимальная скорость обучения: {optimal_lr}")
return optimal_lr |
|
Оптимизация на сложных функциях потерь
В реальных задачах функция потерь редко бывает такой гладкой, как в нашем учебном примере. Давайте испытаем наши оптимизаторы на более сложной функции:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| def complex_cost_function(X, y, theta):
predictions = X.dot(theta)
errors = predictions - y
# Комбинация MSE и Хьюбера для робастности
delta = 1.0 # Порог для функции Хьюбера
small_errors = np.abs(errors) <= delta
large_errors = ~small_errors
cost = np.sum(errors[small_errors]**2) / 2 + delta * (np.abs(errors[large_errors]) - delta/2)
return cost / len(y)
def complex_gradient(X, y, theta):
predictions = X.dot(theta)
errors = predictions - y
delta = 1.0
small_errors = np.abs(errors) <= delta
gradient = np.zeros_like(theta)
# Для малых ошибок используем градиент MSE
if np.any(small_errors):
gradient += X[small_errors].T.dot(errors[small_errors, np.newaxis])
# Для больших ошибок используем градиент абсолютной ошибки (L1)
if np.any(~small_errors):
signs = np.sign(errors[~small_errors])
gradient += delta * X[~small_errors].T.dot(signs[:, np.newaxis])
return gradient / len(y) |
|
Такие комбинированные функции потерь часто используются в реальных проектах, где данные содержат выбросы, и мы хотим сделать модель более робастной к ним.
Планировщики скорости обучения
Фиксированная скорость обучения — как встать на якорь в бурном море. Иногда удобно, но часто ограничивает. Планировщики скорости обучения (learning rate schedulers) динамически меняют скорость по заданному расписанию. Это помогает быстрее двигаться в начале обучения и точнее настраиваться в конце. Вот пример пошагового уменьшения:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| def step_decay_scheduler(initial_lr, drop_factor=0.5, epochs_drop=10):
def scheduler(epoch):
return initial_lr * drop_factor ** (epoch // epochs_drop)
return scheduler
def gradient_descent_with_scheduler(X, y, theta, initial_lr, iterations, scheduler):
m = len(y)
cost_history = np.zeros(iterations)
for i in range(iterations):
learning_rate = scheduler(i)
gradient = (1 / m) * X.T.dot(X.dot(theta) - y)
theta = theta - learning_rate * gradient
cost_history[i] = (1 / (2 * m)) * np.sum((X.dot(theta) - y) ** 2)
return theta, cost_history |
|
Другой популярный подход — экспоненциальное затухание:
Python | 1
2
3
4
| def exp_decay_scheduler(initial_lr, decay_rate=0.1):
def scheduler(epoch):
return initial_lr * np.exp(-decay_rate * epoch)
return scheduler |
|
Часто также используют косинусное затухание, которое сначала плавно снижает скорость, а затем снова повышает её:
Python | 1
2
3
4
| def cosine_decay_scheduler(initial_lr, iterations):
def scheduler(epoch):
return initial_lr * 0.5 * (1 + np.cos(epoch * np.pi / iterations))
return scheduler |
|
Техники борьбы с переобучением
Градиентный спуск может слишком усердно минимизировать ошибку на обучающих данных, что приводит к переобучению. Существует несколько приёмов, которые помогают с этим бороться:
1. Ранняя остановка (Early Stopping)
Суть в том, чтобы следить за ошибкой на отложенной выборке и останавливать обучение, когда она начинает расти:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| def gradient_descent_with_early_stopping(X_train, y_train, X_val, y_val, theta, learning_rate, max_iterations, patience=10):
m = len(y_train)
train_cost_history = []
val_cost_history = []
best_val_cost = float('inf')
best_theta = theta.copy()
patience_counter = 0
for i in range(max_iterations):
gradient = (1 / m) * X_train.T.dot(X_train.dot(theta) - y_train)
theta = theta - learning_rate * gradient
train_cost = (1 / (2 * m)) * np.sum((X_train.dot(theta) - y_train) ** 2)
val_cost = (1 / (2 * len(y_val))) * np.sum((X_val.dot(theta) - y_val) ** 2)
train_cost_history.append(train_cost)
val_cost_history.append(val_cost)
if val_cost < best_val_cost:
best_val_cost = val_cost
best_theta = theta.copy()
patience_counter = 0
else:
patience_counter += 1
if patience_counter >= patience:
print(f"Раняя остановка на итерации {i}")
break
return best_theta, train_cost_history, val_cost_history |
|
Эта техника позволяет выбрать золотую середину между недообучением и переобучением.
2. Регуляризация при оптимизации
Регуляризация — добавка к функции потерь, которая "штрафует" модель за сложность. Для линейной регрессии это выглядит так:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| def compute_cost_with_regularization(X, y, theta, lambda_reg):
m = len(y)
predictions = X.dot(theta)
cost = (1 / (2 * m)) * np.sum((predictions - y) ** 2)
# L2 регуляризация (не включаем bias term)
reg_term = (lambda_reg / (2 * m)) * np.sum(theta[1:]**2)
return cost + reg_term
def compute_gradient_with_regularization(X, y, theta, lambda_reg):
m = len(y)
predictions = X.dot(theta)
gradient = (1 / m) * X.T.dot(predictions - y)
# Регуляризуем все, кроме bias
reg_gradient = np.zeros_like(theta)
reg_gradient[1:] = (lambda_reg / m) * theta[1:]
return gradient + reg_gradient |
|
А так выглядит оптимизатор с регуляризацией:
Python | 1
2
3
4
5
6
7
8
9
10
| def gradient_descent_with_regularization(X, y, theta, learning_rate, iterations, lambda_reg):
m = len(y)
cost_history = np.zeros(iterations)
for i in range(iterations):
gradient = compute_gradient_with_regularization(X, y, theta, lambda_reg)
theta = theta - learning_rate * gradient
cost_history[i] = compute_cost_with_regularization(X, y, theta, lambda_reg)
return theta, cost_history |
|
Праллельная и распределённая оптимизация
Когда данных становится слышком много, вычисление градиента на одной машине может занимать часы. Пришло время распараллелить наш градиентный спуск!
1. Параллельный градиентный спуск
Самый простой подход — разделить данные на части и распределить их между разными потоками или процессами:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| def parallel_gradient_computation(X, y, theta, num_workers=4):
from concurrent.futures import ProcessPoolExecutor
import numpy as np
m = len(y)
chunk_size = m // num_workers
def compute_partial_gradient(worker_id):
start_idx = worker_id * chunk_size
end_idx = start_idx + chunk_size if worker_id < num_workers - 1 else m
X_chunk = X[start_idx:end_idx]
y_chunk = y[start_idx:end_idx]
predictions = X_chunk.dot(theta)
partial_gradient = (1 / chunk_size) * X_chunk.T.dot(predictions - y_chunk)
return partial_gradient
with ProcessPoolExecutor(max_workers=num_workers) as executor:
gradients = list(executor.map(compute_partial_gradient, range(num_workers)))
# Объединяем частичные градиенты
full_gradient = sum(gradients) / num_workers
return full_gradient |
|
2. Асинхронный стохастический градиентный спуск (ASGD)
В распределённой среде синхронизация может быть узким местом. Асинхронный SGD позволяет работникам обновлять параметры независимо, без ожидания друг друга:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # Это псевдокод для наглядности, в реальности нужны механизмы распределённого хранения параметров
def async_sgd_worker(X, y, shared_theta, worker_id, iterations, learning_rate):
m = len(y)
for i in range(iterations):
# Каждый работник выбирает случайную выборку
idx = np.random.randint(m)
xi = X[idx:idx+1]
yi = y[idx:idx+1]
# Получаем текущие параметры
theta = shared_theta.get()
# Вычисляем градиент и обновляем общие параметры
gradient = (xi.T.dot(xi.dot(theta) - yi))
shared_theta.update(theta - learning_rate * gradient) |
|
На практике такие схемы реализуются с помощью библиотек распределённого обучения, таких как Spark MLlib, Ray или современные фреймворки глубокого обучения.
Оптимизация гиперпараметров
Подбор гиперпараметров (learning rate, момент, регуляризация и т.д.) — это мета-задача оптимизации. Существует несколько подходов:
1. Сетка поиска (Grid Search)
Перебираем все комбинации из заданных наборов значений:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| def grid_search(X_train, y_train, X_val, y_val, param_grid):
best_score = float('-inf')
best_params = {}
# Генерируем все комбинации параметров
import itertools
param_combinations = list(itertools.product(*param_grid.values()))
param_names = list(param_grid.keys())
for combination in param_combinations:
params = dict(zip(param_names, combination))
# Обучаем модель с текущими параметрами
theta_init = np.random.randn(X_train.shape[1], 1)
if params.get('optimizer') == 'gd':
theta, _ = batch_gradient_descent(X_train, y_train, theta_init,
params['learning_rate'], params['iterations'])
elif params.get('optimizer') == 'momentum':
theta, _ = gradient_descent_with_momentum(X_train, y_train, theta_init,
params['learning_rate'], params['iterations'],
params['beta'])
# ... другие оптимизаторы
# Оцениваем на валидационном наборе
predictions = X_val.dot(theta)
score = -np.mean((predictions - y_val) ** 2) # Отрицательное MSE
if score > best_score:
best_score = score
best_params = params
return best_params, best_score |
|
2. Случайный поиск (Random Search)
Вместо перебора всех комбинаций выбираем случайно:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| def random_search(X_train, y_train, X_val, y_val, param_ranges, n_iter=10):
best_score = float('-inf')
best_params = {}
for _ in range(n_iter):
# Генерируем случайные параметры
params = {}
for param_name, param_range in param_ranges.items():
if isinstance(param_range[0], int):
params[param_name] = np.random.randint(param_range[0], param_range[1])
else:
params[param_name] = np.random.uniform(param_range[0], param_range[1])
# ... обучение и оценка как в grid_search
return best_params, best_score |
|
3. Байесовская оптимизация
Это более продвинутый подход, который моделирует функцию "качество модели от гиперпараметров" и выбирает следующие точки для исследования умно:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| def bayesian_optimization(X_train, y_train, X_val, y_val, param_ranges, n_iter=10):
# Этот код требует библиотеки, например, scikit-optimize
from skopt import gp_minimize
from skopt.space import Real, Integer, Categorical
# Определяем пространство поиска
space = []
for param_name, param_range in param_ranges.items():
if isinstance(param_range[0], int):
space.append(Integer(param_range[0], param_range[1], name=param_name))
else:
space.append(Real(param_range[0], param_range[1], name=param_name))
# Функция для оптимизации
def objective(params):
# Преобразуем список параметров в словарь
param_dict = dict(zip([s.name for s in space], params))
# ... обучение модели с этими параметрами
# Возвращаем отрицательную метрику (минимизируем)
return -score
# Запускаем байесовскую оптимизацию
result = gp_minimize(objective, space, n_calls=n_iter, random_state=42)
# Извлекаем лучшие параметры
best_params = dict(zip([s.name for s in space], result.x))
best_score = -result.fun
return best_params, best_score |
|
Практические рекомендации по выбору оптимизатора
После сравнения разных оптимизаторов на множестве задач, у меня сложились такие рекомендации:
1. Для простых задач с малым кол-вом параметров: обычный градиентный спуск или SGD.
2. Для глубоких сетей или сложных функций потерь: Adam или RMSProp.
3. Если данные сильно зашумлены: стохастический градиентный спуск с моментом.
4. Если ресурсы ограничены: мини-пакетный градиентный спуск.
И самое главное — не бойтесь экспериментировать! Часто оптимальный выбор зависит от конкретных данных и задачи.
Практические кейсы
Настало время испытать наши алгоритмы градиентного спуска на реальных боевых задачах. Теория прекрасна, но в ML без практики – как в театре без сцены. Поглядим, как наш математический инструментарий справляется с настоящими проблемами.
Регрессионные задачи на реальных данных
Простые примеры линейной регрессии с одной переменной хороши для понимания основ, но реальные данные гораздо многомернее и шумнее. Возмём классический набор данных о ценах на жильё в Бостоне:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| from sklearn.datasets import load_boston
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
# Загружаем данные и подготавливаем их
boston = load_boston()
X, y = boston.data, boston.target
# Стандартизация признаков
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_b = np.c_[np.ones((X.shape[0], 1)), X_scaled] # Добавляем bias term
# Разделяем на тренировочную и тестовую выборки
X_train, X_test, y_train, y_test = train_test_split(X_b, y, test_size=0.2, random_state=42)
# Инициализируем параметры модели
theta_init = np.random.randn(X_train.shape[1], 1)
# Обучаем модель разными оптимизаторами
optimizers = {
'SGD': stochastic_gradient_descent,
'Momentum': gradient_descent_with_momentum,
'Adam': adam
}
results = {}
learning_rate = 0.01
iterations = 500
for name, optimizer in optimizers.items():
if name == 'SGD':
theta, cost_hist = optimizer(X_train, y_train.reshape(-1, 1), theta_init.copy(), learning_rate, iterations)
elif name == 'Momentum':
theta, cost_hist = optimizer(X_train, y_train.reshape(-1, 1), theta_init.copy(), learning_rate, iterations, beta=0.9)
else:
theta, cost_hist = optimizer(X_train, y_train.reshape(-1, 1), theta_init.copy(), learning_rate, iterations)
# Оцениваем на тестовой выборке
y_pred = X_test.dot(theta)
mse = np.mean((y_pred - y_test.reshape(-1, 1))**2)
rmse = np.sqrt(mse)
results[name] = {'theta': theta, 'rmse': rmse, 'cost_history': cost_hist}
print(f"{name} RMSE: {rmse:.4f}") |
|
Интересно проследить, как разные оптимизаторы справляются с одной и той же задачей. Adam обычно показывает лучшие результаты, но его превосходство не всегда стоит вычислительных затрат для простых линеных моделей.
Градиентный спуск в логистической регрессии
Логистическая регрессия – рабочая лошадка бинарной классификации. Заменив функцию потерь на кросс-энтропию, мы заставим градиентный спуск решать задачи классификации:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| def sigmoid(z):
return 1 / (1 + np.exp(-np.clip(z, -500, 500))) # Клиппинг для избежания переполнения
def logistic_cost(X, y, theta):
m = len(y)
h = sigmoid(X.dot(theta))
cost = -1/m * (y.T.dot(np.log(h)) + (1-y).T.dot(np.log(1-h)))
return cost[0, 0]
def logistic_gradient(X, y, theta):
m = len(y)
h = sigmoid(X.dot(theta))
gradient = X.T.dot(h - y) / m
return gradient
# Загрузим датасет для бинарной классификации
from sklearn.datasets import load_breast_cancer
cancer = load_breast_cancer()
X, y = cancer.data, cancer.target
# Подготовка данных...
[H2][аналогично предыдущему примеру][/H2]
# Обучение с Adam оптимизатором
def adam_logistic(X, y, theta, learning_rate=0.01, iterations=1000, beta1=0.9, beta2=0.999, epsilon=1e-8):
m = len(y)
cost_history = np.zeros(iterations)
v = np.zeros_like(theta)
s = np.zeros_like(theta)
for i in range(iterations):
gradient = logistic_gradient(X, y, theta)
v = beta1 * v + (1 - beta1) * gradient
s = beta2 * s + (1 - beta2) * gradient[B]2
v_corrected = v / (1 - beta1[/B](i+1))
s_corrected = s / (1 - beta2**(i+1))
theta = theta - learning_rate * v_corrected / (np.sqrt(s_corrected) + epsilon)
cost_history[i] = logistic_cost(X, y, theta)
return theta, cost_history |
|
Этот код иллюстрирует, как нетрудно перенастроить наши оптимизаторы на новую функцию потерь. Кстати, это прекрасная демонстрация абстрактой природы градиентного спуска — ему неважно, какую функцию оптимизировать, лишь бы градиент был определён.
Мини-сеть для распознавания рукописных цифр
Нейронки и градиентный спуск — как кофе и сахар: теоретически можно использовать отдельно, но вместе они гораздо эффективнее. Реализуем простую нейронную сеть для классификации цифр MNIST:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| class SimpleNN:
def __init__(self, input_size, hidden_size, output_size):
self.W1 = np.random.randn(input_size, hidden_size) * 0.01
self.b1 = np.zeros((1, hidden_size))
self.W2 = np.random.randn(hidden_size, output_size) * 0.01
self.b2 = np.zeros((1, output_size))
def relu(self, Z):
return np.maximum(0, Z)
def relu_backward(self, dA, Z):
dZ = np.array(dA, copy=True)
dZ[Z <= 0] = 0
return dZ
def softmax(self, Z):
exp_z = np.exp(Z - np.max(Z, axis=1, keepdims=True))
return exp_z / np.sum(exp_z, axis=1, keepdims=True)
def forward(self, X):
self.Z1 = X.dot(self.W1) + self.b1
self.A1 = self.relu(self.Z1)
self.Z2 = self.A1.dot(self.W2) + self.b2
self.A2 = self.softmax(self.Z2)
return self.A2
def backward(self, X, y):
m = X.shape[0]
# Градиент на выходном слое
dZ2 = self.A2 - y
dW2 = self.A1.T.dot(dZ2) / m
db2 = np.sum(dZ2, axis=0, keepdims=True) / m
# Градиент на скрытом слое
dA1 = dZ2.dot(self.W2.T)
dZ1 = self.relu_backward(dA1, self.Z1)
dW1 = X.T.dot(dZ1) / m
db1 = np.sum(dZ1, axis=0, keepdims=True) / m
return dW1, db1, dW2, db2 |
|
Привычная формула градиентного спуска скрывается в методе backward . Это классический пример алгоритма обратного распространения ошибки (backpropagation), который является лишь хитрым способом применения цепного правила дифференцирования.
Обучение нейросетей: оптимальные стратегии
Обучение нейронных сетей – это особое искусство, требующее не только правильного оптимизатора, но и правильной стратегии обучения:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| def train_nn_with_schedule(nn, X, y, epochs=50, batch_size=32, initial_lr=0.1):
m = X.shape[0]
cost_history = []
# Планировшик скорости обучения: косинусное затухание
def lr_schedule(epoch):
return initial_lr * 0.5 * (1 + np.cos(epoch * np.pi / epochs))
for epoch in range(epochs):
# Перемешиваем данные
indices = np.random.permutation(m)
X_shuffled = X[indices]
y_shuffled = y[indices]
learning_rate = lr_schedule(epoch)
epoch_cost = 0
# Мини-пакетное обучение
for i in range(0, m, batch_size):
X_batch = X_shuffled[i:i+batch_size]
y_batch = y_shuffled[i:i+batch_size]
# Прямой проход
y_pred = nn.forward(X_batch)
# Обратный проход
dW1, db1, dW2, db2 = nn.backward(X_batch, y_batch)
# Обновление параметров с момумом
nn.W1 -= learning_rate * dW1
nn.b1 -= learning_rate * db1
nn.W2 -= learning_rate * dW2
nn.b2 -= learning_rate * db2
# Вычисляем кросс-энтропию
batch_cost = -np.sum(y_batch * np.log(y_pred + 1e-8)) / batch_size
epoch_cost += batch_cost * batch_size
epoch_cost /= m
cost_history.append(epoch_cost)
if epoch % 5 == 0:
accuracy = np.mean(np.argmax(nn.forward(X), axis=1) == np.argmax(y, axis=1))
print(f"Epoch {epoch}/{epochs}, Cost: {epoch_cost:.4f}, Accuracy: {accuracy:.4f}, LR: {learning_rate:.4f}")
return cost_history |
|
Здесь мы используем мини-пакетное обучение и косинусное затухание скорости обучения — отличная комбинация для большинства практических задач.
Компьютерное зрение: сверточные сети
В задачах компьютерного зрения градиентный спуск модифицируется для эффективного обучения сверточных нейронных сетей:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # Пример упрощенной сверточной операции
def simple_convolution(image, kernel, stride=1):
k_height, k_width = kernel.shape
i_height, i_width = image.shape
# Вычисляем размеры выходной матрицы
o_height = (i_height - k_height) // stride + 1
o_width = (i_width - k_width) // stride + 1
output = np.zeros((o_height, o_width))
for h in range(o_height):
for w in range(o_width):
h_start = h * stride
w_start = w * stride
output[h, w] = np.sum(
image[h_start:h_start+k_height, w_start:w_start+k_width] * kernel
)
return output |
|
Градиент для каждого ядра свёртки вычисляется через операцию свёртки с повёрнутым на 180° градиентом следующего слоя. Это тот же принцип обратного распространения, но адаптированый для сверточных слоёв.
Обработка языка: рекуррентные сети
Обработка последовательностей, например текста, требует особого подхода. РНС (рекуррентные нейронные сети) используют градиентный спуск с учётом временной зависимости:
Python | 1
2
3
4
5
6
7
8
9
| # Пример простой RNN ячейки
def rnn_cell_forward(x, h_prev, parameters):
Wax = parameters["Wax"]
Waa = parameters["Waa"]
ba = parameters["ba"]
a_next = np.tanh(np.dot(Waa, h_prev) + np.dot(Wax, x) + ba)
return a_next |
|
Обратное распростанение через время (BPTT) — иной способ сказать "вычисляем градиент для каждого шага времени и суммируем".
Распределённое обучение на больших данных
Когда данных слишком много для одной машины, градиентный спуск расширяется до распределённой версии:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| # Псевдокод для асинхронного стохастического градиентного спуска
def parallel_sgd(data_partitions, initial_model, learning_rate, iterations):
model = initial_model # Начальная модель, общая для всех узлов
# Запускаем параллельные процессы
for partition in data_partitions:
process = Process(target=worker_sgd, args=(partition, model, learning_rate, iterations))
process.start()
# Основной цикл сбора обновлений от воркеров
for _ in range(iterations):
# Получаем градиенты от воркеров (асинхронно)
gradients = receive_gradients_from_workers()
# Обновляем общую модель
model = model - learning_rate * gradients
return model |
|
В системах вроде TensorFlow распределённый градиентный спуск забирает на себя большую часть тяжелой логистики синхронизации.
Эти примеры – лишь верхушка айсберга. Алгоритм, родившийся два века назад для чисто математических задач, превратился в универсальный ключ к обучению искуственных нейронных сетей и других моделей машинного обучения. И может быть, когда-нибудь наши искусственные умы, обученные на градиентном спуске, превзойдут своих создателей в понимании алгоритмов оптимизации.
Оптимизация гиперпараметров: тонкая настройка градиентного спуска
Настройка гиперпараметров в машинном обучении — это как игра в рулетку для новичков и шахматы для опытных инженеров. Без методичного подхода вы обречены на слепой перебор с переменным успехом. Рассмотрим более системный подход к оптимизации важнейшего гиперпараметра — скорости обучения:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| def learning_rate_finder(X_train, y_train, model, min_lr=1e-7, max_lr=1.0, beta=0.98):
"""
Реализация метода Leslie Smith для поиска оптимальной скорости обучения
"""
lrs = np.geomspace(min_lr, max_lr, 100) # Логарифмическая шкала
losses = np.zeros_like(lrs)
# Начальные параметры и момент
theta = np.random.randn(X_train.shape[1], 1) * 0.01
v = np.zeros_like(theta)
avg_loss = 0
best_loss = np.inf
for i, lr in enumerate(lrs):
# Вычисляем градиент на батче
indices = np.random.randint(0, X_train.shape[0], 32)
X_batch = X_train[indices]
y_batch = y_train[indices]
# Прямой и обратный проход
gradient = compute_gradient(X_batch, y_batch, theta)
# Обновляем параметры с моментом
v = beta * v + (1 - beta) * gradient
theta = theta - lr * v
# Вычисляем и сглаживаем потери
loss = compute_cost(X_batch, y_batch, theta)
avg_loss = beta * avg_loss + (1 - beta) * loss
smooth_loss = avg_loss / (1 - beta**(i+1))
# Сохраняем результат
losses[i] = smooth_loss
# Останавливаемся, если потери резко растут
if smooth_loss > 4 * best_loss:
break
if smooth_loss < best_loss:
best_loss = smooth_loss
# Визуализируем результаты
plt.figure(figsize=(10, 6))
plt.semilogx(lrs[:i+1], losses[:i+1])
plt.xlabel('Скорость обучения (log scale)')
plt.ylabel('Потери')
plt.title('Поиск оптимальной скорости обучения')
plt.grid(True)
# Ищем "локоть" кривой — точку перед резким ростом потерь
min_grad_idx = np.gradient(np.gradient(smooth_loss[:i+1])).argmax()
suggested_lr = lrs[min_grad_idx]
plt.axvline(x=suggested_lr, color='r', linestyle='--')
plt.text(suggested_lr, min(losses[:i+1]), f'Предлагаемый LR: {suggested_lr:.6f}')
plt.show()
return suggested_lr |
|
Этот подход, популяризированный Лесли Смитом в его работе о циклических скоростях обучения, позволяет визуально определить, при какой скорости обучения модель начинает "терять равновесие".
Рекомендательные системы на основе матричной факторизации
Градиентный спуск играет ключевую роль в алгоритмах рекомендаций. Кейс матричной факторизации для предсказания рейтингов — элегантный пример:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| def matrix_factorization(R, P, Q, K, steps=5000, alpha=0.0002, beta=0.02):
"""
R: матрица рейтингов (пользователи x товары)
P: матрица латентных факторов пользователей
Q: матрица латентных факторов товаров
K: размерность латентного пространства
"""
Q = Q.T
losses = []
for step in range(steps):
# Рассматриваем только ненулевые рейтинги
for i in range(len(R)):
for j in range(len(R[i])):
if R[i][j] > 0:
# Вычисляем ошыбку
eij = R[i][j] - np.dot(P[i,:], Q[:,j])
# Обновляем параметры градиентным спуском
for k in range(K):
P[i][k] += alpha * (2 * eij * Q[k][j] - beta * P[i][k])
Q[k][j] += alpha * (2 * eij * P[i][k] - beta * Q[k][j])
# Вычисляем ошибку на всей матрице
error = 0
for i in range(len(R)):
for j in range(len(R[i])):
if R[i][j] > 0:
error += (R[i][j] - np.dot(P[i,:], Q[:,j]))**2
# Добавляем регуляризацию
for k in range(K):
error += (beta/2) * (P[i][k][B]2 + Q[k][j][/B]2)
losses.append(error)
if step % 100 == 0:
print(f"Итерация {step}, ошыбка: {error:.4f}")
# Проверка на сходимость
if len(losses) > 2 and abs(losses[-1] - losses[-2]) < 0.001:
print("Сходимость достигнута!")
break
return P, Q.T, losses |
|
В эпоху данных, когда персонализация стала святым граалем маркетинга, такие алгоритмы стоят за кулисами Netflix, Amazon и других гигантов рекомендательных систем.
Анализ чувствительности и интерпретация модели
После обучения модели важно понять, насколько она "уверена" в своих предсказаниях и какие факторы на это влияют:
Python | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| def gradient_sensitivity_analysis(X, y, theta, feature_names):
"""
Анализирует чувствительность модели к изменениям во входных признаках
"""
m, n = X.shape
sensitivities = np.zeros(n)
# Вычисляем базовую ошибку
y_pred = X.dot(theta)
base_error = np.mean((y_pred - y)**2)
# Для каждого признака добавляем шум и смотрим на изменение ошибки
noise_level = 0.1
for i in range(n):
X_noisy = X.copy()
X_noisy[:, i] += noise_level * np.std(X[:, i]) * np.random.randn(m)
y_noisy_pred = X_noisy.dot(theta)
noisy_error = np.mean((y_noisy_pred - y)**2)
# Чувствительность - относительное изменение ошибки
sensitivities[i] = (noisy_error - base_error) / base_error
# Визуализируем результаты
plt.figure(figsize=(12, 6))
plt.bar(range(n), sensitivities)
plt.xticks(range(n), feature_names, rotation=90)
plt.xlabel('Признаки')
plt.ylabel('Относительная чувствительность')
plt.title('Анализ чувствительности модели')
plt.tight_layout()
plt.show()
return sensitivities |
|
Этот анализ помогает не только понять, какие признаки важнее всего для модели, но и выявить потенциальные проблемы с устойчивостью.
Заключительные мысли о практическом применении
После нескольких лет работы с градиентным спуском в реальных проектах я заметил, что теория часто расходится с практикой. Стандартные реализации типа "скопировал из учебника" обычно работают на игрушечных примерах, но в реальном мире требуются адаптации:
1. Нормализация данных: Не просто "хорошая практика", а скорее обязательное требование. Ассиметричное распределение признаков может заставить градиентный спуск танцевать брейк-данс вместо плавного спуска.
2. Обработка выбросов: Статистически значимые, но экстремальные значения могут сбить с толку даже адаптивные оптимизаторы.
3. Мониторинг градиентов: Исчезающие или взрывающиеся градиенты — частая проблема в глубоких сетях. Простой анализ распределения градиентов помогает выявить проблему до того, как модель улетит в бесконечность.
4. Практичность vs Математическая чистота: Иногда стохастические или гибридные подходы работают лучше, чем "чистые" реализации алгоритмов из научных статей. Прагматизм побеждает пуризм.
5. Ленивая оценка градиентов: Для огромных наборов признаков можно использовать приближённые методы вычисления градиентов, экономя память и время.
Градиентный спуск — не просто алгоритм, а скорее философия оптимизации, которая пронизывает современное машинное обучение. От классической линейной регрессии до сложнейших архитектур трансформеров — в основе лежит этот элегантный принцип: двигайся небольшими шагами против градиента, и рано или поздно достигнешь цели. Как в жизни, так и в машином обучении — иногда самые простые идеи оказываются самыми мощными.
Нахождение экстремума функции нескольких переменных методом градиентного спуска написал програму для нахождения экстремума функции нескольких переменных методом градиентного... Шаг градиентного спуска. Решение СНУ Здравствуйте, разбираюсь с методами спуска. В данном примере решаю СНУ градиентным спуском... Метод градиентного спуска, для ряда Тейлора помогите нужен код на Python.Формула градиентного спуска Метод градиентного спуска Имеется СЛАУ, но не знаю, как запрограммировать, сам алгоритм есть, надо решить методом... В чем отличия градиентного бустинга от просто бустинга? 4. В чем отличия градиентного бустинга от просто бустинга? Обучение линейного алгоритма бинарной классификации образов с помощью градиентного алгоритма В файле iris_data.py даны обучающие выборки (по вариантам) для обучения
линейного алгоритма... Реализация модели XGBoost с нуля Товарищи знатоки, нужна помощь. Никак не могу понять где именно ошибка типов.
class... Python с нуля Друзья, насколько оправдано изучение питона в связке с джанго для фриланса и последующего... Быстрое изучение python с нуля Здравствуйте!
Посоветуйте пожалуйста небольшую(200-400 страниц) книгу для изучения python.
... Покритикуйте/дополните план изучения Python с нуля Привет всем,
Изучаю Python всего второй день, составил вот такой план на ближайшие 9 недель.
... Учебник для изучения Python с нуля Посоветуйте учебник для изучения языка Python с нуля. Изучение Python с нуля. Получение профессий. Обучение с двух школ сразу Всем Доброго времени суток!
Вообщем решил приобрести новую профессию, внимание упало на питон...
|