В предыдущей статье мы описали алгоритм машинного обучения с помощью нейронной сети. В этой статье мы построим код на языке Python для внедрения этого алгоритма. Мы будем использовать термины и обозначения из предыдущей статьи.

Для работы с векторами и матрицами мы будем использовать библиотеку NumPy, которая изначально написана на C, то есть очень быстра в исполнении.

Структура нейронной сети

Наша задача сконструировать такую нейронную сеть, которую легко инициализировать глобальными параметрами, а именно:

  • Размерностью входного вектора астрологических признаков input_dim
  • Размерностью каждого скрытого слоя, которую можно задать через массив чисел hidden_dims, например hidden_dims=[16, 9] означало бы, что у нас есть 2 скрытых слоя в 16 и 9 нейронов соответственно.
  • Шагом градиентного спуска, (learning rate, lr)
  • Количеством эпох для обучения модели — epoches
  • Числом примеров в отдельной подвыборке из тренировочных данных — batch_size

Для этого мы создадим класс BernoulliNet, который можно инициализировать этими параметрами, например:

model = BernoulliNet(
    input_dim=8,
    hidden_dims=[16, 9],
    lr=0.05,
    epochs=100,
    batch_size=32,
)

Здесь параметры input_dims и hidden_dims определяет общую структуру нейросети

dims = [input_dim, 16, 9, 1]
#           ↑       ↑  ↑  ↑
#        слой 0     1  2  3 (выходной)

При этом матрицы весов будут иметь следующие размерности в этом примере:

Мы сделаем веса и смещения внутренними свойствами модели, то есть

class BernoulliNet:

    def __init__(self,
                 input_dim: int,
                 hidden_dims: list[int],
                 lr: float = 0.01,
                 epochs: int = 100,
                 batch_size: int = 32,
                 ) -> None:
        # Размерность всей нейросети
        dims: list[int] = [input_dim] + hidden_dims + [1]
        # Инициализация весов
        self.W, self.b = self._init_weights(dims)

Поскольку мы будем пробегать по значениям в компьютерной нотации это будут элементы массива с индексами self.W[0], self.W[1], self.W[2]. Аналогично смещения будут хранится в массиве self.b[0], self.b[1], self.b[2]

В общем случае

  • матрица весов self.W[layer-1] соответствует размерностью .
  • вектор-столбец смещений self.b[layer-1] соответствует вектору-столбцу размерностью .

Типы данных

Мы договорились ранее, что нижние индексы обозначают вектор-столбец Формально — это матрица размерностью строк и одним столбцом .

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

Любые матрицы, наполненные десятичными числами в библиотеке NumPy — это объекты NDArray[np.float64], поэтому чтобы отличать их размерности мы введем явную аннотацию типов данных, которыми мы будем оперировать в коде:

import numpy as np
from numpy.typing import NDArray

# Геометрические типы
# ===================

# [n × 1] — вектор-столбец
Vector = NDArray[np.float64]

# [1 × n] — вектор-строка
CoVector = NDArray[np.float64]

# [m × n] — матрица весов
Matrix = NDArray[np.float64]

# Семантические типы
# ==================

# [n × 1] — один пример (набор входных x⃗ или промежуточных h⃗)
Sample = NDArray[np.float64]

# [n × N] — N примеров (N наборов входных x⃗ или промежуточных h⃗ для всего батча)
Batch = NDArray[np.float64]

# [n × N_total] — весь датасет признаков x⃗ из AstroDataBank
Dataset = NDArray[np.float64]

Это никак не влияет на функциональность — мы вводим эти аннотации исключительно для удобства восприятия кода.

Функции активации для нейронной сети

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

Выходной слой

Напомню, что функция активации выходного слоя — сигмоида — применяется для каждого -го примера в батче. Вместо применения сигмоиды к каждому мы можем делегировать это поэлементное вычисление библиотека NumPy которая выполняет эту задачу на скомпилированном C-коде, что даёт существенный выигрыш в скорости. Для этого достаточно передать в функцию сразу весь набор из примеров. то есть вектор-строку размерностью :

def sigmoid(z: Batch) -> Batch:
    return 1.0 / (1.0 + np.exp(-z))

Промежуточные слои

Функция активации промежуточных слоев для каждого -го примера в батче реализуется по тому же принципу — мы делегируем NumPy поэлементное применение функции, передавая ей весь набор из примеров:

def relu(z: Batch) -> Batch:
    return np.maximum(0, z)

Производная функции ReLU равна 1 для и 0 во всех остальных случаях. Мы можем представить это в виде строки логических значений , в котором затем мы поэлементно преобразуем каждое значение в 1.0 и каждое — в 0.0 через метод .astype() библиотеки NumPy:

def d_relu(z: Batch) -> Batch:
    result: Batch = (z > 0).astype(np.float64)
    return result

Градиентный спуск для нейронной сети

Функция потерь

Напомню, что функция потерь для всех примеров из батча описывается формулой:

где — предсказанная вероятность конкретного события на примере -го гороскопа, а — метка реального события (1, если события произошло и 0 — если иначе). В библиотеке NumPy есть встроенная функция усреднения mean, которая эквивалентна , поэтому функцию потерь можно реализовать так:

def loss(self, p_hat: Batch, y: Batch) -> float:
    """
    Бинарная кросс-энтропия (логарифмическое правдоподобие Бернулли)
    """
    return float(
        -np.mean(
            y * np.log(p_hat + 1e-9)
            + (1 - y) * np.log(1 - p_hat + 1e-9)
        )
    )

Здесь p_hat и y — это векторы-строки размерностью

Инициализация весов и смещений

Мы говорили в первой части статьи, что для стабильного обучения надо, чтобы веса в каждом слое были инициированы случайными числами с нулевым средним и дисперсией , где — число нейронов в предыдущем слое.

Для одного слоя

В библиотеке NumPy инициализацию матрицу весов размерностью для каждого -го слоя можно реализовать так:

W_layer: Matrix = np.random.normal( # Нормальное распределение
    loc=0.0,                        # среднее значение
    scale=np.sqrt(2.0 / n_prev),    # Дисперсия
    size=(n_curr, n_prev),          # Размер матрицы 
).astype(np.float64)        # десятичные числа в ячейках матрицы

Также мы должны инициализировать нулями вектор-столбец смещения размерностью для каждого -го слоя. В нотации NumPy это можно реализовать так:

b_layer: Vector = np.zeros( # Массив нулей
    (n_curr, 1),            # в виде столбца
    dtype=np.float64        # в десятичной форме, т.е. 0.0
)

Для всех слоев

Чтобы инициализировать параметры во всех слоях нейросети мы просто последовательно применяем эти формулы на стыке соседних слоев:

for layer in range(len(dims) - 1):
    n_prev = dims[layer]      # число нейронов в предыдущем слое
    n_curr = dims[layer + 1]  # число нейронов в текущем слое

    # Далее инициируем матрицы весов и вектора смещений

После этого мы добавляем эти первичные значения в результирующий список весов и смещений для всех слоев нейросети self.W[0], self.W[1], self.W[2] и self.b[0], self.b[1], self.b[2], что соответствует и в наших формулах.

Прямой проход

Как вы помните из предыдущей статьи после инициации весов и смещений мы сначала совершаем прямой проход, применяя для каждого -го примера в батче формулу:

и затем мы сохраняем эти значения в кэш, чтобы использовать их далее в обратном проходе.

Как обычно, вместо циклического применения этих формул к каждому примеру отдельно, с точки зрения скорости вычисления эффективней передать сразу весь массив примеров библиотеке NumPy — ее матричные вычисления существенно быстрее, чем обычный цикл перебора на языке Python.

Нулевой слой

В нулевом слое для каждого -го примера в батче — вектор-столбец размерностью . А для всех примеров в батче этот вектор превращается в матрицу размерностью .

В нотации NumPy эту матрицу можно задать так:

H: Batch = X 
H_cache: list[Batch] = [H]

В первой строке мы делаем поэлементное присвоение. А во второй строке мы инициализируем временное хранилище H_cache матриц , которые понадобятся нам при обратном проходе. Для инициализации мы просто заносим значение в массив H_cache. Итого

  • H_cache[0], H_cache[1], H_cache[2]соответствует матрицам
  • Размерность каждой матрицы .

Скрытые слои

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

В нотации NumPy эти матрицы можно реализовать так:

for W, b in zip(self.W[:-1], self.b[:-1]):
	H = relu(W @ H + b)
    H_cache.append(H)

Здесь в первой строке внутри цикла:

  • Переменная H — это выход предыдущего слоя размерностью .
  • Переменная W — это self.W[layer-1], то есть матрица размерностью
  • b — это вектор self.b[layer-1] размерностью , который NumPy автоматически расширяет до матрицы простым повторением столбцов, чтобы обеспечить операцию сложения.
  • W @ H + b — это матрица размером , к которой поэлементно применяется ReLU
  • Результат поэлементного применения функции активации заносится в переменную H, которая соответствует в нашей формуле и будет использоваться в следующем цикле.

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

Обычный цикл Питона обеспечивает проход по всем слоям. Мы берем набор (self.W[layer], self.b[layer]) за минусом последнего слоя, что соответствует в наших формулах.

Здесь функция zip склеивает массивы и в массив , а нотация [:-1] означает, что мы пробегаемся по всем значения self.W[0], self.W[1], ... и self.b[0], self.b[1], ..., кроме последнего, то есть по и .

Выходной слой

Для выходного слоя мы применяем формулу для каждого -го примера. Для одного примера — это скаляр, то есть матрица , а для всего батча примеров — это вектор-строка размером . Как обычно, мы передаем библиотеке NumPy сразу весь набор примеров из батча для быстрого последовательного применения сигмоиды:

p_hat: Batch = sigmoid(
	self.W[-1] @ H + self.b[-1]
)

Здесь нотация [-1] означает, что мы берем последний элемент массива self.W, то есть матрицу выходного слоя размерностью , умножаем ее на выход предыдущего слоя размером , что дает нам вектор-строку размером , и далее мы прибавляем последний элемент списка self.b размерностью , который библиотека NumPy преобразует в вектор-строку размером простым повторением числа b раз.

В итоге мы получаем вектор-строку с с числами от 0 до 1 — вероятностями наступления события в каждом из примеров в батче.

Обратный проход

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

Дельта выходного слоя

Напомню, что дельта выходного слоя для каждого -го примера — это скаляр Но для всех примеров в батче — это вектор-строка размером .

В нотации библиотеки NumPy этот вектор реализуется так:

delta: Batch = (p_hat - y) / N

Здесь операция вычитания двух массивов (p_hat - y) вычисляет разность для каждого элемента вектора батча поэлементно, а при операции деления на скаляр N автоматически выполняет усреднение.

Дельты промежуточных слоев

Как вы помните, для промежуточных слоев рекуррентная формула для дельты каждого слоя для каждого -го примера выглядит так:

Шаг 1

Сначала реализуем вычисление

Для каждого -го примера — это вектор-столбец размерностью , где — число нейронов в -м слое. А для всего батча примеров — это матрица размерностью

В нотации NumPy мы можем записать это в виде:

z_cache[layer] = self.W[layer] @ H_cache[layer] + self.b[layer]

Напомню, что H_cache — это временное хранилище (кэш) матриц , которые мы создали при прямом проходе. Каждая из матриц имеет размерность , а self.W[layer] имеет размерность , поэтому матричное произведение self.W[layer] @ H_cache[layer] имеет размерность .

Отдельный элемент self.b[layer] имеет размерность , но NumPy автоматически расширяет его до матрицы простым повторением столбцов.

Итого:

  • Элементы массива z_cache[0], z_cache[1], ... соответствуют
  • Каждая матрица имеет размерность

Шаг 2

Теперь, когда нам известны для всех скрытых слоев , мы можем перейти к реализации дельты скрытых слоев. Для каждого скрытого слоя для -го примера — это вектор-столбец размерностью , а для всего батча из примеров это уже матрица размерностью :

где пробегает значения .

В нотации NumPy эту матрицу для каждого слоя можно реализовать вот так:

for layer in range(len(self.W) - 1, 0, -1):
	delta = (self.W[layer].T @ delta) * d_relu(z_cache[layer - 1])
  • Здесь мы проходим в обратном порядке по индексам layer от предпоследнего до нулевого, что соответствует скрытым слоям .

  • Затем в каждом слое мы производим матричное умножение self.W[layer].T @ delta, что соответствует умножению матрицы размером или на размерностью для всего батча. Результирующая матрица имеет размерность

  • Наконец, мы поэлементно умножаем матрицу размером на производную той же размерности.

Градиенты по весам

Теперь, когда нам известен дельта-вектор выходного слоя размерностью для всего батча и каждая последующая дельта-матрица скрытого слоя, мы можем реализовать формулы градиентов.

Напомню, что для выходного слоя слоя

  • Для одного примера — это вектор размером
  • Для всего батча примеров — это произведение вектора-строки размерностью на транспонированную матрицу размерностью . То есть результат произведения — это матрица размером

Для скрытых слоев

  • Для одного примера — это матрица размером
  • Для всего батча примеров — это произведение матрицы размерностью на транспонированную матрицу размерностью . То есть результат произведения — это матрица размером :

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

delta @ H_cache[layer - 1].T

Здесь delta — это матрица размером . Она принимает размерность для последнего слоя, так как в нашей сети в выходном слое только 1 нейрон:

cache[layer - 1] — это элемент сохраненного при прямом проходе массива который соответствует предыдущему слою.

Так как в обратном проходе мы идем по всем слоям от последнего к первому, мы сохраняем эти градиенты в массив в обратном порядке:

# Массив градиентов по каждому слою
dW: list[Matrix] = []

# Начинаем с выходного слоя
delta: Batch = (p_hat - y) / N
dW.insert(0, delta @ H_cache[-1].T)

# Затем рекуррентно движемся к первому слою
for layer in range(len(self.W) - 1, 0, -1):
    delta = (self.W[layer].T @ delta) * d_relu(z_cache[layer - 1])
	dW.insert(0, delta @ H_cache[layer - 1].T)

Градиенты по смещениям

Аналогичным образом мы теперь можем реализовать формулы градиентов по смещению.

Напомню, что для выходного слоя слоя

  • Для одного примера — это скаляр размерностью

  • Для примеров — это произведение вектора-строки для размерностью всего батча на единичный вектор-строку размерностью то есть градиент по смещению — это скаляр размерностью

Для скрытых слоев

  • Для одного примера — это вектор размером
  • Для всех примеров батча — это произведение матрицы размером на единичный вектор-столбец размерностью , то есть результирующий градиент — это вектор-столбец размером .

Мы можем реализовать этот градиент через матричное произведение в NumPy:

delta @ np.ones((N, 1))

Но еще быстрее — просто просуммировать дельты для всех примеров в батче

delta.sum(axis=1, keepdims=True)

Здесь delta — это матрица размером . В частности для выходного слоя она имеет размерность .

sum(axis=1) означает, что мы суммируем по столбцам, то есть по N. Параметр keepdims=True означает, что мы учитываем размерность матрицы после суммирования.

Так как в обратном проходе мы идем по всем слоям от последнего к первому, мы сохраняем эти градиенты в массив в обратном порядке:

# Массив градиентов по каждому слою
db: list[Vector] = []

# Начинаем с выходного слоя
delta: Batch = (p_hat - y) / N
db.insert(0, delta.sum(axis=1, keepdims=True))

# Затем рекуррентно движемся к первому слою
for layer in range(len(self.W) - 1, 0, -1):
    delta = (self.W[layer].T @ delta) * d_relu(z_cache[layer - 1])
	db.insert(0, delta.sum(axis=1, keepdims=True))

Общий алгоритм градиентного спуска

Шаг 1. Перемешивание датасета в начале эпохи

Для каждой эпохи мы берем весь датасет и перемешивает его. Это можно реализовать так:

N_total: int = X.shape[1]
for epoch in range(1, self.epochs + 1):
    idx = np.random.permutation(N_total)
    X, y = X[:, idx], y[:, idx]

Здесь Х — это астрологические признаки всех примеров взятых из AstroDataBank, то есть матрица размером .

Сначала мы берем индексы всех примеров и перемешиваем их, затем пересобираем примеры в порядке новых (перемешанных) индексов.

Шаг 2. Разбиение датасета на батчи

Далее мы разбиваем датасет на батчи и начинаем обучение на каждом отдельном батче из N примеров. Это разбиение можно реализовать так:

for s in range(0, N_total, self.batch_size):
    Xb: Batch = X[:, s: s + self.batch_size]
    yb: Batch = y[:, s: s + self.batch_size]

Здесь мы проходим порядковые номера элементов датасета с шагом (размером батча), то есть последовательно выбираем -й элемент, кратный размеру батча.

Затем мы выделяем из датасета две подматрицы — размером и размером простым выбором столбцов в диапазоне от индекса до . Эти две подматрицы вместе образуют батч из примеров .

Шаг 3. Прямой и обратный проходы

Теперь мы должны пройтись прямым проходом по нашему батчу и сохранить результаты активации слоев — вектор-строку для выходного слоя и матрицы для всех промежуточных слоев.

Затем используя эти сохраненные величины мы совершаем обратный проход и сохраняем градиенты по весам и смещениям для всех слоев.

Этот шаг можно реализовать так:

p_hat, H_cache = forward(Xb)
dW, db = backward(p_hat, yb, H_cache)

Шаг 4. Обновление параметров

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

Это обновление параметров можно реализовать через обычный цикл Python по всем слоям:

self.W = [W - self.lr * dw for W, dw in zip(self.W, dW)]
self.b = [b - self.lr * db_ for b, db_ in zip(self.b, db)]

Полный код

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

Затем код оценивает точность предсказаний на этом же датасете.

import numpy as np
from numpy.typing import NDArray
from typing import cast
# pyright: reportConstantRedefinition=false

# Геометрические типы
Vector = NDArray[np.float64]    # [n × 1]   — столбец
CoVector = NDArray[np.float64]  # [1 × n]   — строка
Matrix = NDArray[np.float64]    # [m × n]   — матрица весов

# Семантические типы
Sample = NDArray[np.float64]    # [n × 1]       — один пример x⃗
Batch = NDArray[np.float64]     # [n × N]       — N примеров
Dataset = NDArray[np.float64]   # [n × N_total] — весь датасет


# Функции активации и их производные
def sigmoid(z: Batch) -> Batch:
    return 1.0 / (1.0 + np.exp(-z))


def relu(z: Batch) -> Batch:
    return np.maximum(0, z)


def d_relu(z: Batch) -> Batch:
    result: Batch = (z > 0).astype(np.float64)
    return result


class BernoulliNet:
    """
    Нейросеть для бинарной классификации (распределение Бернулли).

    Параметры
    ---------
    input_dim   : размер входного вектора x
    hidden_dims : список размеров скрытых слоёв, напр. [64, 32]
    lr          : шаг градиентного спуска λ
    epochs      : число эпох
    batch_size  : размер мини-батча
    """

    W: list[Matrix]
    b: list[Vector]
    lr: float
    epochs: int
    batch_size: int

    def __init__(self,
                 input_dim: int,
                 hidden_dims: list[int],
                 lr: float = 0.01,
                 epochs: int = 100,
                 batch_size: int = 32,
                 ) -> None:
        dims: list[int] = [input_dim] + hidden_dims + [1]

        # Инициализация весов: W ~ N(0, 2/n_prev)
        self.W, self.b = self._init_weights(dims)
        self.lr, self.epochs, self.batch_size = lr, epochs, batch_size

    def _init_weights(self, dims: list[int]) -> tuple[list[Matrix], list[Vector]]:
        """
        Инициаализирует веса W и смещения b для всех слоев:
        W ~ N(μ=0, σ²=2/n_prev).
        """
        W_list: list[Matrix] = []
        b_list: list[Vector] = []

        for layer in range(len(dims) - 1):
            n_prev = dims[layer]      # число нейронов в предыдущем слое
            n_curr = dims[layer + 1]  # число нейронов в текущем слое

            # Случайная матрица с нормальным распределением N(μ=0, σ²=1=2/n_prev),
            W_layer: Matrix = np.random.normal(
                loc=0.0,
                scale=np.sqrt(2.0 / n_prev),
                size=(n_curr, n_prev),
            ).astype(np.float64)

            W_list.append(W_layer)

            # Вектор смещений — вектор-столбец нулей
            b_layer: Vector = np.zeros((n_curr, 1), dtype=np.float64)
            b_list.append(b_layer)

        return W_list, b_list

    def forward(self, X: Batch) -> tuple[Batch, list[Batch]]:
        """
        Прямой проход по сети: возвращает предсказания p̂ для всех примеров в батче
        и кэш активаций Hᵢ для для обратного прохода (для всех примеров на каждом слое).

        Параметры
        ---------
        X : Batch  матрица астрологических признаков размером [input_dim × N]
        """

        # Прямой проход по нулевому слою:
        H: Batch = X
        H_cache: list[Batch] = [H]

        # Прямой проход по скрытым слоям:
        for W, b in zip(self.W[:-1], self.b[:-1]):
            H = relu(W @ H + b)  # размер [n_current × N]
            H_cache.append(H)

        # Прямой проход по выходному слою:
        p_hat: Batch = sigmoid(
            self.W[-1] @ H + self.b[-1])  # размер [1 × N]

        return p_hat, H_cache

    def loss(self, p_hat: Batch, y: Batch) -> float:
        """
        Бинарная кросс-энтропия (логарифмическое правдоподобие Бернулли)
        """
        return float(
            -np.mean(
                y * np.log(p_hat + 1e-9)
                + (1 - y) * np.log(1 - p_hat + 1e-9)
            )
        )

    def backward(self,
                 p_hat: Batch,
                 y: Batch,
                 H_cache: list[Batch]
                 ) -> tuple[list[Matrix], list[Vector]]:
        """
        Обратный проход по сети: возвращает градиенты dW и db 
        для всех слоев. 

        Параметры
        ----------
        p_hat : Batch  размер [1 × N]  — предсказания для всех примеров в батче
        y     : Batch  размер [1 × N]  — метки событий для всех примеров в батче
        cache : list[Batch]  — сохраненные активации H⁽ˡ⁾ для каждого слоя, размер [n⁽ˡ⁾ × N]
        """
        N: int = y.shape[1]
        dW: list[Matrix] = []
        db: list[Vector] = []

        # Выходной слой:
        delta: Batch = (p_hat - y) / N
        dW.insert(0, delta @ H_cache[-1].T)
        db.insert(0, delta.sum(axis=1, keepdims=True))

        # Скрытые слои:
        z_cache: list[Batch] = [
            self.W[layer] @ H_cache[layer] + self.b[layer]
            for layer in range(len(self.W) - 1)
        ]

        # Обратный проход по скрытым слоям
        for layer in range(len(self.W) - 1, 0, -1):
            delta = (self.W[layer].T @ delta) * d_relu(z_cache[layer - 1])
            dW.insert(0, delta @ H_cache[layer - 1].T)
            db.insert(0, delta.sum(axis=1, keepdims=True))

        return dW, db

    def train(self, X: Dataset, y: Dataset, verbose: bool = True) -> None:
        """
        X : Dataset  shape [input_dim × N_total]  — признаки всех примеров в датасете
        y : Dataset  shape [1 × N_total]  — метки реальных событий {0, 1}
        """
        N_total: int = X.shape[1]
        for epoch in range(1, self.epochs + 1):
            idx = np.random.permutation(N_total)
            X, y = X[:, idx], y[:, idx]
            for s in range(0, N_total, self.batch_size):
                Xb: Batch = X[:, s: s + self.batch_size]
                yb: Batch = y[:, s: s + self.batch_size]
                p_hat, H_cache = self.forward(Xb)
                dW, db = self.backward(p_hat, yb, H_cache)
                self.W = [W - self.lr * dw for W, dw in zip(self.W, dW)]
                self.b = [b - self.lr * db_ for b, db_ in zip(self.b, db)]
            if verbose and epoch % 10 == 0:
                p_hat, _ = self.forward(X)
                print(f"Эпоха {epoch:4d} | потери = {self.loss(p_hat, y):.4f}")

    def predict_probability(self, X: Dataset) -> Dataset:
        """Возвращает p̂ ∈ (0,1) события для каждого столбца батча."""
        p_hat, _ = self.forward(X)
        return p_hat

    def predict_label(self, X: Dataset, threshold: float = 0.5) -> Dataset:
        """Возвращает наблюдаемые бинарные метки события y = {0, 1}."""
        result: Batch = (self.predict_probability(X) >=
                         threshold).astype(np.float64)
        return result


# Пример использования
if __name__ == "__main__":
    np.random.seed(42)
    N = 500

    # Эмулируем астрологические признаки (нормированы в [0, 1])
    X: Dataset = np.random.rand(8, N).astype(np.float64)

    # Эмулируем бинарные метки событий (например, "сбылось" или "не сбылось")
    y: Dataset = (X[0] + X[3] > 1.0).astype(np.float64).reshape(1, N)

    # Инициализация и обучение модели
    model = BernoulliNet(
        input_dim=8,
        hidden_dims=[16, 9],
        lr=0.05,
        epochs=100,
        batch_size=32,
    )
    model.train(X, y)

    # Оценка точности на трейне
    accuracy: float = cast(float, np.mean(model.predict_label(X) == y))
    print(f"\nТочность на трейне: {accuracy:.2%}")

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

Также в реальном сценарии вместо библиотеки NumPy надо использовать ее аналог CuPy для быстрого вычисления на GPU.