Files
neurocomputers-python/lab1/1.2_pytorch_solution.ipynb

35 KiB

ЛАБОРАТОРНАЯ РАБОТА №1.2

ИЗУЧЕНИЕ ОСНОВНЫХ ПОНЯТИЙ ТЕОРИИ ИСКУССТВЕННЫХ НЕЙРОННЫХ СЕТЕЙ

Цель работы: изучение основных понятий теории искусственных нейронных сетей на примере простых задач распознавания логических функций («И», «ИЛИ», «исключающее ИЛИ»).

Импорт библиотек:

import numpy as np
import torch
import matplotlib.pyplot as plt
from IPython.display import clear_output

from torch import nn

%matplotlib inline

В заключительной части данной лабораторной работы мы применяем PyTorch — современный фреймворк для научных вычислений и глубокого обучения в Python.

PyTorch во многом напоминает NumPy по принципам работы и синтаксису:

  • оперирует многомерными массивами (в PyTorch они называются тензорами, или Tensors), которые являются прямым аналогом ndarray из NumPy;
  • поддерживает похожий синтаксис для базовых операций: индексация, срезы, арифметические действия;
  • реализует векторизованные вычисления и механизм broadcasting (широковещательное сложение);
  • позволяет выполнять стандартные математические операции с интуитивно понятными методами (например, tensor.mean() в PyTorch аналогичен array.mean() в NumPy).

Некоторые основные компоненты PyTorch:

  1. Тензоры (Tensors)
    • многомерные массивы с поддержкой вычислений на GPU;
    • аналог numpy.ndarray, но с расширенными возможностями для машинного обучения.
  2. Автоматическое дифференцирование (Autograd)
    • механизм для автоматического вычисления градиентов;
    • критически важен для обучения нейронных сетей (реализует алгоритм обратного распространения ошибки).
  3. Модули (nn.Module)
    • базовые строительные блоки для создания нейронных сетей;
    • включают слои, функции активации, функции потерь и др.
  4. Оптимизаторы (optim)
    • реализации популярных алгоритмов оптимизации: SGD, Adam, RMSprop и др.;
    • упрощают обновление параметров модели.

Ключевые возможности PyTorch:

  • Создание и обучение нейронных сетей любой сложности — от простых перцептронов до трансформеров.
  • Вычисления на CPU и GPU с минимальным изменением кода (достаточно переместить тензоры на устройство cuda).
  • Динамические вычислительные графы — в отличие от статических графов в некоторых других фреймворках, PyTorch строит граф операций «на лету», что упрощает отладку и эксперименты.
  • Гибкость в проектировании архитектур — легко создавать кастомные слои и модели, переопределять методы обратного прохода.

Преимущества для исследований и разработки:

  • Интуитивный интерфейс, близкий к NumPy — низкий порог входа для тех, кто знаком с NumPy.
  • Гибкая система наследования — возможность создавать собственные модули и слои.
  • Обширное сообщество — множество туториалов, примеров, библиотек и форумов.
  • Совместимость с другими библиотеками — лёгкая интеграция с NumPy, SciPy, Matplotlib и др.
  • Производительность — ускорение вычислений на GPU (в 10–100 раз по сравнению с CPU для крупных задач).

Официальная документация: https://pytorch.org/docs/stable/index.html

В качестве примера вышесказанного переписанная на PyTorch метрика accuracy:

def accuracy(y_pred, y_true):
    return torch.sum(y_pred == y_true) / len(y_true)

Содержание:

1. Решение задачи логического «И»
2. Решение задачи логического «ИЛИ»
3. Решение задачи логического «исключающего ИЛИ»

1. Решение задачи логического «И»

Представим входные данные для решения задачи логического «И» в виде тензора X_data.

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

X_data = torch.tensor([[0, 0], [0, 1], [1, 0], [1, 1]]).float()
print(X_data)

Аналогичная операция с выходным вектором:

y_and_data = torch.tensor([0, 0, 0, 1]).reshape(-1, 1).float()
print(y_and_data)

Создание нейронной сети в PyTorch: последовательная модель

Один из самых простых и наглядных способов создать нейронную сеть в PyTorch — использовать nn.Sequential. Это контейнер, который организует слои в последовательный пайплайн: данные проходят через каждый слой по порядку, от входа к выходу — выход предыдущего слоя автоматически становится входом следующего.

Создадим данным способом нейронную сеть model_seq с одним слоем нейронов.

Пояснения к выбранным слоям в её архитектуре:

  1. nn.Linear(in_features=2, out_features=1) — полностью соединённый (плотный, полносвязный) слой.

Параметры:

  • in_features=2 — размерность входного вектора (2 признака, соответствующие входам логической операции);
  • out_features=1 — размерность выхода (1 нейрон для итогового результата).

Математическая операция:
\[ y = W \cdot X + b, \]

  1. nn.Sigmoid() — функция активации (сигмоида).

После создания экземпляр model_seq можно:

  • вызывать как функцию: model_seq(X_data) — прямой проход через сеть;
  • получать параметры: model_seq.parameters() — для оптимизации;
  • выводить структуру: печать model_seq покажет последовательность слоёв.
model_seq = nn.Sequential(
    nn.Linear(in_features=2, out_features=1),
    nn.Sigmoid()
)

model_seq

Переберём все обучаемые параметры сети model_seq — результатом будет список тензоров, содержащих исходные веса и смещения сети:

[x for x in model_seq.parameters()]

Обратите внимание в выводе на флаг requires_grad=True.

Он указывает, что для этого тензора в процессе обратного распространения ошибки нужно будет вычислять градиенты (производные) всех математических операций, через которые далее в коде этот тензор проходит. Без этого флага параметры не будут обновляться при обучении. Флаг при задании тензора также можно переключать на False.

Как это работает:

  1. Во время прямого прохода (forward) PyTorch строит вычислительный граф, запоминая операции с тензорами, у которых requires_grad=True.
  2. При вызове loss.backward() автоматически вычисляются градиенты по всем параметрам с requires_grad=True, при этом значение каждое градиенты сохраняются в атрибуте .grad тензора.
  3. Оптимизатор (объект, реализующий алгоритм обновления параметров модели на основе вычисленных градиентов — например, SGD) использует эти градиенты для обновления параметров.

Рассмотрим всё на примере.

Выберем в качестве оптимизатора параметров model_seq SGD (с достаточно большим шагом обучения lr для наглядности) и среднеквадратичную функцию потерь:

optimizer = torch.optim.SGD(model_seq.parameters(), lr=1.5)
criterion = nn.MSELoss()

Пропустим входные данные X_data через model_seq:

z = model_seq(X_data)
z

Атрибут grad_fn=<SigmoidBackward0> в выводе обозначает:

  • Тензор z — результат работы сигмоиды в последнем слое сети;
  • PyTorch «запомнил» эту операцию в своём вычислительном графе;
  • При обратном распространении ошибки градиенты будут корректно рассчитаны по формуле производной сигмоиды и переданы дальше в сеть.

Рассчитаем значение функции потерь:

loss = criterion(z, y_and_data)
loss

В атрибуте grad_fn=<SigmoidBackward0> аналогично видно, что в вычислительном графе к тензорам применена функция среднеквадратичной ошибки.

Вычислим градиенты функции потерь по всем параметрам модели согласно вычислительному графу:

loss.backward()

Обновим с помошью оптимизатора все параметры сети:

optimizer.step()

Посмотрим значения обновлённых параметров:

[x for x in model_seq.parameters()]

Метод optimizer.zero_grad() вызывается обязательно перед каждым последующим обратным проходом (loss.backward()) в цикле обучения для обнуления градиентов всех параметров модели, управляемых этим оптимизатором.

Почему это необходимо?

В PyTorch градиенты по умолчанию накапливаются (суммируются) при каждом вызове loss.backward(). Если не обнулять градиенты:

  • значения градиентов будут расти с каждой итерацией;
  • веса модели начнут обновляться некорректно (с учётом «старых» градиентов);
  • возможен эффект «взрывных градиентов» (gradients explode), когда значения становятся настолько большими, что превращаются в NaN (не число).
optimizer.zero_grad()

Сделайте несколько проходов (итераций) от первого шага обратного распространения ошибки до данной ячейки. Убедитесь, что ошибка сети loss понижается.

Рассмотрим ещё один способ создания сети в PyTorch.

Создание нейронной сети в PyTorch: через ООП

В PyTorch принято определять архитектуры нейронных сетей как классы, наследующие от nn.Module. Это позволяет:

  • структурировать код в виде повторно используемых компонентов;
  • чётко разделять инициализацию параметров (__init__) и прямой проход (forward);
  • легко расширять и модифицировать модели;
  • использовать встроенные механизмы PyTorch (оптимизаторы, сохранение/загрузка моделей).

Пример сети с одним полносвязным слоем и сигмоидой в качестве функции активации:

class OneLayerNetwork(nn.Module):
    def __init__(self, n_inputs=2, n_outputs=1):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(in_features=n_inputs, out_features=n_outputs),
            nn.Sigmoid()
        )

    def forward(self, X):
        return self.model(X)

Сейчас все слои для удобства по-прежнему собраны в nn.Sequential. Чтобы повысить гибкость модели, в методе __init__ можно по отдельности задать все необходимые слои, а в forward — определять порядок их применения. Такой способ даёт больше контроля над архитектурой (пример разберём в одной из следующих лабораторных работ).

Сигмоиду здесь можно также заменить на nn.ReLU или другую функцию активации — но для отделения от экземплятор с исходной архитектурой создайте другой класс (например, OneLayerReLUNetwork())

Создадим экземпляр однослойной сети этого класса для решения задачи логического «И»:

model_one_layer = OneLayerNetwork()
model_one_layer

По аналогии с прошлым ноутбуком зададим

  • данные для обучения (входные и выходные);
  • экземпляр нейронной сети (через последовательность nn.Sequential() или класс OneLayerNetwork());
  • количество эпох или итераций в процессе обучения (желательно больше 5);
  • скорость обучение и коэффициент импульса;
  • оптимизатор (попробуйте разные алгоритмы — torch.optim.SGD(), torch.optim.Adam() и другие) (значение learning_rate передаётся в lr) (учтите, что в torch.optim.Adam() нет импульса);
  • функцию потерь (можно оставить nn.MSELoss(), однако поскольку решается задача бинарной классификации, можно выбрать бинарную кросс-энтропию nn.BCELoss()).

Примечание. Вы можете использовать для обучения сети либо код ниже, либо модифицированный на его основе — например, реализовать функцию обучения нейронной сети и проверки её работы.

y_true = y_and_data

# Перебор seed для инициализации параметров
torch.manual_seed(seed=42)

model = # Ваш код здесь

epochs = # Ваш код здесь

learning_rate = # Ваш код здесь
momentum = # Ваш код здесь

optimizer = # Ваш код здесь
criterion = # Ваш код здесь

Обучение нейронной сети для решения задачи логического «И»:

loss_history = []

for epoch in range(epochs):
    
    # Метод .train() переводит модель в режим обучения
    model.train()

    optimizer.zero_grad()

    z = model(X_data)
    model_answer_interpretation = (z >= 0.5).float()

    loss = criterion(z, y_true)
    
    # Метод .item() извлекает скалярное значение из тензора loss
    loss_history.append(loss.item())

    loss.backward()

    optimizer.step()
    
    # Метод .eval() переводит модель в режим валидации/тестирования (об этом в следующей лабораторной)
    model.eval()

    if (epoch + 1) % 5 == 0:

        clear_output(True)
        plt.plot(range(1, epoch+2), loss_history, label='Loss')
        plt.title(f'Epoch: {epoch + 1}, Loss: {loss:.6f}')
        plt.grid(True, alpha=0.3)
        plt.legend(loc='best')
        plt.show()

        print('Test:')
        for i in range(len(X_data)):
            # Метод .detach() создаёт копию тензора, отсоединённую от вычислительного графа,
            # чтобы избежать ненужного вычисления градиентов для исходного тензора
            print(f'Input: {X_data.numpy()[i]}, Output: {torch.round(z[i].detach() * 100).numpy() / 100}, Prediction: {model_answer_interpretation[i].numpy()}, True: {y_true[i].numpy()}')

        print(f'Accuracy: {accuracy(model_answer_interpretation, y_true):.2f}')

Проверим качество обучения сети:

assert accuracy(model_answer_interpretation, y_and_data) == 1
assert np.mean(loss_history[:5]) > np.mean(loss_history[-5:])

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

2. Решение задачи логического «ИЛИ»

По аналогии с задачей логического «И» решите задачу логического «ИЛИ».

Выходные данные:

y_or_data = torch.tensor([0, 1, 1, 1]).reshape(-1, 1).float()
print(y_or_data)
# Ваш код здесь
# Ваш код здесь

Проверьте качество обучения сети:

assert accuracy(model_answer_interpretation, y_or_data) == 1
assert np.mean(loss_history[:5]) > np.mean(loss_history[-5:])

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

3. Решение задачи логического «исключающего ИЛИ»

Выходные данные:

y_xor_data = torch.tensor([0, 1, 1, 0]).reshape(-1, 1).float()
print(y_xor_data)

Напомним, что однослойной сетью задачу «исключающего ИЛИ» решить нельзя. В нейронную сеть требуется добавить ещё один слой с как минимум двумя нейронами.

Модифицируем для этого класс OneLayerNetwork() в класс TwoLayersNetwork() — в последовательсти в self.model добавим ещё один полносвязный слой с сигмоидой для активации. Добавлен также параметр n_hiddens — количество нейронов в скрытом слое. Обратите внимание на задание размерности в последовательности слоёв.

class TwoLayersNetwork(nn.Module):
    def __init__(self, n_inputs=2, n_hiddens=2, n_outputs=1):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(in_features=n_inputs, out_features=n_hiddens),
            nn.Sigmoid(),
            nn.Linear(in_features=n_hiddens, out_features=n_outputs),
            nn.Sigmoid()
        )

    def forward(self, X):
        return self.model(X)

Создадим экземпляр двухслойной сети этого класса для решения задачи «исключающего ИЛИ»:

model_two_layers = TwoLayersNetwork()
model_two_layers

По аналогии с задачами логического «И» и логического «ИЛИ» решите «задачу исключающего ИЛИ»:

# Ваш код здесь

# Вариант задания сети с разным количество нейронов в скрытом слое
n_hiddens = 2
model = TwoLayersNetwork(n_hiddens=n_hiddens)

# Ваш код здесь

Проверьте качество обучения сети:

assert accuracy(model_answer_interpretation, y_xor_data) == 1
assert np.mean(loss_history[:5]) > np.mean(loss_history[-5:])

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

Литература:

  1. Бородкин А.А., Елисеев В.Л. Основы и применение искусственных нейронных сетей. Сборник лабораторных работ: методическое пособие. – М.: Издательский дом МЭИ, 2017.
  2. MachineLearning.ru — профессиональный информационно-аналитический ресурс, посвященный машинному обучению, распознаванию образов и интеллектуальному анализу данных: http://www.machinelearning.ru
  3. Modern State of Artificial Intelligence — Online Masters program at MIPT: https://girafe.ai/