Урок 4 - bias&variance problem, регуляризация и переобучение¶
В рамках практической части занятия мы усовершенствуем нашу кастомную реализацию нейронной сети с использованием библиотеки numpy, а также на практике познакомимся с тонкостями обучения нейронных сетей: потребность разделения выборки на обучающую и валидационную, проблемы переобучения и способы борьбы с ним.
Усовершенствуем решение с предыдщуего занятия¶
На прошлом уроке мы реализовали обучение нейронной сети с одним скрытым слоем. Как правило, для решения более сложных задач одного скрытого слоя бывает недостаточно. Реализуем класс нейронной сети, схожий с классом с предыдущего занятия, но позволяющий указать количество слоёв и их размерность при инициализации.
import numpy as np
from tqdm import tqdm
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def sigmoid_derivative(x):
return x * (1 - x)
class NeuralNetwork:
def __init__(self, layer_sizes):
self.num_layers = len(layer_sizes)-1
self.layer_sizes = layer_sizes
# Инициализация весов и смещений
self.weights = [np.random.randn(layer_sizes[i], layer_sizes[i+1]) for i in range(self.num_layers)]
self.biases = [np.zeros((1, layer_sizes[i+1])) for i in range(self.num_layers)]
def forward(self, inputs):
# Прямое распространение
activations = inputs
self.outputs = [activations] # будем сохранять выходы модели на каждом слое для будущего обновления весов
for i in range(self.num_layers):
activations = sigmoid(np.dot(activations, self.weights[i]) + self.biases[i])
self.outputs.append(activations)
return activations
def backward(self, inputs, targets, learning_rate):
output_error = (self.outputs[-1] - targets) * sigmoid_derivative(self.outputs[-1])
deltas = [output_error]
# Посчитаем градиенты методом обратного распространения ошибки
for i in range(self.num_layers - 1, 0, -1):
delta = np.dot(deltas[-1], self.weights[i].T) * sigmoid_derivative(self.outputs[i])
deltas.append(delta)
deltas.reverse()
# Обновим веса
for i in range(self.num_layers):
self.weights[i] -= learning_rate * np.dot(self.outputs[i].T, deltas[i])
self.biases[i] -= learning_rate * np.sum(deltas[i], axis=0)
def train(self, inputs, targets, epochs, learning_rate):
for epoch in tqdm(range(epochs)):
output = self.forward(inputs)
self.backward(inputs, targets, learning_rate)
loss = np.mean(np.square(targets - output))
print(f"Loss: {round(loss, 4)}")
В рамках этого занятия мы будем проверять более сложные гипотезы, поэтому задач с синтетическими данными не будет достаточно, чтобы в полной мере проверить их.
Возьмём в качестве примера более сложную задачу - набор данных с информацией о 422 пациентов, больных диабетом. В рамках исследуемого набора данных мы должны решить задачу регрессии и предсказать количественный показатель прогрессирования диабета. Набор данных содержит следующие признаки, по которым мы должны осуществить предсказания:
- age - возраст в годах;
- sex - пол (2 или 1, соответственно мужской или женский);
- bmi - индекс массы тела челвоека;
- bp - артериальное давление;
- s1 - TC: уровень холестерина в крови;
- s2 - LDL: уровень липопротеидов низкой плотности (?);
- s3 - HDL: уровень липопротеидов высокой плотности (?);
- s4 - TCH: общий уровень холестерина;
- s5 - LTG: уровень триглециридов в крови (?);
- s6 - GLU: уровень сахара в крови;
Для получения небора данных мы воспользуемся библиотекой sklearn - необходимый инструмент любого инженера машинног обучения, содержащий огромное количество реализованных моделей, статистических методов и инструментов для анализа данных и построения пайплайнов алгоритмов машинного обучения. В sklearn уже загружен этот набор данных в качестве тестового датасета, на котором можно проверять различные гипотезы и инструменты.
from sklearn.datasets import load_diabetes
data = load_diabetes(as_frame=True, scaled=False)
# посмотрим на наш набор данных. Сейчас он хранится в виде таблицы в формате pandas DataFrame
data.frame.head()
age | sex | bmi | bp | s1 | s2 | s3 | s4 | s5 | s6 | target | |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 59.0 | 2.0 | 32.1 | 101.0 | 157.0 | 93.2 | 38.0 | 4.0 | 4.8598 | 87.0 | 151.0 |
1 | 48.0 | 1.0 | 21.6 | 87.0 | 183.0 | 103.2 | 70.0 | 3.0 | 3.8918 | 69.0 | 75.0 |
2 | 72.0 | 2.0 | 30.5 | 93.0 | 156.0 | 93.6 | 41.0 | 4.0 | 4.6728 | 85.0 | 141.0 |
3 | 24.0 | 1.0 | 25.3 | 84.0 | 198.0 | 131.4 | 40.0 | 5.0 | 4.8903 | 89.0 | 206.0 |
4 | 50.0 | 1.0 | 23.0 | 101.0 | 192.0 | 125.4 | 52.0 | 4.0 | 4.2905 | 80.0 | 135.0 |
Мы видим, что диапазон значений каждого признака сильно отличается. Это может негативно сказаться на обучении нейронной сети. Отмасштабируем набор данных, используя MinMaxScaler из библиотеки sklearn.
from sklearn.preprocessing import MinMaxScaler
from sklearn.model_selection import train_test_split
X, y = data["data"], data["target"].values.reshape((-1, 1))
scaler_X = MinMaxScaler().fit(X)
scaler_y = MinMaxScaler().fit(y)
X = scaler_X.transform(X)
y = scaler_y.transform(y)
# разделим выборку на обучающую и валидационную
X_train, X_valid, y_train, y_valid = train_test_split(X, y, test_size=0.2, random_state=42)
# создадим нейронную сеть
model = NeuralNetwork([10, 8, 1])
model.train(X_train, y_train, 10000, 0.1)
100%|██████████| 10000/10000 [00:01<00:00, 6079.19it/s]
Loss: 0.0272
# посчитаем ошибку модели на валидационной выборке по метрике MAPE
preds = model.forward(X_valid)
mape = (abs(preds - y_valid) / y_valid).mean()
print("Ошибка модели по метрике MAPE:", round(mape, 3))
Ошибка модели по метрике MAPE: 0.734
# ради интереса сравним нашу модель с матожиданием - т.е. посмотрим, какая будет ошибка,
# если всегда предсказывать просто среднее значение целевой переменной в датасете
mape_mean = (abs(y_valid.mean() - y_valid) / y_valid).mean()
print("Ошибка при мат. ожидании:", round(mape_mean, 3))
Ошибка при мат. ожидании: 0.955
Видно, что модель справляется в два раза выше, чем мат. ожидание. Это далеко не предел и с помощью нескольких трюков и правильного подбора гипперпараметров можно ещё значительно выше повысить точность. Но всё равно можно сделать вывод, что модель научилась делать некоторые обобщения и искать закономерности в данных.
Радии эксперимента попробуем обучить модель на неотмасштабируемых данных
X_nonscaled, y_nonscaled = data["data"], data["target"].values.reshape((-1, 1))
X_train_nonscaled, X_valid_nonscaled, y_train_nonscaled, y_valid_nonscaled = train_test_split(X_nonscaled, y_nonscaled,
test_size=0.2, random_state=42)
# реализуем класс нейронной сети без сигмоиды в выходное слое
# в качестве функции активации в скрытых слоях будем использовать ReLU, т.к. сигмоида не позволяет
# оперировать численными значениями в том диапазоне, который мы хотим научиться предсказывать
def relu(x):
return np.maximum(0, x)
def relu_derivative(x):
return np.where(x > 0, 1, 0)
class RawOutNeuralNetwork(NeuralNetwork):
def __init__(self, layer_sizes):
# Инициализация весов и смещений
self.num_layers = len(layer_sizes) - 1
self.layer_sizes = layer_sizes
self.weights = [np.random.randn(layer_sizes[i], layer_sizes[i+1]) for i in range(self.num_layers)]
self.biases = [np.zeros((1, layer_sizes[i+1])) for i in range(self.num_layers)]
def forward(self, inputs):
# Прямое распространение
activations = inputs
self.outputs = [activations] # будем сохранять выходы модели на каждом слое для будущего обновления весов
for i in range(self.num_layers):
activations = np.dot(activations, self.weights[i]) + self.biases[i]
if i != self.num_layers - 1:
activations = relu(activations)
self.outputs.append(activations)
return activations
def backward(self, inputs, targets, learning_rate):
output_error = self.outputs[-1] - targets
deltas = [output_error]
# Посчитаем градиенты методом обратного распространения ошибки
for i in range(self.num_layers - 1, 0, -1):
delta = np.dot(deltas[-1], self.weights[i].T) * relu_derivative(self.outputs[i])
deltas.append(delta)
deltas.reverse()
model_nonscaled = RawOutNeuralNetwork([10, 8, 1])
model_nonscaled.train(X_train_nonscaled, y_train_nonscaled, 10000, 0.1)
100%|██████████| 10000/10000 [00:00<00:00, 10351.49it/s]
Loss: 10090.1287
preds = model_nonscaled.forward(X_valid_nonscaled)
mape = (abs(preds - y_valid_nonscaled) / y_valid_nonscaled).mean()
print("Ошибка модели по метрике MAPE:", round(mape, 3))
Ошибка модели по метрике MAPE: 0.424
Ошибка модели получилась значительно больше. Посмотрим на распределение предсказаний модели и целевых предсказаний
import seaborn as sns
sns.histplot(y_valid_nonscaled)
<Axes: ylabel='Count'>
sns.histplot(model_nonscaled.forward(X_valid))
<Axes: ylabel='Count'>
Видно, что модель выдаёт совершенно невалидные предсказания, т.к. значения весов при стартовой инициализации сильно меньше входных значений. Попробуйте вручную изменить диапазон значений весов при стартовой инициализации или попробовать заполнить веса нулями/единицами и посмотреть, как это повлияет на точность и распределение предсказаний.
Видно, что модель намного лучше работает при масштабированных входных параметрах, поэтому в дальнейшем мы будем проводить эксперименты с таким вариантом.
Регуляризация¶
Как говорилось в теоретической части занятия, переобучение и проблема баланса между точностью и обобщённостью (bias variance problem) является одной из ключевых проблем машинного обучения. Существует много причин и методов борьбы с переобучением. Одна из фундаментальных техник, работающих как с простыми моделями линейной регрессии, так и с большими моделями - регуляризация.
Суть регуляризации в том, что мы "штрафуем" модель за высокое значение весов, пытаясь тем самым не просто решить задачу минимизации функции ошибки, но и наложить доп. критерий - найти такое значение весов, чтобы не только минимизировать функцию ошибки, но при этом сделать сами значения весов не очень большими.
Очевидно, что такой подход повышает стабильность модели - у нас уменьшается количество случаев, при которых небольшое отклонение от области значений на обучающей выборке приведёт к сильно непохожим результатам.
# визуализируем распределение весов нашей модели без регуляризации
sns.histplot(model.weights[0].flatten())
<Axes: ylabel='Count'>
class RegularizedNN(NeuralNetwork):
def __init_(self, layer_sizes):
super().__init__(layer_sizes)
def backward(self, inputs, targets, learning_rate):
output_error = (self.outputs[-1] - targets) * sigmoid_derivative(self.outputs[-1])
deltas = [output_error]
# Посчитаем градиенты методом обратного распространения ошибки
for i in range(self.num_layers - 1, 0, -1):
delta = np.dot(deltas[-1], self.weights[i].T) * sigmoid_derivative(self.outputs[i])
deltas.append(delta)
deltas.reverse()
# Обновим веса с учетом регуляризации
for i in range(self.num_layers):
# Градиенты для L1 и L2 регуляризации
l1_grad = 0.001 * np.sign(self.weights[i]) # вы можете самостоятельно сделать l1 и l2 penalty передаваемым параметром
l2_grad = 2 * 0.001 * self.weights[i]
self.weights[i] -= learning_rate * (np.dot(self.outputs[i].T, deltas[i]) + l1_grad + l2_grad)
self.biases[i] -= learning_rate * np.sum(deltas[i], axis=0)
model = RegularizedNN([10, 8, 1])
model.train(X_train, y_train, 10000, 0.1)
100%|██████████| 10000/10000 [00:01<00:00, 5577.28it/s]
Loss: 0.0294
# посчитаем ошибку модели на валидационной выборке по метрике MAPE
preds = model.forward(X_valid)
mape = (abs(preds - y_valid) / y_valid).mean()
print("Ошибка модели по метрике MAPE:", round(mape, 3))
Ошибка модели по метрике MAPE: 0.468
sns.histplot(model.weights[0].flatten())
<Axes: ylabel='Count'>
Мы видим, что распределение поменялось: весов с большими значениями стало намного меньше и теперь практически все веса равны к значениям, близкими к нулю. Это значит, что регуляризация работает хорошо. Мы можем подобрать новые гипперпараметры, при которых значение ошибки должно быть меньше.
Переобучение¶
Одной из самых распространенных причин переобучения является слишком большое количество эпох для обучения - модель может уже выучить все закономерности в данных и из-за этого начать подстраиваться под обучающую выборку и пытаться просто "запомнить" все данные в ней. Ранняя остановка обучения может не только повысить точность, но и значительно ускорить время обучение, что важно, когда речь идёт об архитектурах с сотнями миллионами параметров и гигабайтами данных.
class AdvancedNN(RegularizedNN):
def __init__(self, layer_sizes):
super().__init__(layer_sizes)
def train(self, train_data, valid_data, epochs, learning_rate, early_stopping_epochs=10):
X_train, y_train = train_data
X_valid, y_valid = valid_data
prev_valid_loss = float('inf')
plato_epochs = 0
for epoch in tqdm(range(epochs)):
output = self.forward(X_train)
self.backward(X_train, y_train, learning_rate)
valid_loss = np.mean(np.square(y_valid.flatten() - model.forward(X_valid).flatten()))
if abs(prev_valid_loss - valid_loss) < 1e-3:
plato_epochs += 1
else:
plato_epochs = 0
if plato_epochs >= early_stopping_epochs:
break
prev_loss = valid_loss
print(f"Epoch: {epoch+1} | Valid loss: {round(valid_loss, 4)}")
model = AdvancedNN([10, 8, 1])
model.train((X_train, y_train), (X_valid, y_valid), 10000, 0.1)
100%|██████████| 10000/10000 [00:02<00:00, 4562.81it/s]
Epoch: 10000 | Valid loss: 0.028
Добавим ускорение к оптимизатору¶
class MomentumNN(AdvancedNN):
def __init__(self, layer_sizes):
super().__init__(layer_sizes)
# Тут мы будем хранить импульс для ускорения
self.velocity_weights = [np.zeros((layer_sizes[i], layer_sizes[i+1])) for i in range(self.num_layers)]
self.velocity_biases = [np.zeros((1, layer_sizes[i+1])) for i in range(self.num_layers)]
def backward(self, inputs, targets, learning_rate, momentum=0):
output_error = (self.outputs[-1] - targets) * sigmoid_derivative(self.outputs[-1])
deltas = [output_error]
# Посчитаем градиенты методом обратного распространения ошибки
for i in range(self.num_layers - 1, 0, -1):
delta = np.dot(deltas[-1], self.weights[i].T) * sigmoid_derivative(self.outputs[i])
deltas.append(delta)
deltas.reverse()
# Обновим веса с учетом регуляризации и ускорения
for i in range(self.num_layers):
# Градиенты для L1 и L2 регуляризации
l1_grad = 0.001 * np.sign(self.weights[i])
l2_grad = 2 * 0.001 * self.weights[i]
# Обновление весов с использованием ускорения
velocity_weight_update = momentum * self.velocity_weights[i] - learning_rate * (np.dot(self.outputs[i].T, deltas[i]) + l1_grad + l2_grad)
self.weights[i] += velocity_weight_update
self.velocity_weights[i] = velocity_weight_update
velocity_bias_update = momentum * self.velocity_biases[i] - learning_rate * np.sum(deltas[i], axis=0)
self.biases[i] += velocity_bias_update
self.velocity_biases[i] = velocity_bias_update
model = MomentumNN([10, 8, 1])
model.train((X_train, y_train), (X_valid, y_valid), 10000, 0.01)
100%|██████████| 10000/10000 [00:02<00:00, 4143.00it/s]
Epoch: 10000 | Valid loss: 0.0248
# посчитаем ошибку модели на валидационной выборке по метрике MAPE
preds = model.forward(X_valid)
mape = (abs(preds - y_valid) / y_valid).mean()
print("Ошибка модели по метрике MAPE:", round(mape, 3))
Ошибка модели по метрике MAPE: 0.563
Решим задачу классификации¶
До этого мы решали задачу регрессии, в рамках которой нам нужно было предсказать только одно выходное значение. Также мы до этого использовали только MSE в качестве функции ошибки. Но на реальной практике довольно часто возникают задачи, требующие предсказать более одного выходного значения. Как пример - задача классификации, в рамках которой нужно предсказать вероятность отнесения объекта к каждому классу (в качестве предсказанного класса выбирает объект(ы) с наибольшей вероятностью). Соответственно, для подобной задачи нужна другая функция ошибки. Как правило, это кросс-энтропия.
$BCE = -\frac{1}{N} \sum_{i=0}^{N} y_{i} log(ŷ_{i}) + (1 - y_{i})log(1-ŷ_{i})$
Интуитивно, кросс-энтропия описывает, насколько распределение предсказаний модели похоже на реальное распределение классов в наборе данных.
Также, нам необходимо будет изменить нашу выходную функцию активации таким образом, чтобы она предсказывала вероятность отнесения объекта к каждому из классов. В данном случае нам не подходит сигмоидальная функция активации, т.к. она применяется независимо ко всем объектом выходного вектора, а нам необходимо, чтобы функция учитывала, что сумма всех выходов должна быть равна 1 (т.к. сумма всех вероятностей равна единице).
В качестве такой функции обычно используется softmax:
$Softmax(x) = \frac{{e^{x_{i}}}}{\sum_{i=1}^{N}e^{x_{i}}}$
def softmax(x):
exp_x = np.exp(x - np.max(x, axis=1, keepdims=True))
return exp_x / np.sum(exp_x, axis=1, keepdims=True)
# вывод производной софтмакса требует большой подготовительной работы, поэтому пропустим его
def softmax_derivative(x):
s = softmax(x)
return s * (1 - s)
def cross_entropy_loss_derivative(predicted, actual):
N = actual.shape[0]
return (predicted - actual) / N
class NNClassfier(MomentumNN):
def __init__(self, layer_sizes):
super().__init__(layer_sizes)
def forward(self, inputs):
activations = inputs
self.outputs = [activations]
for i in range(self.num_layers):
activations = np.dot(activations, self.weights[i]) + self.biases[i]
if i != self.num_layers - 1:
activations = relu(activations)
else:
activations = softmax(activations)
self.outputs.append(activations)
return activations
def backward(self, inputs, targets, learning_rate, momentum=0.8):
output_error = cross_entropy_loss_derivative(self.outputs[-1], targets) * softmax_derivative(self.outputs[-1])
deltas = [output_error]
for i in range(self.num_layers - 1, 0, -1):
delta = np.dot(deltas[-1], self.weights[i].T) * relu_derivative(self.outputs[i])
deltas.append(delta)
deltas.reverse()
for i in range(self.num_layers):
l1_grad = 0.01 * np.sign(self.weights[i])
l2_grad = 2 * 0.01 * self.weights[i]
velocity_weight_update = momentum * self.velocity_weights[i] - learning_rate * (np.dot(self.outputs[i].T, deltas[i]) + l1_grad + l2_grad)
self.weights[i] += velocity_weight_update
self.velocity_weights[i] = velocity_weight_update
velocity_bias_update = momentum * self.velocity_biases[i] - learning_rate * np.sum(deltas[i], axis=0)
self.biases[i] += velocity_bias_update
self.velocity_biases[i] = velocity_bias_update
В качестве решаемой задачи будем использовать ещё один учебный датасет, представленный в sklearn'е - Iris dataset. Это набор данных для задачи классификации, предоставляющий информацию о ширине и длине лепестка и чашелистника для 100 ирисов трёх различных классов. Целевая задача - предсказание класса ириса.
from sklearn.datasets import load_iris
from sklearn.preprocessing import OneHotEncoder
iris = load_iris(as_frame=True)
X = iris.data
y = iris.target.values.reshape(-1, 1)
X.head()
sepal length (cm) | sepal width (cm) | petal length (cm) | petal width (cm) | |
---|---|---|---|---|
0 | 5.1 | 3.5 | 1.4 | 0.2 |
1 | 4.9 | 3.0 | 1.4 | 0.2 |
2 | 4.7 | 3.2 | 1.3 | 0.2 |
3 | 4.6 | 3.1 | 1.5 | 0.2 |
4 | 5.0 | 3.6 | 1.4 | 0.2 |
color_dict = {0: "r", 1: "g", 2: "b"}
plt.subplot(2,3,1)
plt.scatter(X.values[:, 0], X.values[:, 1], color=[color_dict[y1[0]] for y1 in y])
plt.subplot(2,3, 2)
plt.scatter(X.values[:, 0], X.values[:, 2], color=[color_dict[y1[0]] for y1 in y])
plt.subplot(2,3,3)
plt.scatter(X.values[:, 0], X.values[:, 3], color=[color_dict[y1[0]] for y1 in y])
plt.subplot(2,3,4)
plt.scatter(X.values[:, 1], X.values[:, 2], color=[color_dict[y1[0]] for y1 in y])
plt.subplot(2,3,5)
plt.scatter(X.values[:, 1], X.values[:, 3], color=[color_dict[y1[0]] for y1 in y])
plt.subplot(2,3,6)
plt.scatter(X.values[:, 2], X.values[:, 3], color=[color_dict[y1[0]] for y1 in y])
<matplotlib.collections.PathCollection at 0x79d58719ba90>
Видно, что данные довольно хорошо кластеризируются и каждая комбинация признаков образует чётко-выделяющиеся кластеры. Попробуем обучить на этих данных модель.
# превратим значения целевой переменной в бинарный вектор, где на месте i-ого класса стоит единица
encoder = OneHotEncoder()
y_onehot = encoder.fit_transform(y).toarray()
# Разделение данных на обучающий и тестовый наборы
X_train, X_valid, y_train, y_valid = train_test_split(X, y_onehot, test_size=0.2, random_state=42)
model = NNClassfier([4, 5, 3])
model.train((X_train, y_train), (X_valid, y_valid), epochs=1000, learning_rate=0.1)
100%|██████████| 1000/1000 [00:01<00:00, 955.77it/s]
Epoch: 1000 | Valid loss: 0.0409
print("предсказания модели:", model.forward(X_valid).argmax(axis=1))
предсказания модели: [1 0 2 1 1 0 1 2 1 1 2 0 0 0 0 1 2 1 1 2 0 2 0 2 2 2 2 2 0 0]
acc = (model.forward(X_valid).argmax(axis=1) == y_valid.argmax(axis=1)).mean()
print("точность модели по метрике accuracy:", acc)
точность модели по метрике accuracy: 1.0
Отличный результат.
В рамках этого занятия мы научились основным фундаментальным техникам и подходам, использующимся в машинном обучении. Они будут полезны и актуальны на всех дальнейших этапах знакомства с нейронными сетями и другими ML-алгоритмами. В качестве практики дома рекомендуется поисследовать разработанный код, подобрать оптимальные гипперпараметры для решаемых задач, а также поэкспериментировать и поизучать влияние разных архитектур и стартовых условий на результат. Это эффективное упражнение, которое позволяет в дальнейшем на интуитивном уровне понимать те или иные процессы, происходящие с данными. На следующем занятии мы изучим фреймворк PyTorch, на основе которого реализуются практически все современные модели глубокого обучения, а также попрактикуемся в решении более комплексных задач.