Урок 4 - bias&variance problem, регуляризация и переобучение¶

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

Усовершенствуем решение с предыдщуего занятия¶

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

In [2]:
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 уже загружен этот набор данных в качестве тестового датасета, на котором можно проверять различные гипотезы и инструменты.

In [33]:
from sklearn.datasets import load_diabetes

data = load_diabetes(as_frame=True, scaled=False)
In [34]:
# посмотрим на наш набор данных. Сейчас он хранится в виде таблицы в формате pandas DataFrame

data.frame.head()
Out[34]:
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.

In [35]:
from sklearn.preprocessing import MinMaxScaler
from sklearn.model_selection import train_test_split
In [36]:
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)
In [37]:
# разделим выборку на обучающую и валидационную

X_train, X_valid, y_train, y_valid = train_test_split(X, y, test_size=0.2, random_state=42)
In [38]:
# создадим нейронную сеть

model = NeuralNetwork([10, 8, 1])
In [39]:
model.train(X_train, y_train, 10000, 0.1)
100%|██████████| 10000/10000 [00:01<00:00, 6079.19it/s]
Loss: 0.0272

In [40]:
# посчитаем ошибку модели на валидационной выборке по метрике MAPE

preds = model.forward(X_valid)
mape = (abs(preds - y_valid) / y_valid).mean()
print("Ошибка модели по метрике MAPE:", round(mape, 3))
Ошибка модели по метрике MAPE: 0.734
In [41]:
# ради интереса сравним нашу модель с матожиданием - т.е. посмотрим, какая будет ошибка,
# если всегда предсказывать просто среднее значение целевой переменной в датасете

mape_mean = (abs(y_valid.mean() - y_valid) / y_valid).mean()
print("Ошибка при мат. ожидании:", round(mape_mean, 3))
Ошибка при мат. ожидании: 0.955

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

Радии эксперимента попробуем обучить модель на неотмасштабируемых данных

In [42]:
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)
In [43]:
# реализуем класс нейронной сети без сигмоиды в выходное слое
# в качестве функции активации в скрытых слоях будем использовать 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

In [44]:
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

Ошибка модели получилась значительно больше. Посмотрим на распределение предсказаний модели и целевых предсказаний

In [45]:
import seaborn as sns

sns.histplot(y_valid_nonscaled)
Out[45]:
<Axes: ylabel='Count'>
No description has been provided for this image
In [46]:
sns.histplot(model_nonscaled.forward(X_valid))
Out[46]:
<Axes: ylabel='Count'>
No description has been provided for this image

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

Видно, что модель намного лучше работает при масштабированных входных параметрах, поэтому в дальнейшем мы будем проводить эксперименты с таким вариантом.


Регуляризация¶

Как говорилось в теоретической части занятия, переобучение и проблема баланса между точностью и обобщённостью (bias variance problem) является одной из ключевых проблем машинного обучения. Существует много причин и методов борьбы с переобучением. Одна из фундаментальных техник, работающих как с простыми моделями линейной регрессии, так и с большими моделями - регуляризация.
Суть регуляризации в том, что мы "штрафуем" модель за высокое значение весов, пытаясь тем самым не просто решить задачу минимизации функции ошибки, но и наложить доп. критерий - найти такое значение весов, чтобы не только минимизировать функцию ошибки, но при этом сделать сами значения весов не очень большими.
Очевидно, что такой подход повышает стабильность модели - у нас уменьшается количество случаев, при которых небольшое отклонение от области значений на обучающей выборке приведёт к сильно непохожим результатам.

In [47]:
# визуализируем распределение весов нашей модели без регуляризации

sns.histplot(model.weights[0].flatten())
Out[47]:
<Axes: ylabel='Count'>
No description has been provided for this image
In [48]:
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)
In [49]:
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

In [50]:
# посчитаем ошибку модели на валидационной выборке по метрике MAPE

preds = model.forward(X_valid)
mape = (abs(preds - y_valid) / y_valid).mean()
print("Ошибка модели по метрике MAPE:", round(mape, 3))
Ошибка модели по метрике MAPE: 0.468
In [51]:
sns.histplot(model.weights[0].flatten())
Out[51]:
<Axes: ylabel='Count'>
No description has been provided for this image

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

Переобучение¶

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

In [52]:
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)}")
In [53]:
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

Добавим ускорение к оптимизатору¶

In [54]:
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
In [55]:
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

In [56]:
# посчитаем ошибку модели на валидационной выборке по метрике 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}}}$

In [171]:
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 ирисов трёх различных классов. Целевая задача - предсказание класса ириса.

In [180]:
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)
In [184]:
X.head()
Out[184]:
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
In [210]:
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])
Out[210]:
<matplotlib.collections.PathCollection at 0x79d58719ba90>
No description has been provided for this image

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

In [211]:
# превратим значения целевой переменной в бинарный вектор, где на месте i-ого класса стоит единица

encoder = OneHotEncoder()
y_onehot = encoder.fit_transform(y).toarray()
In [212]:
# Разделение данных на обучающий и тестовый наборы
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

In [214]:
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]
In [215]:
acc = (model.forward(X_valid).argmax(axis=1) == y_valid.argmax(axis=1)).mean()
print("точность модели по метрике accuracy:", acc)
точность модели по метрике accuracy: 1.0

Отличный результат.

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

In [ ]: