{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### ЛАБОРАТОРНАЯ РАБОТА №1.1\n",
    "## ИЗУЧЕНИЕ ОСНОВНЫХ ПОНЯТИЙ ТЕОРИИ ИСКУССТВЕННЫХ НЕЙРОННЫХ СЕТЕЙ"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "> Цель работы: изучение основных понятий теории искусственных нейронных сетей на примере простых задач распознавания логических функций («И», «ИЛИ», «исключающее ИЛИ»)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Импорт библиотек:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from IPython.display import clear_output\n",
    "\n",
    "%matplotlib inline"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В первой части данной лабораторной работы мы используем библиотеку NumPy (стандартный псевдоним при импорте — `np`), которая является фундаментальным пакетом для научных вычислений в Python. Она предоставляет:\n",
    "- многомерные массивы (`ndarray`) с эффективной реализацией;\n",
    "- набор математических функций для работы с массивами;\n",
    "- инструменты для линейной алгебры, Фурье‑преобразований и генерации случайных чисел.\n",
    "\n",
    "Полную информацию о библиотеке и её возможностях вы найдёте в официальной документации:  \n",
    "[https://numpy.org/doc/](https://numpy.org/doc/)\n",
    "\n",
    "Кроме того, чтобы быстро, с помощью встроенной справки узнать подробности о конкретном методе или объекте NumPy, используйте символ `?` перед его именем.\n",
    "\n",
    "Например, для получения справки по функции создания массивов `array` напишите в ячейке кода `?np.array`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Ваш код здесь"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Содержание:  \n",
    "\n",
    "[1. Работа искусственного нейрона](#p_1)  \n",
    "[2. Обратное распространение ошибки](#p_2)  \n",
    "[3. Решение задачи логического «И»](#p_3)  \n",
    "[4. Решение задачи логического «ИЛИ»](#p_4)  \n",
    "[5. Решение задачи логического «исключающего ИЛИ»](#p_5)  "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 1. Работа искусственного нейрона<a id=\"p_1\"></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Рассмотрим работу искусственной нейронной сети, состоящей из одного нейрона, на примере задачи распознавания логической операции «И».\n",
    "\n",
    "Требуется спроектировать и обучить искусственный нейрон таким образом, чтобы он корректно воспроизводил поведение логической функции «И»: на выходе нейрона должно формироваться значение `1` (истина) только в том случае, если оба входных сигнала равны `1`. Во всех остальных случаях выход должен быть `0` (ложь).\n",
    "\n",
    "**Входные данные:**  \n",
    "На вход нейрона подаются двоичные векторы длины 2 — все возможные комбинации значений двух логических переменных:\n",
    "\n",
    "$$\n",
    "\\begin{aligned}\n",
    "&\\mathbf{x}_1 = [0,\\, 0], \\\\\n",
    "&\\mathbf{x}_2 = [0,\\, 1], \\\\\n",
    "&\\mathbf{x}_3 = [1,\\, 0], \\\\\n",
    "&\\mathbf{x}_4 = [1,\\, 1].\n",
    "\\end{aligned}\n",
    "$$\n",
    "\n",
    "**Требуемые выходные значения (целевые метки):**  \n",
    "Для каждого входного вектора ожидаемый выход $y$ определяется по таблице истинности операции «И»:\n",
    "\n",
    "$$\n",
    "\\begin{array}{c|c|c}\n",
    "x_1 & x_2 & y \\\\\n",
    "\\hline\n",
    "0 & 0 & 0 \\\\\n",
    "0 & 1 & 0 \\\\\n",
    "1 & 0 & 0 \\\\\n",
    "1 & 1 & 1 \\\\\n",
    "\\end{array}\n",
    "$$\n",
    "\n",
    "**Формальная постановка задачи:**  \n",
    "Необходимо найти такие параметры нейрона: \n",
    "- вектор весов $\\mathbf{W} = [w_1,\\, w_2]$,  \n",
    "- смещение (порог) $b$,  \n",
    "\n",
    "чтобы для любого входного вектора $\\mathbf{x} = [x_1,\\, x_2]$ выполнялось:\n",
    "\n",
    "$$\n",
    "y_{\\text{pred}} = f(w_1 x_1 + w_2 x_2 + b) \\approx y,\n",
    "$$\n",
    "\n",
    "где $f$ — функция активации (подробнее будет рассмотрено ниже)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Рассмотрим, как входные данные проходят через искусственный нейрон: от подачи на вход до вычисления выходного значения — или прямой проход.\n",
    "\n",
    "Создадим массив (матрицу) `X_data`, содержащий все возможные комбинации входных значений для логической операции «И» (два входа, каждый — `0` или `1`). В ней всего 4 комбинации, соответствующие таблице истинности операции «И»."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "X_data = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])\n",
    "print(X_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Для обучения нейрона необходимы «правильные» ответы — значения, которые модель должна выдавать для каждого входного вектора. В случае логической операции «И» (логическое умножение) правило следующее:\n",
    "- выход равен `0`, если хотя бы один из входов равен `0`;\n",
    "- выход равен `1`, только если оба входа равны `1`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "y_and_data = np.array([0, 0, 0, 1]).reshape(-1, 1)\n",
    "print(y_and_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Для работы нейрона необходимо задать начальные значения его параметров (т.е. инициализировать их):\n",
    "- весов (`w`) — коэффициентов, определяющих вклад каждого входного признака;\n",
    "- смещения (`b`) — порогового значения, сдвигающего активацию нейрона.\n",
    "\n",
    "Рассмотрим пример равномерного распределения для инициализации весов и смещения:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "np.random.seed(seed=21)\n",
    "\n",
    "w = np.random.uniform(low=-.05, high=.05, size=(2, 1))\n",
    "b = np.random.uniform(low=-.05, high=.05)\n",
    "\n",
    "print('Веса:\\n', w)\n",
    "print('\\nСмещение:', b)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Самостоятельно изучите функцию `np.random.normal` и реализуйте инициализацию весов и смещения на её основе. Обратите внимание на ключевые параметры:\n",
    "  - `loc` — среднее значение распределения (математическое ожидание);\n",
    "  - `scale` — стандартное отклонение (определяет разброс значений);\n",
    "  - `size` — форма выходного массива."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "w_norm = # Ваш код здесь\n",
    "b_norm = # Ваш код здесь\n",
    "\n",
    "# Ваш код здесь"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*Примечание*. `np.random.seed(seed)` — функция NumPy для установки начального значения (seed) генератора псевдослучайных чисел.\n",
    "\n",
    "Установка seed обеспечивает:\n",
    " - воспроизводимость результатов - при каждом запуске кода будут генерироваться *одни и те же* случайные числа;\n",
    " - контролируемость экспериментов - позволяет сравнивать результаты разных запусков при неизменных начальных условиях."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Умножим матрицу входных данных `X_data` на вектор весов `w`, чтобы вычислить взвешенную сумму входов для каждого примера, затем прибавим смещение `b` — так мы получим линейную комбинацию входных признаков, которая послужит входным сигналом для функции активации нейрона."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "s = X_data.dot(w) + b\n",
    "print(s)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*Примечание*. В NumPy для умножения векторов и матриц можно использовать три эквивалентных способа: функцию `np.dot()`, метод массива `np.dot()` и оператор `@`. Все три способа выполняют скалярное (точечное) произведение векторов или матричное умножение. Для матриц результат соответствует правилам матричного умножения."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Перейдём к функциям активации.\n",
    "\n",
    "Функции активации вводят нелинейность в работу нейрона. Без них нейронная сеть сводилась бы к линейной модели (независимо от числа слоёв), что резко ограничивало бы её возможности — она не смогла бы обучаться сложным зависимостям в данных.\n",
    "\n",
    "Рассмотрим сигмоиду как наглядный пример функции активации:\n",
    "\n",
    "$$\n",
    "\\sigma(s) = \\frac{1}{1 + e^{-s}},\n",
    "$$\n",
    "\n",
    "Она преобразует любое вещественное число `s` в значение из интервала (0, 1). Это удобно для:\n",
    "- интерпретации выхода как вероятности;\n",
    "- бинарной классификации (выход близко к 0 — класс «нет», близко к 1 — класс «да»)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def sigmoid(s, c=1):\n",
    "    return 1. / (1. + np.exp(-c*s))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Функции активации должны обладать следующими ключевыми свойствами:  \n",
    "- нелинейность — позволяет нейронной сети обучаться сложным зависимостям.  \n",
    "- дифференцируемость — можно вычислять градиенты для обучения методом обратного распространения ошибки;\n",
    "- ограниченность (для нейронов выходных слоёв в некоторых задачах) — например, выход всегда в диапазоне (0, 1), что удобно для интерпретации как вероятности. \n",
    "\n",
    "Самостоятельно реализуйте несколько функций активации:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def tanh(s):\n",
    "    return # Ваш код здесь\n",
    "\n",
    "# Ваш код здесь"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Постройте графики реализованных вами функций активации (по примеру строки кода для сигмоиды):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ss = np.linspace(start=-10, stop=10, num=200)\n",
    "\n",
    "plt.plot(ss, sigmoid(ss), label='sigmoid')\n",
    "plt.plot(# Ваш код здесь)\n",
    "# Ваш код здесь\n",
    "plt.legend(loc='best')\n",
    "plt.grid(True)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Дополнительно рассмотрим такой важный параметр функции сигмоиды, как температуру (в коде обозначен `c`). Хотя в классической формуле сигмоиды этот параметр отсутствует, его введение позволяет гибко регулировать форму активационной функции.\n",
    "\n",
    "Температура `c` выступает положительным множителем при входном сигнале `s` в экспоненте:  \n",
    "$$\n",
    "\\sigma(s, c) = \\frac{1}{1 + e^{-c \\cdot s}}\n",
    "$$  \n",
    "Очевидно, что при `c = 1` функция совпадает со стандартной сигмоидой.  \n",
    "\n",
    "В ячейке с кодом ниже по примеру для `c = 1` постройте графики для вариантов, когда:\n",
    " - температура больше единицы;\n",
    " - температуры меньше единицы.  \n",
    " \n",
    "Проинтерпретируйте результат. Для каких случаях применим каждый из этих двух вариантов?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "plt.plot(ss, sigmoid(ss, c=1), label='1')\n",
    "plt.plot(ss, # Ваш код здесь)\n",
    "plt.plot(ss, # Ваш код здесь)\n",
    "plt.title('Sigmoid')\n",
    "plt.legend(loc='best', title='temperature c:')\n",
    "plt.grid(True)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Применим сигмоиду как функцию активации к взвешенной сумме входов для каждого примеров - чтобы найти вероятностную оценку, которая и будет выходным сигналом нейрона:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "z = sigmoid(s) \n",
    "print(z)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "После вычисления выходного сигнала нейрона `z` возникает главный вопрос: насколько полученный результат близок к истинному значению?\n",
    "\n",
    "Для ответа используем функцию потерь — в нашем случае для удобства выбрана среднеквадратичная ошибка (Mean Square error, MSE).\n",
    "\n",
    "Формула MSE:\n",
    "$$\n",
    "\\mathcal{L}_{MSE} = \\frac{1}{n} \\sum_{i=1}^{n} (y_{\\text{pred}, i} - y_{\\text{true}, i})^2,\n",
    "$$\n",
    "где:\n",
    "- $n$ — количество примеров в выборке;\n",
    "- $y_{\\text{pred}, i}$ — предсказание модели для $i$-го примера;\n",
    "- $y_{\\text{true}, i}$ — истинное значение для $i$-го примера.\n",
    "\n",
    "MSE количественно измеряет расхождение (или в геометрическом смысле расстояние) между предсказанными значениями (`y_pred`) и истинными (`y_true`). Чем меньше значение MSE (как и значение любой другой функции потерь), тем лучше построенная нами модель соответствует данным.\n",
    "\n",
    "Реализация MSE через Numpy:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def mse(y_pred, y_true):\n",
    "    return np.mean((y_pred - y_true) ** 2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Значение MSE на данный момент:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "mse(z, y_and_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*Примечание*. MSE, как правило, применяется в задаче регрессии, где нужно предсказать непрерывное значение (например, цену дома или температуру). Однако, как видно, её можно использовать и в задаче классификации — хотя это менее типично, поскольку MSE никак не учитывает вероятностную природу выходных данных.\n",
    "\n",
    "Для бинарной классификации стандартна бинарная кросс‑энтропия (Binary Cross Entropy) или logloss:  \n",
    "$$\n",
    "\\mathcal{L}_{BCE} = -\\frac{1}{n} \\sum_{i=1}^{n} \\left[ y_i \\log(\\hat{y}_i) + (1 - y_i) \\log(1 - \\hat{y}_i) \\right],\n",
    "$$  \n",
    "где:  \n",
    "- $y_i \\in \\{0, 1\\}$ — истинная метка класса,  \n",
    "- $\\hat{y}_i \\in \\{0, 1\\}$ — предсказанная вероятность принадлежности к классу 1,  \n",
    "- $n$ — число примеров.\n",
    "\n",
    "Для многоклассовой классификации используют кросс‑энтропию  (Cross Entropy) в общей форме:  \n",
    "$$\n",
    "\\mathcal{L}_{CE} = -\\frac{1}{n} \\sum_{i=1}^{n} \\sum_{k=1}^{K} y_{ik} \\log(\\hat{y}_{ik}),\n",
    "$$  \n",
    "где:  \n",
    "- $K$ — число классов,  \n",
    "- $y_{ik}$ — истинная вероятность (обычно 1 для истинного класса и 0 для остальных),  \n",
    "- $\\hat{y}_{ik}$ — предсказанная вероятность класса $k$ для примера $i$.\n",
    "\n",
    "Очевидно, что бинарная кросс‑энтропия — это частный случай многоклассовой при $K=2$.\n",
    "\n",
    "В отличие от MSE, кросс‑энтропия учитывает вероятностный характер предсказаний."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Поскольку мы решаем задачу классификации, важно оценивать, насколько хорошо модель предсказывает классы (а не просто выдаёт пусть и полезные, но служебные числовые значения).\n",
    "\n",
    "Для этого используем метрику accuracy (доля правильно классифицированных примеров от общего числа).\n",
    "\n",
    "Преобразуем выданные нейроном вероятности в классы `0` и `1` (отсекаем по задаваемому порогу — по умолчанию это 0.5). Вектор предсказанных классов для всех примеров:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "model_answer_interpretation = (z >= 0.5).astype('int')\n",
    "print(model_answer_interpretation)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Напомним исходные ответы `y_and_data`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(y_and_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Реализация метрики accuracy:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def accuracy(y_pred, y_true):\n",
    "    return np.sum(y_pred == y_true) / len(y_true)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Значение accuracy на данный момент:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "accuracy(model_answer_interpretation, y_and_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Чтобы снизить значение MSE (уменьшить расхождение между предсказанными и истинными значениями), необходимо подобрать такие значения весов и смещения, при которых ошибки предсказания будут минимальными, а качество классификации — максимальным.\n",
    "\n",
    "Рассмотренный нами одиночный нейрон представляет собой нейронную сеть самой простейшей архитектуры. Изменение значений его весов и смещения — это задача обучения нейронной сети."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2. Обратное распространение ошибки<a id=\"p_2\"></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Задачу обучения нейронной сети как задачу оптимизации можно поставить (и проинтерпретировать) следующим образом.\n",
    "\n",
    "В процессе обучения мы никак не можем изменять структуру сети, входные данные или функцию активации — единственное, что доступно для настройки, это параметры модели:  \n",
    "- веса связей ($w_{ij}$);  \n",
    "- смещения ($b_i$).  \n",
    "\n",
    "Тогда наша цель — подобрать такие значения этих параметров, при которых функция потерь $\\mathcal{L}(\\theta)$ (где $\\theta$ — вектор всех параметров) принимает минимально возможное значение на обучающей выборке.\n",
    "\n",
    "Минимизация функции потерь осуществляется методом градиентного спуска: параметры изменяются в направлении антиградиента функции потерь, то есть в направлении наиболее быстрого убывания $\\mathcal{L}$.\n",
    "\n",
    "Почему нужен именно антиградиент?\n",
    "\n",
    "Градиент $\\nabla_{\\theta} \\mathcal{L}$ указывает направление наиболее быстрого возрастания функции, тогда как антиградиент $-\\nabla_{\\theta} \\mathcal{L}$ — направление наиболее быстрого убывания. Поэтому для минимизации мы двигаемся против градиента.\n",
    "\n",
    "**Итеративное обновление параметров** выполняется по формуле:  \n",
    "\n",
    "$$\n",
    "\\theta^{[t+1]} \\leftarrow \\theta^{[t]} - \\eta \\nabla_{\\theta} \\mathcal{L}(\\theta^{[t]}),\n",
    "$$  \n",
    "где:  \n",
    "- $\\theta^{[t]}$ — вектор параметров на шаге $t$;  \n",
    "- $\\theta^{[t+1]}$ — обновлённый вектор параметров;  \n",
    "- $\\eta > 0$ — скорость обучения (learning rate), скалярный гиперпараметр, определяющий «шаг» движения в пространстве параметров;  \n",
    "- $\\nabla_{\\theta} \\mathcal{L}$ — градиент функции потерь по параметрам $\\theta$.\n",
    "\n",
    "Подробное пояснение компонентов:\n",
    "\n",
    "1. Минус перед градиентом.  \n",
    "   Обеспечивает движение в направлении антиградиента, а не градиента. Без минуса мы бы максимизировали функцию потерь, что противоположно нашей цели.\n",
    "\n",
    "2. Скорость обучения $\\eta$.\n",
    "   - Если $\\eta$ слишком велика, алгоритм может «перепрыгнуть» минимум и даже расходиться.  \n",
    "   - Если $\\eta$ слишком мала, обучение будет чрезмерно медленным.  \n",
    "   - На практике $\\eta$ выбирают эмпирически или используют адаптивные методы (Adam, RMSprop).\n",
    "\n",
    "3. Градиент $\\nabla_{\\theta} \\mathcal{L}$. \n",
    "   Вектор, составленный из всех частных производных функции потерь $\\mathcal{L}$ по каждому из параметров модели $\\theta$. Если модель имеет набор настраиваемых параметров $\\theta = (\\theta_1, \\theta_2, \\ldots, \\theta_m)$, то градиент объединяет информацию о том, как изменение *каждого* параметра влияет на значение функции потерь. Таким образом, именно значение градиента задаёт направление, в котором следует изменять параметры, чтобы минимизировать ошибку модели."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В случае многослойных нейронных сетей прямое (аналитическое) вычисление частных производных функции потерь по всем параметрам сталкивается с рядом принципиальных сложностей:\n",
    "\n",
    "1. Экспоненциальный рост числа слагаемых  \n",
    "   При развёртывании производных по цепному правилу количество слагаемых растёт экспоненциально с увеличением числа слоёв. Для сети из нескольких слоёв вычисление $\\frac{\\partial \\mathcal{L}}{\\partial \\theta^{(1)}}$ (производной по параметру первого слоя: например, веса) требует учёта всех путей влияния этого параметра на выход через промежуточные слои.\n",
    "\n",
    "\n",
    "2. Высокие вычислительные затраты  \n",
    "   Аналитическое дифференцирование для сети с миллионами параметров:\n",
    "   - требует хранения гигантских символьных выражений;\n",
    "   - приводит к неприемлемому времени вычислений;\n",
    "   - потребляет чрезмерный объём памяти.\n",
    "\n",
    "\n",
    "3. Сложность учёта нелинейностей  \n",
    "   Каждый нейрон применяет нелинейную функцию активации (sigmoid, ReLU и др.). При аналитическом дифференцировании это порождает:\n",
    "   - вложенные производные сложных функций;\n",
    "   - необходимость многократного применения цепного правила;\n",
    "   - риск численной нестабильности (проблемы затухающих/взрывающихся градиентов).\n",
    "\n",
    "\n",
    "4. Непрактичность обновления параметров  \n",
    "   Даже если удалось вычислить все производные, их использование для обновления весов требует:\n",
    "   - формирования полного градиентного вектора;\n",
    "   - итеративных пересчётов при каждом изменении параметров;\n",
    "   - решения систем уравнений высокой размерности.\n",
    "\n",
    "Именно поэтому в обучении многослойных нейронных сетей (глубоком обучении, Deep Learning) повсеместно используют численное дифференцирование через обратное распространение ошибки (backpropagation), а не аналитические методы."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Рассмотрим суть алгоритма обратного распространения ошибки для многослойной нейронной сети общего вида.\n",
    "\n",
    "Пусть нейронная сеть состоит из $N$ слоёв. Каждый слой выполняет функциональное преобразование своих входных данных $G^{(i)}$ с использованием своих параметров (весов $W_i$). Кроме того, пусть $G^{(i)}$ не просто функция активации над взвешенными входными данными, а общее преобразование, включающее ещё и возможные архитектурные особенности (нормализация, дропаут и др.).\n",
    "\n",
    "Представим преобразование данных по слоям.\n",
    "\n",
    "Выход первого слоя:  \n",
    "$$\n",
    "Y_1 = G^{(1)}_{W_1}(X),\n",
    "$$  \n",
    "где:  \n",
    "- $X$ — входные данные;  \n",
    "- $G^{(i)}$ — преобразование, выполняемое первым слоем;  \n",
    "- $W_1$ — параметры (пусть веса и смещение) первого слоя;  \n",
    "\n",
    "Выход второго слоя:  \n",
    "$$\n",
    "Y_2 = G^{(2)}_{W_2}(Y_1) = G^{(2)}_{W_2}\\bigl(G^{(1)}_{W_1}(X)\\bigr),\n",
    "$$  \n",
    "где:  \n",
    "- $G^{(2)}$ — преобразование второго слоя;  \n",
    "- $W_2$ — параметры второго слоя.\n",
    "\n",
    "Заметим, что $Y_2$ зависит:  \n",
    "- от входных данных $X$;  \n",
    "- от параметров первого слоя $W_1$;  \n",
    "- от параметров второго слоя $W_2$;  \n",
    "- от вида функций $G^{(1)}$ и $G^{(2)}$.\n",
    "\n",
    "Тогда выход всей сети из $N$ слоёв можно записать как композицию преобразований:  \n",
    "\n",
    "$$\n",
    "Y_{\\text{pred}} = G^{(N)}_{W_N}\\bigl(G^{(N-1)}_{W_{N-1}}\\bigl(\\cdots G^{(1)}_{W_1}(X)\\bigr)\\cdots\\bigr).\n",
    "$$\n",
    "\n",
    "Очевидно, что замечания, данные выше для второго слоя, справедливы и даже масштабированы для выхода всей сети.\n",
    "\n",
    "Напомним, что задача обучения нашей нейронной сети заключается в том, чтобы для каждого её слоя нам необходимо подобрать параметры $\\{W_i\\}$, минимизирующие функцию потерь:  \n",
    "\n",
    "$$\n",
    "\\mathcal{L} = \\mathcal{L}(Y_{\\text{pred}}, Y_{\\text{true}}),\n",
    "$$  \n",
    "где:  \n",
    "- $Y_{\\text{pred}}$ — предсказание сети;  \n",
    "- $Y_{\\text{true}}$ — истинные значения (метки).\n",
    "\n",
    "И чтобы применить градиентный спуск, нужно для каждого слоя вычислить значение градиента $\\frac{\\partial \\mathcal{L}}{\\partial W_i}$ для формулы итеративного обновления параметров:  \n",
    "\n",
    "$$\n",
    "W_i^{[t+1]} \\leftarrow W_i^{[t]} - \\eta \\frac{\\partial \\mathcal{L}}{\\partial W_i},\n",
    "$$\n",
    "где $\\eta$ — скорость обучения."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Используя цепное правило дифференцирования (chain rule или правило частных производных), выразим градиенты по весам каждого слоя, начиная с последнего:\n",
    "\n",
    "- Для последнего слоя ($N$):  \n",
    "  $$\n",
    "  \\frac{\\partial \\mathcal{L}}{\\partial W_N} = \\frac{\\partial \\mathcal{L}}{\\partial G^{(N)}} \\cdot \\frac{\\partial G^{(N)}}{\\partial W_N}.\n",
    "  $$\n",
    "\n",
    "- Для предпоследнего слоя ($N-1$):  \n",
    "  $$\n",
    "  \\frac{\\partial \\mathcal{L}}{\\partial W_{N-1}} = \\frac{\\partial \\mathcal{L}}{\\partial G^{(N)}} \\cdot \\frac{\\partial G^{(N)}}{\\partial G^{(N-1)}} \\cdot \\frac{\\partial G^{(N-1)}}{\\partial W_{N-1}}.\n",
    "  $$\n",
    "\n",
    "- Для слоя ($N-2$):  \n",
    "  $$\n",
    "  \\frac{\\partial \\mathcal{L}}{\\partial W_{N-2}} = \\frac{\\partial \\mathcal{L}}{\\partial G^{(N)}} \\cdot \\frac{\\partial G^{(N)}}{\\partial G^{(N-1)}} \\cdot \\frac{\\partial G^{(N-1)}}{\\partial G^{(N-2)}} \\cdot \\frac{\\partial G^{(N-2)}}{\\partial W_{N-2}}.\n",
    "  $$ \n",
    "  \n",
    "- **...** \n",
    "\n",
    "\n",
    "- Для первого слоя ($1$):  \n",
    "  $$\n",
    "  \\frac{\\partial \\mathcal{L}}{\\partial W_1} = \\frac{\\partial \\mathcal{L}}{\\partial G^{(N)}} \\cdot \\frac{\\partial G^{(N)}}{\\partial G^{(N-1)}} \\cdot \\ldots \\cdot \\frac{\\partial G^{(2)}}{\\partial G^{(1)}} \\cdot \\frac{\\partial G^{(1)}}{\\partial W_1}.\n",
    "  $$"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ключевая идея обратного распространения ошибки (backpropagation) заключается в поэтапном накопленим градиентов с сохранением вычисленных ранее промежуточных результатов:\n",
    "\n",
    "- Выражение $\\dfrac{\\partial \\mathcal{L}}{\\partial G^{(N)}}$ вычисляется на первом шаге (для последнего слоя с номером $N$) — через непосредственную подстановку в него предсказание сети $Y_{\\text{pred}}$ и истинные значения $Y_{\\text{true}}$.\n",
    "- Далее значение этого выражения, что очень важно, не пересчитывается заново, а используется при вычислении градиентов для более ранних слоёв  — как минимум слоя с номером $N-1$.\n",
    "- Аналогично, произведение $\\dfrac{\\partial \\mathcal{L}}{\\partial G^{(N)}} \\cdot \\dfrac{\\partial G^{(N)}}{\\partial G^{(N-1)}}$ сохраняется и применяется для слоя с номером $N-2$, и т. д.\n",
    "\n",
    "В результате такого обратного прохода мы получаем полный набор требуемых градиентов:\n",
    "- $\\dfrac{\\partial \\mathcal{L}}{\\partial W_N}$ — по весам последнего слоя;\n",
    "- $\\dfrac{\\partial \\mathcal{L}}{\\partial W_{N-1}}$ — по весам предпоследнего слоя;\n",
    "- ...\n",
    "- $\\dfrac{\\partial \\mathcal{L}}{\\partial W_1}$ — по весам первого слоя."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "После того как в ходе обратного распространения ошибки вычислены все необходимые градиенты $\\frac{\\partial \\mathcal{L}}{\\partial W_i}$ для каждого слоя, они непосредственно используются для обновления параметров сети.\n",
    "\n",
    "Описанный процесс образует итеративный цикл, который повторяется до достижения критерия остановки:\n",
    "\n",
    "1. Прямой проход:  \n",
    "   - подача входных данных $X$ через сеть;\n",
    "   - вычисление выхода $Y_{\\text{pred}}$ и функции потерь $\\mathcal{L}$.\n",
    "\n",
    "2. Обратный проход (backpropagation):  \n",
    "   - вычисление градиентов $\\frac{\\partial \\mathcal{L}}{\\partial W_i}$ для всех слоёв;\n",
    "   - сохранение промежуточных производных для эффективного пересчёта.\n",
    "\n",
    "3. Обновление параметров:  \n",
    "   - применение формулы градиентного спуска для всех $W_i$;\n",
    "   - корректировка весов и смещений сети.\n",
    "\n",
    "4. Проверка условия остановки:  \n",
    "   - достигнута ли заданная точность ($\\mathcal{L} \\leq \\varepsilon$)?\n",
    "   - исчерпано ли максимальное число итераций?\n",
    "   - стабилизировалась ли ошибка на валидационной выборке?\n",
    "\n",
    "Если критерий остановки не выполнен, цикл повторяется: новый прямой проход с обновлёнными весами, затем обратное распространение и очередное обновление.\n",
    "\n",
    "Обучение завершается при выполнении одного из условий (критерия остановки):\n",
    "- функция потерь $\\mathcal{L}$ достигла заранее заданного порога $\\varepsilon$;\n",
    "- число итераций (эпох) достигло максимального значения;\n",
    "- ошибка на валидационной выборке перестаёт уменьшаться (признак переобучения);\n",
    "- изменение $\\mathcal{L}$ между итерациями становится пренебрежимо малым."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Преимущества и особенности алгоритма обратного распространения ошибки:\n",
    "\n",
    "1. Эффективность вычислений.\n",
    "   Избегается повторное вычисление одних и тех же производных. Сложность становится линейной относительно числа параметров (вместо экспоненциальной при прямом аналитическом дифференцировании).\n",
    "\n",
    "2. Экономия памяти.  \n",
    "   Промежуточные градиенты хранятся только для текущего шага обратного прохода. Не требуется хранить гигантские символьные выражения.\n",
    "\n",
    "3. Масштабируемость.  \n",
    "   Алгоритм работает одинаково эффективно для сетей любой глубины. Легко адаптируется к различным архитектурам (свёрточные, рекуррентные и др.).\n",
    "\n",
    "4. Физическая интерпретация.  \n",
    "   Ошибка «распространяется обратно» от выхода к входу, последовательно уточняя вклад каждого слоя. Каждый шаг использует результаты предыдущего, что напоминает волновой процесс.\n",
    "\n",
    "Алгоритм обратного распространения ошибки подходит для сетей любой архитектуры: от простейших однонейронных моделей (подобных той, которую мы рассматривали выше) до глубоких многослойных сетей без принципиальных изменений в процедуре обучения. Он универсален для различных типов слоёв (полносвязных, свёрточных, рекуррентных и др.), совместим с разными функциями активации и функциями потерь."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Рассмотрим пошагово пример обратного прохода для задачи логического «И».\n",
    "\n",
    "Предварительно нам нужно задать функции частных производных использованных функции потерь MSE (производная по выходному результату сети) и сигмоиды как функции активации (производная по взвешенным входным данным):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def mse_derivative(y_pred, y_true):\n",
    "    return 2 * (y_pred - y_true) * 1\n",
    "\n",
    "def sigmoid_derivative(s):\n",
    "    z = sigmoid(s)\n",
    "    return z * (1 - z)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В ячейке ниже задайте функции частных производных функций потерь и функций активации, которые вы вводили:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Ваш код здесь"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Чтобы ускорить сходимость и улучшить стабильность обучения, модифицируем стандартную формулу градиентного спуска — введём понятие импульса (momentum).\n",
    "\n",
    "Идея модификации в том, что импульс учитывает историю изменений градиентов: вместо того чтобы корректировать веса только по текущему градиенту, мы добавляем «память» о предыдущих направлениях движения в пространстве параметров. Это позволяет быстрее преодолевать плоские участки функции потерь, сглаживать колебания при осциллирующих градиентах и ускорять сходимость в овражных зонах ландшафта ошибки.\n",
    "\n",
    "Введём вспомогательную переменную — вектор скорости $v_i$ для параметров слоя $W_i$, который аккумулирует информацию о предыдущих градиентах.\n",
    "\n",
    "Обновление параметров при этом происходит в два этапа:\n",
    "\n",
    "1. Расчёт новой скорости (с учётом импульса):  \n",
    "   \n",
    "   $$\n",
    "   v_i^{[t]} = \\beta \\cdot v_i^{[t-1]} + \\eta \\cdot \\frac{\\partial \\mathcal{L}}{\\partial W_i^{[t]}},\n",
    "   $$  \n",
    "   где:  \n",
    "   - $v_i^{[t]}$ — скорость (накопительный вектор) для параметров слоя $W_i$ на шаге $t$;  \n",
    "   - $v_i^{[t-1]}$ — значение скорости на предыдущем шаге (инициализируется нулём);  \n",
    "   - $\\beta \\in [0, 1)$ — коэффициент импульса (обычно $0.9$–$0.99$);  \n",
    "   - $\\eta$ — скорость обучения (learning rate);  \n",
    "   - $\\frac{\\partial \\mathcal{L}}{\\partial W_i^{[t]}}$ — градиент функции потерь по параметрам $W_i$ на текущем шаге.\n",
    "\n",
    "\n",
    "2. Обновление весов с использованием накопленной скорости:  \n",
    "   \n",
    "   $$\n",
    "   W_i^{[t+1]} = W_i^{[t]} - v_i^{[t]},\n",
    "   $$  \n",
    "   где $W_i^{[t]}$ — текущие значения параметров слоя.\n",
    "\n",
    "Импульс (momentum) — простой, но мощный способ улучшить градиентный спуск. Он добавляет «физику движения» в оптимизацию: веса не просто шагают по градиенту, а разгоняются в направлении устойчивого спуска, что ускоряет обучение и повышает устойчивость.\n",
    "\n",
    "*Примечание*. Это только одна из возможных реализаций импульса. Прикладное удобство данного варианта в том, что при $\\beta = 0$ получается классическое обновление параметров."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Напомним исходные веса и смещение в нашей задаче:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('Веса:\\n', w)\n",
    "print('\\nСмещение:', b)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Задайте скорость обучение и коэффициент импульса (согласно рекомендациям для каждого из этих гиперпараметров):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "learning_rate = # Ваш код здесь\n",
    "momentum = # Ваш код здесь"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Инициализируем нулями значения скоростей:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "velocity_w = np.zeros_like(w)\n",
    "velocity_b = 0.0   "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Рассмотрим пошагово реализацию кода обратного распространения ошибки.\n",
    "\n",
    "<a id=\"loop_and\"></a>Первый шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dL_dz = mse_derivative(z, y_and_data)\n",
    "print(dL_dz)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вычислены значения производной функции потерь (MSE) по выходу сигмоиды $z$.  \n",
    "\n",
    "Математическая запись:\n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial z} = \\frac{\\partial}{\\partial z}\\left[(z - y)^2\\right] = 2 \\cdot (z - y),\n",
    "   $$  \n",
    "   где:  \n",
    "   - $z$ (`z`) — выход сигмоиды (предсказание модели);  \n",
    "   - $y$ (`y_and_data`) — истинные значения (целевые метки).  \n",
    "\n",
    "Второй шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dz_ds = sigmoid_derivative(s)\n",
    "print(dz_ds)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вычислены значения производной сигмоиды по её входу $s$ (взвешенной сумме).  \n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial z}{\\partial s} = \\sigma'(s) = \\sigma(s) \\cdot (1 - \\sigma(s)) = z \\cdot (1 - z),\n",
    "   $$  \n",
    "   где:  \n",
    "   - $\\sigma(s)$ — функция сигмоиды;  \n",
    "   - $s$ (`z`) — взвешенная сумма входных данных (до применения активации);  \n",
    "   - $z = \\sigma(s)$ — выход сигмоиды.  \n",
    "   \n",
    "Третий шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dL_ds = dL_dz * dz_ds\n",
    "print(dL_ds)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вычислены значения функции потерь по взвешенной сумме $s$ через правило частных производных.  \n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial s} = \\frac{\\partial \\mathcal{L}}{\\partial z} \\cdot \\frac{\\partial z}{\\partial s},\n",
    "   $$  \n",
    "\n",
    "Таким образом, объединены два предыдущих градиента:  \n",
    "   - как ошибка зависит от выхода $z$ (`dL_dz`);  \n",
    "   - как выход $z$ зависит от входа $s$ (`dz_ds`).  \n",
    "   \n",
    "Последний шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dL_dw = np.dot(X_data.T, dL_ds)\n",
    "dL_db = np.sum(dL_ds)\n",
    "\n",
    "print(dL_dw)\n",
    "print(dL_db)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В первой строке вычисляются значения градиента функции потерь по весовым коэффициентам $W$ (вектор/матрица $\\frac{\\partial \\mathcal{L}}{\\partial W}$).\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial W} = X^\\mathsf{T} \\cdot \\frac{\\partial \\mathcal{L}}{\\partial s},\n",
    "   $$  \n",
    "   где:  \n",
    "   - $X$ (`X_data`) — матрица входных данных размера $(n, m)$, где $n$ — число объектов, $m$ — число признаков;  \n",
    "   - $X^\\mathsf{T}$ — транспонированная матрица (размер $(m, n)$);  \n",
    "   - $\\frac{\\partial \\mathcal{L}}{\\partial s}$ (`dL_ds`) — градиент по взвешенным суммам размера $(n, 1)$;  \n",
    "   - результат — матрица градиентов размера $(m, 1)$.\n",
    "\n",
    "Операция `np.dot` выполняет матричное умножение: каждый элемент результата — скалярное произведение строки $X^\\mathsf{T}$ (признака) и столбца `dL_ds` (градиентов по объектам). В итоге мы получаем вектор/матрицу градиентов, показывающих, как изменение каждого веса влияет на ошибку.\n",
    "\n",
    "Во второй строке вычисляются значения градиента функции потерь по смещению $b$ (скаляр или вектор $\\frac{\\partial \\mathcal{L}}{\\partial b}$).\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial b} = \\sum_{i=1}^{n} \\frac{\\partial \\mathcal{L}}{\\partial s_i},\n",
    "   $$  \n",
    "   где:  \n",
    "   - $\\frac{\\partial \\mathcal{L}}{\\partial s_i}$ — градиент ошибки по взвешенной сумме для $i$-го объекта (`dL_ds[i]`);  \n",
    "   - суммирование идёт по всем $n$ объектам выборки.\n",
    "\n",
    "Смещение $b$ при прямом проходе одинаково применяется ко всем объектам, поэтому его градиент — это сумма градиентов `dL_ds` по всем примерам. Операция суммирования `np.sum` агрегирует градиенты: поскольку `dL_ds` имеет размер $(n, 1)$, результат — скаляр. Физический смысл: показывается, насколько нужно изменить $b$, чтобы уменьшить ошибку в среднем по выборке.\n",
    "\n",
    "С помощью вычисленных градиентов `dL_dw` и `dL_db` обновим скорости для весов и для смещения соответственно:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "velocity_w = momentum * velocity_w + learning_rate * dL_dw\n",
    "velocity_b = momentum * velocity_b + learning_rate * dL_db"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Обновим веса и смещение непосредственно:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "w -= velocity_w\n",
    "b -= velocity_b\n",
    "\n",
    "print('Веса:\\n', w)\n",
    "print('\\nСмещение:', b)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Проверим, как с обновлёнными весами изменятся значение функции потерь и метрики accuracy. Произведём прямой проход:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "s = X_data.dot(w) + b\n",
    "z = sigmoid(s)\n",
    "model_answer_interpretation = (z >= 0.5).astype('int')\n",
    "\n",
    "print(model_answer_interpretation)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Среднеквадратичная ошибка:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "mse(z, y_and_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Доля правильно классифицированных примеров:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "accuracy(model_answer_interpretation, y_and_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Сделайте несколько проходов (итераций) от [первого шага обратного распространения ошибки](#loop_and) до данной ячейки. Убедитесь, что ошибка сети понижается, а качество классификации растёт."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 3. Решение задачи логического «И»<a id=\"p_3\"></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Сведём весь код, представленный выше для непосредственного решения задачи логического «И». Вы можете использовать либо его, либо модифицированный на его основе — например, реализовать функцию обучения нейроннойсети и проверки её работы.\n",
    "\n",
    "Перед обучением сети задайте (или передайте параметрами в реализованную функцию):\n",
    " - данные для обучения (входные и выходные);\n",
    " - функцию потерь и функцию активации, а также их производные (можете использовать реализации для MSE и сигмоиды);\n",
    " - начальные значения весов и смещения;\n",
    " - количество итераций в процессе обучения (иначе говоря, эпох — т.е. сколько раз в процессе обучения нейронная сеть будет использовать все имеющиеся для этого данные) (желательно больше 5);\n",
    " - скорость обучение и коэффициент импульса."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X = X_data\n",
    "y_true = y_and_data\n",
    "\n",
    "loss_function = # Ваш код здесь\n",
    "loss_derivative = # Ваш код здесь\n",
    "activate_function = # Ваш код здесь\n",
    "activate_derivative = # Ваш код здесь\n",
    "\n",
    "w = # Ваш код здесь\n",
    "b = # Ваш код здесь\n",
    "\n",
    "epochs = # Ваш код здесь\n",
    "\n",
    "# Гиперпараметры\n",
    "learning_rate = # Ваш код здесь\n",
    "momentum = # Ваш код здесь"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Обучение нейронной сети для решения задачи логического «И»:**"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Инициализация скоростей для импульса\n",
    "velocity_w = np.zeros_like(w)\n",
    "velocity_b = 0.0\n",
    "\n",
    "# Список для хранения истории потерь\n",
    "loss_history = []\n",
    "\n",
    "for epoch in range(epochs):\n",
    "    \n",
    "    # Прямой проход\n",
    "    s = np.dot(X, w) + b\n",
    "    z = activate_function(s)\n",
    "    model_answer_interpretation = (z >= 0.5).astype('int')\n",
    "    \n",
    "    # Вычисляем ошибку\n",
    "    loss = loss_function(z, y_true)\n",
    "    loss_history.append(loss)\n",
    "    \n",
    "    # Обратное распространение\n",
    "    dL_dz = loss_derivative(z, y_true)\n",
    "    dz_ds = activate_derivative(s)\n",
    "    dL_ds = dL_dz * dz_ds\n",
    "    \n",
    "    # Градиенты\n",
    "    dL_dw = np.dot(X.T, dL_ds)\n",
    "    dL_db = np.sum(dL_ds)\n",
    "    \n",
    "    # Обновление скоростей\n",
    "    velocity_w = momentum * velocity_w + learning_rate * dL_dw\n",
    "    velocity_b = momentum * velocity_b + learning_rate * dL_db\n",
    "    \n",
    "    # Обновление параметров\n",
    "    w -= velocity_w\n",
    "    b -= velocity_b\n",
    "    \n",
    "    # Вывод каждые 5 эпох\n",
    "    if (epoch + 1) % 5 == 0:\n",
    "              \n",
    "        clear_output(True)\n",
    "        plt.plot(range(1, epoch+2), loss_history, label='Loss')\n",
    "        plt.title(f'Epoch: {epoch + 1}, Loss: {loss:.6f}')           \n",
    "        plt.grid(True, alpha=0.3)\n",
    "        plt.legend(loc='best')\n",
    "        plt.show()\n",
    "        \n",
    "        print('Test:')\n",
    "        for i in range(len(X)):\n",
    "            print(f'Input: {X[i]}, Output: {np.round(z[i], 2)}, Prediction: {model_answer_interpretation[i]}, True: {y_true[i]}')\n",
    "\n",
    "        print(f'Accuracy: {accuracy(model_answer_interpretation, y_true):.2f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Убедитесь, что сеть обучена хорошо — т.е. у неё высокое качество классификации, а в процессе обучения ошибка стаюильно снижалась:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "assert accuracy(model_answer_interpretation, y_and_data) == 1\n",
    "assert np.mean(loss_history[:5]) > np.mean(loss_history[-5:])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Поэкспериментируйте в процессе подбора параметров. Сделайте несколько вариантов с разными параметрами и выберите лучшие по качеству обучения модели, по её ошибке и по количеству эпох (т.е. как быстро она обучилась с такими параметрами).\n",
    "\n",
    "Отдельно проведите исследование, как на обучение сети влияют значения скорости обучение (`learning_rate`) и коэффициента импульса (`momentum`)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 4. Решение задачи логического «ИЛИ»<a id=\"p_4\"></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "По образцу решения задачи логического «И» решите с помощью нейронной сети, состоящей из одного нейрона, задачу логического «ИЛИ» логическое сложение). Напомним, павило работы операции:\n",
    " - выход равен `0` олько если оба входа равны `0`;\n",
    " - выход равен `1` если хотя бы один из входов равен `1`.\n",
    "\n",
    "Таблица истинности операции «ИЛИ»:\n",
    "\n",
    "$$\n",
    "\\begin{array}{c|c|c}\n",
    "x_1 & x_2 & y \\\\\n",
    "\\hline\n",
    "0 & 0 & 0 \\\\\n",
    "0 & 1 & 1 \\\\\n",
    "1 & 0 & 1 \\\\\n",
    "1 & 1 & 1 \\\\\n",
    "\\end{array}\n",
    "$$\n",
    "\n",
    "Результат в массиве `y_or_data`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "y_or_data = np.array([0, 1, 1, 1]).reshape(-1, 1)\n",
    "print(y_or_data)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Ваш код здесь"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Ваш код здесь"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Проверьте обученную вами сеть:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "assert accuracy(model_answer_interpretation, y_or_data) == 1\n",
    "assert np.mean(loss_history[:5]) > np.mean(loss_history[-5:])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Поэкспериментируйте в процессе подбора параметров. Сделайте несколько вариантов с разными параметрами и выберите лучшие по качеству обучения модели, по её ошибке и по количеству эпох (т.е. как быстро она обучилась с такими параметрами).\n",
    "\n",
    "Отдельно проведите исследование, как на обучение сети влияют значения скорости обучение (`learning_rate`) и коэффициента импульса (`momentum`)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 5. Решение задачи логического «исключающего ИЛИ»<a id=\"p_5\"></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Правило работы операции «исключающего ИЛИ» (сумма по модулю 2):\n",
    " - выход равен `0` если входы одинаковы (оба `0` или оба `1`);\n",
    " - выход равен `1` если входы различны (один `0`, другой `1`).\n",
    "\n",
    "Таблица истинности операции «исключающего ИЛИ»:\n",
    "\n",
    "$$\n",
    "\\begin{array}{c|c|c}\n",
    "x_1 & x_2 & y \\\\\n",
    "\\hline\n",
    "0 & 0 & 0 \\\\\n",
    "0 & 1 & 1 \\\\\n",
    "1 & 0 & 1 \\\\\n",
    "1 & 1 & 0 \\\\\n",
    "\\end{array}\n",
    "$$\n",
    "\n",
    "Результат в массиве `y_xor_data`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "y_xor_data = np.array([0, 1, 1, 0]).reshape(-1, 1)\n",
    "print(y_xor_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Рассмотрим проблему «исключающего ИЛИ» с помощью графического представления, сопоставив её с другими базовыми логическими операциями.\n",
    "\n",
    "Операции «И» и «ИЛИ» могут быть решены одним линейным классификатором — то есть нейронной сетью с единственным нейроном. Это возможно, поскольку данные операции являются линейно разделимыми: их входные комбинации можно разделить на классы с помощью одной прямой (гиперплоскости в двумерном пространстве):\n",
    "\n",
    "![Операции И и ИЛИ](https://sun9-14.userapi.com/s/v1/ig2/qqdYL6RKIEnKOVTQX57Zh4dx_oJ9qL8LNABXJKc4eHWY34C-dsGXyi6-45BTy93H4xzf90snp3QV8QwGwVrlJVBU.jpg?quality=95&as=32x18,48x27,72x41,108x61,160x90,240x135,327x184&from=bu&cs=327x0)\n",
    "\n",
    "В отличие от них, операция «исключающего ИЛИ» не является линейно разделимой. Для её реализации требуется как минимум два линейных классификатора (или нейронная сеть с одним скрытым слоем, содержащим два нейрона). Такая архитектура позволяет:\n",
    " - сначала выделить промежуточные признаки (например, области «выше» и «ниже» двух разделяющих прямых);\n",
    " - затем скомбинировать их для получения итогового результата «исключающего ИЛИ».\n",
    "\n",
    "![Операция исключающего ИЛИ](https://sun9-16.userapi.com/s/v1/ig2/55_GHsfUuJqQ8t2pUupUc7FxS-IlhKzaXoy1yhm95eHK8HsT9qW8hv13N67dvFKg6D_e1gaNBSgTPnvtRSd5XM4h.jpg?quality=95&as=32x18,48x26,72x40,108x59,160x88,240x132,298x164&from=bu&cs=298x0)\n",
    "\n",
    "Таким образом, задача «исключающего ИЛИ» демонстрирует необходимость нелинейного разделения и служит классическим примером, показывающим ограничения однонейронных моделей и пользу скрытых слоёв."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Рассмотрим, как данные проходят через нейронную сеть при решении задачи «исключающего ИЛИ» (прямой проход). Для наглядности и удобства отслеживания размерностей зададим в скрытом слое 3 нейрона.\n",
    "\n",
    "Задаём веса связей от входного слоя к скрытому и смещение для каждого из нейронов скрытого слоя (веса и смещения входного слоя):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "n_hidden_neurons = 3\n",
    "\n",
    "w1 = np.random.uniform(low=-.05, high=.05, size=(2, n_hidden_neurons))\n",
    "b1 = np.random.uniform(low=-.05, high=.05, size=(1, n_hidden_neurons))\n",
    "\n",
    "print('Веса входного слоя:\\n', w1)\n",
    "print('\\nСмещения входного слоя:\\n', b1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Для завершения архитектуры сети зададим параметры веса связей от скрытого слоя к выходу и смещение (выходного слоя):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "w2 = np.random.uniform(low=-.05, high=.05, size=(n_hidden_neurons, 1))\n",
    "b2 = np.random.uniform(low=-.05, high=.05, size=(1, 1))\n",
    "\n",
    "print('Веса выходного слоя:\\n', w2)\n",
    "print('\\nСмещение выходного слоя:\\n', b2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Умножаем входные данные размера $(n, 2)$ на веса входного слоя размера $(2, h)$ (где $h$ — количество нейронов в скрытом слое) и прибавляем к каждому столбцу результата соответствующее смещение для каждого нейрона скрытого слоя.\n",
    "\n",
    "Это формирует матрицу со взвешенными суммами (`s1`) размера $(n, h)$, которая далее будет передана через функцию активации:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "s1 = np.dot(X_data, w1) + b1\n",
    "print(s1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Полученный результат `s1` пропускаем через функцию активации скрытого слоя (пусть это будет сигмоида):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "z1 = sigmoid(s1)\n",
    "print(z1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Умножаем выходные данные скрытого слоя `z1` размера $(n, h)$ на веса выходного слоя $(h, 1)$ и прибавляем к полученному столбцу размера $(n, 1)$ смещение для выходного нейрона:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "s2 = np.dot(z1, w2) + b2\n",
    "print(s2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Пропускаем `s2` через функцию активации и получаем выход нейронной сети `z2`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "z2 = sigmoid(s2)\n",
    "print(z2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Значение функции потерь:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "mse(z2, y_xor_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Метрика accuracy:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "model_answer_interpretation = (z2 >= 0.5).astype('int')\n",
    "print(model_answer_interpretation)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "accuracy(model_answer_interpretation, y_xor_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В прямом прохое мы убедились, что можем задавать любое количество нейронов в скрытом слое для решения нашей задачи (и ей подобных), и получили адекватный результат.\n",
    "\n",
    "Перейдём к обучению нейронной сети через обратное распространение ошибки. Рассмотрим обратный проход.\n",
    "\n",
    "В ходе прямого прохода мы продемонстрировали ключевую гибкость архитектуры:\n",
    "- можно задавать любое количество нейронов в скрытом слое — это позволяет адаптировать модель под сложность задачи (в том числе для «исключающего ИЛИ» и аналогичных);\n",
    "- даже при произвольном числе нейронов сеть выдаёт адекватный промежуточный результат на выходе.\n",
    "\n",
    "Теперь переходим к следующему этапу — обучению нейронной сети. Основной механизм обучения — также обратное распространение ошибки (backpropagation). Сосредоточимся на обратном проходе и покажем:\n",
    "1. Как вычисляются градиенты функции потерь по параметрам сети.\n",
    "2. Как эти градиенты используются для корректировки весов и смещений.\n",
    "3. Каким образом ошибка «распространяется» от выходного слоя к входному."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Напомним исходные веса и смещение в нашей задаче:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('Веса входного слоя:\\n', w1)\n",
    "print('\\nСмещения входного слоя:\\n', b1)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('Веса выходного слоя:\\n', w2)\n",
    "print('\\nСмещение выходного слоя:\\n', b2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Задайте скорость обучение и коэффициент импульса (согласно рекомендациям для каждого из этих гиперпараметров):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "learning_rate = # Выш код здесь\n",
    "momentum = # Выш код здесь"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Инициализируем нулями значения скоростей:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "velocity_w1 = np.zeros_like(w1)\n",
    "velocity_b1 = np.zeros_like(b1)\n",
    "velocity_w2 = np.zeros_like(w2)\n",
    "velocity_b2 = np.zeros_like(b2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Рассмотрим пошагово реализацию кода обратного распространения ошибки.\n",
    "\n",
    "<a id=\"loop_xor\"></a>Первый шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dL_dz2 = mse_derivative(z2, y_xor_data)\n",
    "print(dL_dz2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вычисляются значения производной функции потерь (MSE) по выходу выходного нейрона $z_2$.\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial z_2} = \\frac{\\partial}{\\partial z_2}\\left[(z_2 - y)^2\\right] = 2 \\cdot (z_2 - y),\n",
    "   $$  \n",
    "   где:  \n",
    "   - $z_2$ (`z2`) — выход выходного нейрона (предсказание сети);  \n",
    "   - $y$ (`y_xor_data`) — истинные значения (целевые метки для XOR).\n",
    "\n",
    "Размерность:  \n",
    "   - `z2`: $(n, 1)$ — $n$ объектов, 1 выходной нейрон;  \n",
    "   - `y_xor_data`: $(n, 1)$;  \n",
    "   - результат `dL_dz2`: $(n, 1)$.\n",
    "   \n",
    "Второй шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dz2_ds2 = sigmoid_derivative(s2)\n",
    "print(dz2_ds2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вычисляются значения производной сигмоиды по её входу $s_2$ (взвешенной сумме на выходном нейроне).\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial z_2}{\\partial s_2} = \\sigma'(s_2) = \\sigma(s_2) \\cdot (1 - \\sigma(s_2)) = z_2 \\cdot (1 - z_2),\n",
    "   $$  \n",
    "   где $\\sigma(s_2)$ — функция сигмоиды, $s_2$ (`s2`) — взвешенная сумма на выходном нейроне.\n",
    "\n",
    "Размерность:  \n",
    "   - `s2`: $(n, 1)$ — $n$ объектов, 1 нейрон;  \n",
    "   - результат `dz2_ds2`: $(n, 1)$.\n",
    "   \n",
    "Третий шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dL_ds2 = dL_dz2 * dz2_ds2\n",
    "print(dL_ds2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вычисляются значения производной функции потерь по взвешенной сумме $s_2$ через правило частных производных.\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial s_2} = \\frac{\\partial \\mathcal{L}}{\\partial z_2} \\cdot \\frac{\\partial z_2}{\\partial s_2},\n",
    "   $$  \n",
    "\n",
    "Размерность (поэлементное умножение):  \n",
    "   - `dL_dz2`: $(n, 1)$;  \n",
    "   - `dz2_ds2`: $(n, 1)$;  \n",
    "   - результат `dL_ds2`: $(n, 1)$.\n",
    "\n",
    "Таким образом, объединены два предыдущих градиента:  \n",
    "   - как ошибка зависит от выхода $z_2$ (`dL_dz2`);  \n",
    "   - как выход $z_2$ зависит от входа $s_2$ (`dz2_ds2`).  \n",
    "\n",
    "Четвёртый шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dL_dw2 = np.dot(z1.T, dL_ds2)\n",
    "dL_db2 = np.sum(dL_ds2, axis=0, keepdims=True)\n",
    "\n",
    "# Проверки размерностей: \n",
    "assert dL_dw2.shape == w2.shape\n",
    "assert dL_db2.shape == b2.shape\n",
    "\n",
    "print(dL_dw2)\n",
    "print(dL_db2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В первой строке вычисляются значения градиента функции потерь по весам выходного слоя $W_2$: $\\frac{\\partial \\mathcal{L}}{\\partial W_2}$.\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial W_2} = z_1^\\mathsf{T} \\cdot \\frac{\\partial \\mathcal{L}}{\\partial s_2},\n",
    "   $$  \n",
    "   где:  \n",
    "   - $z_1$ (`z1`) — активации скрытого слоя (выход скрытого слоя после активации);  \n",
    "   - $\\frac{\\partial \\mathcal{L}}{\\partial s_2}$ (`dL_ds2`) — градиент потерь по взвешенной сумме на выходном нейроне;  \n",
    "   - $\\cdot$ — матричное умножение.\n",
    "\n",
    "Размерность:  \n",
    "   - `z1`: $(n, h)$ — $n$ объектов, $h$ нейронов в скрытом слое;  \n",
    "   - `dL_ds2`: $(n, 1)$ — градиент по выходному нейрону для $n$ объектов;  \n",
    "   - результат `dL_dw2`: $(h, 1)$ — соответствует размеру `w2` $(h, 1)$.\n",
    "\n",
    "В итоге получаем матрицу градиентов, показывающую, как изменение каждого веса $W_2$ влияет на ошибку. Её мы передадим в формулу обновления весов выходного слоя.\n",
    "\n",
    "Во второй строке вычисляются значения градиента функции потерь по смещениям выходного слоя $b_2$: $\\frac{\\partial \\mathcal{L}}{\\partial b_2}$.\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial b_2} = \\sum_{i=1}^{n} \\frac{\\partial \\mathcal{L}}{\\partial s_{2i}},\n",
    "   $$  \n",
    "   где $s_{2i}$ — взвешенная сумма для $i$-го объекта.\n",
    "\n",
    "Размерность:  \n",
    "   - `dL_ds2`: $(n, 1)$;  \n",
    "   - результат `dL_db2`: $(1, 1)$.\n",
    "\n",
    "Смещение $b_2$ одинаково применяется ко всем объектам, поэтому его градиент — это сумма градиентов `dL_ds2` по всем $n$ примерам. Параметр `axis=0` задаёт суммирование по оси объектов ($n$), `keepdims=True` сохраняет размерность $(1, 1)$, чтобы соответствовать форме `b2` $(1, 1)$. Результат мы передадим в формулу обновления смещения выходного слоя.\n",
    "\n",
    "Пятый шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dL_dz1 = np.dot(dL_ds2, w2.T)\n",
    "print(dL_dz1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вычисляются значения градиента функции потерь по выходам скрытого слоя $z_1$: $\\frac{\\partial \\mathcal{L}}{\\partial z_1}$.\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial z_1} = \\frac{\\partial \\mathcal{L}}{\\partial s_2} \\cdot W_2^\\mathsf{T},\n",
    "   $$  \n",
    "   где:  \n",
    "   - $\\frac{\\partial \\mathcal{L}}{\\partial s_2}$ (`dL_ds2`) — градиент по взвешенной сумме выходного нейрона;  \n",
    "   - $W_2$ (`w2`) — веса между скрытым и выходным слоем;  \n",
    "   - $\\cdot$ — матричное умножение.\n",
    "\n",
    "Размерность:  \n",
    "   - `dL_ds2`: $(n, 1)$ — градиент для $n$ объектов по выходному нейрону;  \n",
    "   - `w2.T`: $(1, h)$ — транспонированные веса выходного слоя (1 выход × $h$ скрытых нейрона);  \n",
    "   - результат `dL_dz1`: $(n, h)$ — градиент по выходам 3 нейронов скрытого слоя для $n$ объектов.\n",
    "   \n",
    "Шестой шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dz1_ds1 = sigmoid_derivative(s1)\n",
    "print(dz1_ds1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вычисляются значения производной сигмоиды по взвешенным суммам скрытого слоя: $\\frac{\\partial z_1}{\\partial s_1}$.\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial z_1}{\\partial s_1} = \\sigma'(s_1) = z_1 \\cdot (1 - z_1),\n",
    "   $$  \n",
    "   где $\\sigma(s_1)$ — функция сигмоиды, $s_1$ (`s1`) — взвешенные суммы на нейронах скрытого слоя.\n",
    "\n",
    "Размерность:  \n",
    "   - `s1`: $(n, h)$ — $n$ объектов, $h$ нейронов скрытого слоя;  \n",
    "   - результат `dz1_ds1`: $(n, h)$.\n",
    "   \n",
    "Седьмой шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dL_ds1 = dL_dz1 * dz1_ds1\n",
    "print(dL_ds1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вычисляются значения градиаента функции потерь по взвешенным суммам скрытого слоя через правило частных производных: $\\frac{\\partial \\mathcal{L}}{\\partial s_1}$.\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial s_1} = \\frac{\\partial \\mathcal{L}}{\\partial z_1} \\cdot \\frac{\\partial z_1}{\\partial s_1},\n",
    "   $$  \n",
    "\n",
    "Размерность (поэлементное умножение):  \n",
    "   - `dL_dz1`: $(n, h)$;  \n",
    "   - `dz1_ds1`: $(n, h)$;  \n",
    "   - результат `dL_ds1`: $(n, h)$.\n",
    "\n",
    "Таким образом, объединены два предыдущих градиента:\n",
    "   - как ошибка зависит от выходов скрытого слоя (`dL_dz1`);  \n",
    "   - как выходы зависят от взвешенных сумм (`dz1_ds1`).  \n",
    "\n",
    "Последний шаг:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dL_dw1 = np.dot(X_data.T, dL_ds1)\n",
    "dL_db1 = np.sum(dL_ds1, axis=0, keepdims=True)\n",
    "\n",
    "# Проверки размерностей:\n",
    "assert w1.shape == dL_dw1.shape\n",
    "assert b1.shape == dL_db1.shape\n",
    "\n",
    "print(dL_dw1)\n",
    "print(dL_db1)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В первой строке вычисляются значения градиента функции потерь по весам входного слоя: $\\frac{\\partial \\mathcal{L}}{\\partial W_1}$.\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial W_1} = X^\\mathsf{T} \\cdot \\frac{\\partial \\mathcal{L}}{\\partial s_1},\n",
    "   $$  \n",
    "   где $X$ (`X_data`) — входные данные.\n",
    "\n",
    "Размерность:  \n",
    "   - `X_data.T`: $(2, n)$ — транспонированные входные данные (2 признака × $n$ объектов);  \n",
    "   - `dL_ds1`: $(n, h)$ — градиенты по взвешенным суммам скрытого слоя;  \n",
    "   - результат `dL_dw1`: $(2, h)$ — соответствует размеру `w1` $(2, h)$.\n",
    "\n",
    "В итоге получаем матрицу градиентов обновления весов входного слоя.\n",
    "\n",
    "Во второй строке вычисляются значения градиента функции потерь по смещениям входного слоя: $\\frac{\\partial \\mathcal{L}}{\\partial b_1}$.\n",
    "\n",
    "Математическая запись:  \n",
    "   $$\n",
    "   \\frac{\\partial \\mathcal{L}}{\\partial b_1} = \\sum_{i=1}^{n} \\frac{\\partial \\mathcal{L}}{\\partial s_{1i}},\n",
    "   $$  \n",
    "   где $s_{1i}$ — взвешенная сумма для $i$-го объекта.\n",
    "\n",
    "Размерность:  \n",
    "   - `dL_ds1`: $(n, h)$;  \n",
    "   - результат `dL_db1`: $(1, h)$ (благодаря `keepdims=True`).\n",
    "\n",
    "В итоге получаем матрицу градиентов обновления смещений входного слоя."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "В результате обратного прохода все требуемые градиаенты получены.\n",
    "\n",
    "Обновим скорости для всех соответствующих весов и смещений:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "velocity_w1 = momentum * velocity_w1 + learning_rate * dL_dw1\n",
    "velocity_b1 = momentum * velocity_b1 + learning_rate * dL_db1\n",
    "velocity_w2 = momentum * velocity_w2 + learning_rate * dL_dw2\n",
    "velocity_b2 = momentum * velocity_b2 + learning_rate * dL_db2"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Обновим сами веса и смещения:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "w1 -= velocity_w1\n",
    "b1 -= velocity_b1\n",
    "w2 -= velocity_w2\n",
    "b2 -= velocity_b2"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Произведём с обновлёнными весами прямой проход данных через нейронную сеть и получим значение среднеквадратичной ошибки:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "s1 = np.dot(X_data, w1) + b1\n",
    "z1 = sigmoid(s1)\n",
    "s2 = np.dot(z1, w2) + b2\n",
    "z2 = sigmoid(s2)\n",
    "\n",
    "mse(z2, y_xor_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Доля правильно классифицированных примеров:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "model_answer_interpretation = (z2 >= 0.5).astype('int')\n",
    "accuracy(model_answer_interpretation, y_xor_data)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Сделайте несколько проходов (итераций) от [первого шага обратного распространения ошибки](#loop_xor) до данной ячейки. Убедитесь, что ошибка сети понижается, а качество классификации растёт."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Сведём весь код решения задачи логического «исключающего ИЛИ». Вы можете опираться на него либо реализовать своё решение.\n",
    "\n",
    "Перед обучением сети задайте (или передайте параметрами в реализованную функцию):\n",
    " - данные для обучения (входные и выходные);\n",
    " - функцию потерь и её производные (можете использовать реализации для MSE);\n",
    " - функции активации для неронов входного и выходного слоёв, а также их производные (можете выбрать либо одну и ту же функцию, либо разные); \n",
    " - количество нейронов в скрытом слое (можно оставить 2)\n",
    " - начальные значения весов и смещения (приведён пример задания через нормальное распределение) (главное, соблюдать размерность);\n",
    " - количество эпох или итераций в процессе обучения (желательно больше 5);\n",
    " - скорость обучение и коэффициент импульса."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X = X_data\n",
    "y_true = y_xor_data\n",
    "\n",
    "loss_function = # Ваш код здесь\n",
    "loss_derivative = # Ваш код здесь\n",
    "activate_function_1 = # Ваш код здесь\n",
    "activate_derivative_1 = # Ваш код здесь\n",
    "activate_function_2 = # Ваш код здесь\n",
    "activate_derivative_2 = # Ваш код здесь\n",
    "\n",
    "n_hidden_neurons = # Ваш код здесь\n",
    "\n",
    "# Перебор seed для инициализации параметров\n",
    "np.random.seed(seed=42)\n",
    "\n",
    "# Входной слой\n",
    "w1 = np.random.randn(2, n_hidden_neurons) * 0.5\n",
    "b1 = np.random.randn(1, n_hidden_neurons) * 0.5\n",
    "\n",
    "# Выходной слой\n",
    "w2 = np.random.randn(n_hidden_neurons, 1) * 0.5\n",
    "b2 = np.random.randn(1, 1) * 0.5\n",
    "\n",
    "epochs = # Ваш код здесь\n",
    "\n",
    "# Гиперпараметры\n",
    "learning_rate = # Ваш код здесь\n",
    "momentum_beta = # Ваш код здесь"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Обучение нейронной сети для решения задачи «исключающего ИЛИ»:**"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "# Инициализация скоростей для импульса\n",
    "velocity_w1 = np.zeros_like(w1)\n",
    "velocity_b1 = np.zeros_like(b1)\n",
    "velocity_w2 = np.zeros_like(w2)\n",
    "velocity_b2 = np.zeros_like(b2)\n",
    "\n",
    "# Список для хранения истории потерь\n",
    "loss_history = []\n",
    "\n",
    "for epoch in range(epochs):\n",
    "    \n",
    "    # Прямой проход\n",
    "    s1 = np.dot(X, w1) + b1\n",
    "    z1 = activate_function_1(s1)\n",
    "    \n",
    "    s2 = np.dot(z1, w2) + b2\n",
    "    z2 = activate_function_2(s2)\n",
    "    \n",
    "    model_answer_interpretation = (z2 >= 0.5).astype('int')\n",
    "    \n",
    "    # Вычисляем ошибку\n",
    "    loss = loss_function(y_true, z2)\n",
    "    loss_history.append(loss)\n",
    "    \n",
    "    # Обратное распространение (backprop)\n",
    "    # Выходной слой\n",
    "    dL_dz2 = loss_derivative(z2, y_true)\n",
    "    dz2_ds2 = activate_derivative_2(s2)\n",
    "    dL_ds2 = dL_dz2 * dz2_ds2\n",
    "    \n",
    "    # Градиенты для w2 и b2\n",
    "    dL_dw2 = np.dot(z1.T, dL_ds2)\n",
    "    dL_db2 = np.sum(dL_ds2, axis=0, keepdims=True)\n",
    "    \n",
    "    # Переход к скрытому слою\n",
    "    dL_dz1 = np.dot(dL_ds2, w2.T)\n",
    "    dz1_ds1 = activate_derivative_1(s1)\n",
    "    dL_ds1 = dL_dz1 * dz1_ds1\n",
    "    \n",
    "    # Градиенты для w1 и b1\n",
    "    dL_dw1 = np.dot(X.T, dL_ds1)\n",
    "    dL_db1 = np.sum(dL_ds1, axis=0, keepdims=True)\n",
    "    \n",
    "    # Обновление скоростей\n",
    "    velocity_w1 = momentum * velocity_w1 + learning_rate * dL_dw1\n",
    "    velocity_b1 = momentum * velocity_b1 + learning_rate * dL_db1\n",
    "    velocity_w2 = momentum * velocity_w2 + learning_rate * dL_dw2\n",
    "    velocity_b2 = momentum * velocity_b2 + learning_rate * dL_db2\n",
    "    \n",
    "    # Обновление параметров\n",
    "    w1 -= velocity_w1\n",
    "    b1 -= velocity_b1\n",
    "    w2 -= velocity_w2\n",
    "    b2 -= velocity_b2\n",
    "    \n",
    "    # Вывод каждые 5 эпох\n",
    "    if (epoch + 1) % 5 == 0:\n",
    "\n",
    "        clear_output(True)\n",
    "        plt.plot(range(1, epoch+2), loss_history, label='Loss')\n",
    "        plt.title(f'Epoch: {epoch + 1}, Loss: {loss:.6f}')           \n",
    "        plt.grid(True, alpha=0.3)\n",
    "        plt.legend(loc='best')\n",
    "        plt.show()\n",
    "\n",
    "        print('Test:')\n",
    "        for i in range(len(X)):\n",
    "            print(f'Input: {X[i]}, Output: {np.round(z2[i], 2)}, Pred: {model_answer_interpretation[i]}, True: {y_true[i]}')\n",
    "\n",
    "        print(f'Accuracy: {accuracy(model_answer_interpretation, y_true):.2f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Убедитесь, что сеть обучена хорошо — т.е. у неё высокое качество классификации, а в процессе обучения ошибка стаюильно снижалась:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "assert accuracy(model_answer_interpretation, y_xor_data) == 1\n",
    "assert np.mean(loss_history[:5]) > np.mean(loss_history[-5:])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Поэкспериментируйте в процессе подбора параметров. Сделайте несколько вариантов с разными параметрами и выберите лучшие по качеству обучения модели, по её ошибке и по количеству эпох (т.е. как быстро она обучилась с такими параметрами).\n",
    "\n",
    "Отдельно проведите исследование, как на обучение сети влияют значения скорости обучение (`learning_rate`) и коэффициента импульса (`momentum`).\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Литература:\n",
    "1. Бородкин А.А., Елисеев В.Л. Основы и применение искусственных нейронных сетей. Сборник лабораторных работ: методическое пособие. – М.: Издательский дом МЭИ, 2017.\n",
    "2. MachineLearning.ru  —  профессиональный информационно-аналитический ресурс, посвященный машинному обучению, распознаванию образов и интеллектуальному анализу данных: http://www.machinelearning.ru\n",
    "3. Modern State of Artificial Intelligence  —  Online Masters program at MIPT: https://girafe.ai/"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    " "
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.7.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
