Доброго времени суток всем, кто зашел почитать эту статью! Итак, сейчас на дворе 2025 год и тема нейросетей уже набрала и продолжает набирать большие обороты и имеет очень большой потенциал. Поэтому и количество заинтересованных ими так же увеличивается и увеличивается, и я не стал тому исключением. Так я и подошел к желанию написать свою собственную нейросеть, думаю у многих возникает такое же желание. Без лишних слов перейдем к тому, что я нашел статью (точнее она состоит из 3-х частей) на Хабре по написанию простой нейросети для начинающих (от автора @AndBoh Так вот, в ней очень много полезной и краткой информации о нейросетях, но меня больше интересует именно практическая, вторая часть, на ее основе я и буду писать свой код и эту статью. Почему я решил переписать эту статью? Ну, наверное потому, что она была написана на языке JavaScript, а мне ближе все таки Python, во-вторых, когда разбирался в этой статье и писал свой код по ней, было очень много непонятных моментов, которые мало объяснялись, собственно поэтому я сделаю упор в своей статье на «понятность» и разбор сложных моментов. Сразу оговорюсь, код будет писаться на основе классов как и у предыдущего автора, но на языке Python, я немного в курсе того, что с библиотекой Tensorflow и матрицами код будет работать быстрее, но я ориентируюсь на простоту и понятность, поэтому мой выбор – классы (ссылка на папку с кодом Ядиск). Итак, начнем…
Немного теории
Для того, чтобы создать нейросеть (машинную/искусственную) нам нужно, во-первых, понять из чего она состоит, а во-вторых как она работает. Нейросеть, очевидно, состоит из нейронов, которые располагаются на слоях: входном, скрытых и выходном. На входной слой мы подаем входные данные, в скрытых слоях происходит их обработка, а на выходном слое мы получаем результат (обычно приближенный, то есть с некоторой ошибкой), либо подаем необходимые ожидаемые данные в случае обучения нейросети. У нейронов есть связи и значения, которые будут участвовать в процессе обучения и работы. Связями в данном случае будут «входы», у которых есть так называемые «веса», не буду подробно на них останавливаться, если хотите узнать больше – обратитесь ко статьям моего предшественника, там все хорошо описано. Теперь к практике.
Часть 1. Инициализация
Итак, в этой статье я буду идти от абстрактных и общих понятий к более конкретным и сложным. Значит, для начала нам нужно создать нейросеть – для этого создаем соответствующий класс:
class NeuroNetwork:
def __init__(self):
pass
Далее нам необходимы слои – создаем:
class Layer:
def __init__(self):
pass
Немного поясню:
def init - это дандер-метод который срабатывает при инициализации класса (конструктор), то есть при создании его экземпляра, self – это как раз ссылка на текущий экземпляр.
pass – просто заглушка, чтобы интерпретатор не ругался
То же самое проделываем с нейронами и входами:
class Neuron:
def __init__(self):
pass
class Input:
def __init__(self):
pass
Вот, мы создали классы необходимых нам сущностей, далее нам нужно, чтобы сеть как то построилась, заработала, синициализировалась – для этого создадим экземпляр класса нейросеть:
new_nn = NeuroNetwork()
Вроде просто) Но кажется, что этого мало, не правда ли?) Попробуем связать воедино наши сущности, для этого опять немного обратимся к теории… Теперь нам нужно понять, с какими данными будет работать нейросеть, сколько в ней слоев, сколько нейронов в слоях, сколько входов будет у нейронов, какие у них значения и какие веса будут у входов. В моем примере я буду работать с логической операцией «ИЛИ» или «OR», так же называемой логическим сложением, входными и выходными данными будут нули и единицы. Вот классическая таблица истинности для данной операции:
A |
B |
A V B |
0 |
0 |
0 |
0 |
1 |
1 |
1 |
0 |
1 |
1 |
1 |
1 |
Входных и выходных слоев обычно по одному, скрытых же может быть больше, но в данном случае будет тоже один, не спрашивайте почему, я и сам не знаю, просто одного достаточно для работы, наверное). Теперь о количестве нейронов в каждом слое (обратите внимание на таблицу истинности):
Входной слой – у нас есть два значения A и B, поэтому у нас и будет два входных нейрона
Выходной слой – в результате операции получается одно значение, поэтому и нейрон будет всего один
Скрытый слой – его размерность будет вычисляться на основе размерности входного и выходного слоев, формула будет дальше, сейчас же просто скажу, что в нем будет три нейрона
Едем дальше… Входы
Очевидно, у входных нейронов входов (масло масляное) нам не требуется, у остальных же входы будут создаваться к каждому нейрону из предыдущего слоя, то есть у скрытых нейронов будет по два входа, а у выходного – три. Нейроны пока останутся без значений, а веса входов будут назначаться рандомно, пока нам будет этого достаточно. Как примерно это выглядит:

Теперь переходим к реализации
Для начала добавим параметров к конструктору класса нашей нейросети, а именно размерности входного и выходного слоев и количество скрытых слоев (по умолчанию он будет 1) и пропишем эти параметры в строку создания сети:
class NeuroNetwork:
def __init__(self, input_l_size, output_l_size, hidden_layers_count=1):
pass
new_nn = NeuroNetwork(2, 1)
Далее займемся слоями:
Во-первых, нам понадобится список, в который будут входить все наши слои
Во-вторых, нужна какая-то процедура заполнения этого списка с одновременной инициализацией каждого слоя
Итак, наполняем наш конструктор класса сеть:
class NeuroNetwork:
def __init__(self, input_l_size, output_l_size, hidden_layers_count=1):
self.l_count = hidden_layers_count + 2 # 2 = 1 input + 1 output
hidden_l_size = min(input_l_size * 2 - 1, ceil(
input_l_size * 2 / 3 + output_l_size)) # формула расчета размера скрытых слоев,
# на основе размера входного и выходного слоев
# (math.ceil() - округление числа с точкой до большего целого)
self.layers = [self.add_layer(i, input_l_size, output_l_size, hidden_l_size)
for i in range(self.l_count)] # range of i = [0..l_count)
l_count – вычисляем общее количество слоев
hidden_l_size – вычисляем размерность скрытого слоя, в формулу я особо не вникал, просто переделал ее на python со статьи предшественника (если что есть конвертеры кода с разных языков, только результаты не всегда корректные, но в целом помогает сэкономить время). Для того чтобы метод ceil работал, необходимо импортировать его, добавив в начало кода:
from math import ceil
layers – это как раз наш список со слоями, здесь я заполняю его с помощью генератора списков, это тоже самое, что и:
for i in range(self.l_count):
self.layers[i] = self.add_layer(i, input_l_size, output_l_size, hidden_l_size)
И здесь у нас рождается add_layer() функция (или метод, кому как нравится) c параметрами:
i – индекс слоя (начинается с 0, если кто не знал)
input_l_size – размер входного слоя
output_l_size – размер выходного слоя
hidden_l_size – размер скрытых слоев
Что же она делает? Она инициализирует и возвращает один слой с заданными параметрами, при этом она определяет по индексу тип слоя и вносит нужные поправки
def add_layer(self, i, in_size, out_size, hl_size): # self - текущий экземпляр NeuroNetwork
count = i + 1 # range of i = [0..l_count)
if 1 < count < self.l_count: # hidden
self.selected_layer = Layer(hl_size, self.selected_layer, self)
# создаем новый слой на основе слоя с указателем,
# и ставим указатель на созданный слой
return self.selected_layer
if count == 1: # input
self.selected_layer = Layer(in_size, None, self) # ставим указатель на первый слой
return self.selected_layer
# else: count == l_count -> output
self.selected_layer = Layer(out_size, self.selected_layer, self)
return self.selected_layer
count – это тот же индекс, только начинается с единицы, просто для наглядности, и в зависимости от его значения мы можем попасть в три варианта действий, распознавая тип слоя
Напомню, если в теле условия есть return, то писать else не имеет смысла, так как происходит выход из функции
Здесь у меня что-то не получалось с генератором списка, и я решил добавить указатель слоя - еще один атрибут selected_layer, он так же удобен для инициализации слоев. Добавим его в конструктор класса сети:
def __init__(self, input_l_size, output_l_size, hidden_layers_count=1):
self.selected_layer = None # указатель на слой
self.l_count = hidden_layers_count + 2 # 2 = 1 input + 1 output
hidden_l_size = min(input_l_size * 2 - 1, ceil(
input_l_size * 2 / 3 + output_l_size)) # формула расчета размера скрытых слоев,
# на основе размера входного и выходного слоев
# (math.ceil() - округление числа с точкой до большего целого)
self.layers = [self.add_layer(i, input_l_size, output_l_size, hidden_l_size) for i in
range(self.l_count)] # range of i = [0..l_count)
self.selected_layer = None # "чистим" указатель
Теперь перейдем к инициализации слоев
Как видно из примера с функцией add_layer() при создании слоя мы указываем три параметра:
Первый – размер слоя
Второй – ссылка на предыдущий слой
И третий – ссылка на родительскую сеть
Реализуем это в конструкторе класса слоя:
class Layer:
def __init__(self, layer_size, prev_layer, parent_network):
pass
Что же должно происходить при инициализации слоя? Очевидно, необходимо наполнить его нейронами и инициализировать их.
def __init__(self, layer_size, prev_layer, parent_network):
self.prev_layer = prev_layer
self._network = parent_network
self.neurons = [Neuron(self, prev_layer) for _ in range(layer_size)]
_network - подчеркивание перед переменной означает, что переменная защищена или protected (подробнее можете поискать в теме инкапсуляция ООП), я списал этот момент у предшественника, но думаю что можно было обойтись и без этого. Также скажу что в python инкапсуляция работает довольно формально.
neurons – это наш список нейронов который заполняется при помощи ранее упомянутого генератора списков
Как мы видим, для инициализации нейрона необходимы два параметра – ссылка на текущий слой и ссылка на предыдущий слой. Добавляем в конструктор класса нейрона:
class Neuron:
def __init__(self, layer: Layer, previous_layer: Layer):
pass
Далее заполняем конструктор класса:
def __init__(self, layer: Layer, previous_layer: Layer):
self._layer = layer
self.inputs = [Input(prev_neuron, randint(0, 10) / 10) for prev_neuron in
previous_layer.neurons] if previous_layer else [] # Генератор списка + однострочное условие
# random.randint(0, 10) / 10 случайное число от 0.0 до 1.0
Здесь нас интересует список inputs – здесь его заполнение происходит немного сложнее, чем в слоях, помимо генератора списков здесь еще используется однострочное условие и рандомайзер randint. Покажу более понятный пример, который будет делать то же самое:
if previous_layer: # если previous_layer пустой, то False, иначе True
self.inputs = [Input(prev_neuron, randint(0, 10) / 10) for prev_neuron in previous_layer.neurons]
else:
self.inputs = []
Итак, если ссылка на предыдущий слой не пустая, то список заполняется «входами» со ссылкой на предыдущий нейрон и случайным весом от 0.0 до 1.0, иначе если ссылка пуста, то есть текущий слой входной, список входов также будет пустым. Для работы метода randint необходимо добавить в начало кода:
from random import randint
Осталось только изменить конструктор класса входа:
class Input:
def __init__(self, prev_neuron: Neuron, weight):
self.prev_neuron = prev_neuron
self.weight = weight
Готово, теперь наша сеть способна инициализироваться, далее будем работать над обучением и тестированием, а пока вот вам полный код инициализации нейросети + «бонусная способность» вывод инфы о ней в консоль/
Часть 2. Обучение и тестирование
Что ж, теперь нам нужно, чтобы наша сеть смогла обучаться. Для этого добавим в класс NeuroNetwork функцию train() и сразу сделаем ее вызов после инициализации сети:
def train(self, dataset, iters=1000):
print(f'nTRAINING STARTED({iters} iterations)...')
for in range(iters):
self.trainonce(dataset)
print(f'nTRAINING COMPLETED!n')
new_nn = NeuroNetwork(2, 1)
dataset_or = [[[0, 0], 0], [[0, 1], 1], [[1, 0], 1], [[1, 1], 1]]
new_nn.train(dataset_or, 100000)
Наша функция запускается с двумя параметрами – dataset и iters (по умолчанию у которого стоит 1000). Первый параметр – это список обучающих данных, второй – количество итераций, которое влияет на количество запусков новой функции train_once(), т.е. одной полной итерации обучения. Список обучающих данных представляет собой список 4-х возможных случаев (смотри таблицу истинности) для логической операции «ИЛИ». В каждом «случае» первые два значения подаются на вход нейросети, а третье взаимодействует с выходом сети.
Принты здесь нужны просто для наглядности, чем больше будет итераций, тем заметнее будет задержка между стартом и «комплитом». Тут можно было поиграться с timestamp, но мне было лень :-) Если хотите – добавьте :-)
Теперь нам необходимо добавить функцию train_once() для класса сети:
def train_once(self, dataset):
for case in dataset:
datacase = {'in_data': case[0], 'res': case[1]}
# Пример: datacase = {'in_data': [0, 0], 'res': 0}
self.set_input_data(datacase['in_data'])
curr_res = self.get_prediction()
for i in range(len(curr_res)):
self.layers[self.l_count - 1].neurons[i].set_error(
curr_res[i] - datacase['res']) # self.layers[self.l_count - 1] = out layer
Итак, что же тут происходит? Мы берем наш список dataset, который мы получили от функции train() и начинаем цикл для каждого «случая» из этого списка.
В одной итерации цикла у нас происходит:
«Случай» мы переводим из списка в словарь, просто для наглядности, этого можно было не делать
Дальше для сети вызывается метод set_input_data(), в который мы подаем входные данные из нашего «случая». Т. е. этот метод должен будет устанавливать на вход сети эти данные.
curr_res = self.get_prediction() – здесь мы вызываем для нейросети метод get_prediction() и записываем в переменную curr_res (текущий результат). Метод get_prediction() по сути, выдает нам какой-то результат на основе имеющихся значений предыдущих нейронов и весов их входов, выдает он его в виде списка значений выходных нейронов, в нашем случае одного нейрона.
Далее запускается цикл для каждого значения из списка curr_res. В одной итерации этого цикла сначала мы обращаемся к списку слоев сети (self.layers) по индексу «self.l_count - 1», то есть к выходному слою, в нем мы обращаемся к списку нейронов neurons по индексу i значения нейрона из списка curr_res, и у нейрона под этим индексом вызываем метод set_error(), в который подается разность значения из списка curr_res и ожидаемого результата из словаря «случая», то есть подается некоторая погрешность результата.
Теперь по порядку рассмотрим каждый новый метод, который у нас здесь появился:
set_input_data() – метод класса нейросеть, который устанавливает на вход переданные в метод данные, в данном случае список из двух значений. Добавим этот метод в класс сети:
def set_input_data(self, val_list):
self.layers[0].set_input_data(val_list)
Здесь мы обращаемся к списку слоев по индексу 0, т. е. ко входному слою и вызываем у него одноименный метод и передаем те же данные, что и получили
Теперь необходимо добавить такой же метод в класс слоя:
def set_input_data(self, val_list):
for i in range(len(val_list)):
self.neurons[i].set_value(val_list[i])
здесь мы проходим по переданному в метод списку значений val_list и в каждой итерации обращаемся к списку нейронов слоя по тому же индексу что и у val_list и вызываем у выбранного нейрона метод set_value(), в который подаем текущее значение из списка val_list. То есть set_value() должен будет присваивать переданное в него значение нейрону, у которого этот метод вызывался. Добавляем этот метод в класс нейрона:
def set_value(self, val):
self.value = val
тут все просто, только появляется новый атрибут value для класса нейрон, добавляем его в конструктор класса:
class Neuron:
def __init__(self, layer: Layer, previous_layer: Layer):
self.value = 0
self._layer = layer
self.inputs = [Input(prev_neuron, randint(0, 10) / 10) for prev_neuron in
previous_layer.neurons] if previous_layer else []
Все, с установкой значений на вход нейросети мы закончили, теперь вернемся к методу сети train_once(). Следующий по списку на добавление у нас метод get_prediction(), добавляем его в класс сети:
def get_prediction(self):
layers = self.layers
output_layer = layers[len(layers) - 1]
out_data = [neuron.get_value() for neuron in output_layer.neurons]
return out_data
Здесь, по идее, все можно было запихнуть в одну строку, но я расписал, чтобы было нагляднее. В layers мы передаем список слоев нашей сети, затем в output_layer записываем последний по индексу слой, т.е. выходной. Далее мы формируем список из значений, полученных от вызова метода нейрона get_value() для каждого нейрона из списка нейронов выходного слоя. Затем мы возвращаем сформированный список на выход метода get_prediction().
Что ж, добавим для класса нейрон метод get_value():
def get_value(self):
network = self._layer.network
if not self.is_no_inputs():
self.set_value(network.activate_func(self.get_input_sum()))
return self.value
Здесь нам придется у класса слоя переделать атрибут _network в просто network, т.е. убрать «защиту», иначе интерпретатор будет ругаться.
Итак, мы обращаемся к слою нейрона и присваиваем ссылку на сеть переменной network. Далее, если у нейрона метод is_no_inputs() не возвращает «Истину/True» , вызывается метод нейрона set_value() и в него передается некоторое значение, полученное с помощью функции сети activate_func(), в которую передали результат работы метода нейрона get_input_sum(). И затем возвращается значение нейрона на выход метода get_value(). Вызов этого метода я так же прописал в конце конструктора нейрона, чтобы при создании нейрона, ему присваивалось вычисленное значение:
self.get_value()
Опишу проще логику работы: если нейрон, у которого был вызван данный метод, входной (т.е. у него нет входов), то мы просто возвращаем значение нейрона без изменений, иначе если нейрон не входной то мы устанавливаем новое значение данному нейрону вычисляя его с помощью какой-то активационной функции, в которую передали какую-то сумму входов, и опять же возвращаем значение этого нейрона, но уже новое.
Для начала добавим метод is_no_inputs() для нейрона:
def is_no_inputs(self):
return not self.inputs
тут все просто – если список входов нейрона пустой, то метод возвращает True, если чем-то наполнен – False
Далее нам необходимо добавить активационную функцию для сети, для этого в конструктор класса сети def init () добавляем следующую строку:
self.activate_func = NeuroNetwork.sigmoid
А так же добавляем метод sigmoid() для класса нейросеть:
@staticmethod
def sigmoid(x):
return 1 / (1 + exp(
-x)) # exp(x) – возвращает экспоненциальное значение x: e^x.
# Аргумент x может быть целого или вещественного типа
Это так называемая сигмоидальная функция, необходимая для вычислений сети. В сами вычисления я не вникал, мне было достаточно просто переписать ее с кода предшественника, если хотите подробностей вычислений, тогда обратитесь к теории, у него вроде бы что-то было об этой функции. @staticmethod – означает что метод статичный и его можно вызывать с помощью имени класса, не создавая при этом его экземпляр. Да, и обязательно добавьте следующую строку для работы метода exp():
from math import ceil, exp
Теперь для класса нейрон нам надо добавить метод get_input_sum():
def get_input_sum(self):
total_sum = sum(curr_input.prev_neuron.get_value() * curr_input.weight for curr_input in self.inputs)
return total_sum
Здесь мы обращаемся к списку входов нейрона и для каждого из них умножаем его вес на полученное с помощью get_value() значение предыдущего нейрона, затем все это складываем и возвращаем на выход метода.
Что ж, с get_value() и get_prediction() мы разобрались, теперь снова возвращаемся к методу train_once(). Нам осталось только разобраться с методом set_error(), добавим его в класс нейрона:
def set_error(self, val):
if self.is_no_inputs():
return
w_delta = val * self._layer.network.derivate_func(self.get_input_sum())
for curr_input in self.inputs:
curr_input.weight -= curr_input.prev_neuron.get_value() * w_delta * self._layer.network.learning_rate
curr_input.prev_neuron.set_error(curr_input.weight * w_delta)
Итак, что тут происходит в общих словах: для каждого из входов данного нейрона происходит пересчет веса этого входа, используя пересчет значения текущего нейрона, вычисленной разности весов и какого-то коэффициента обучения, а так же для предыдущего нейрона этого входа вызывается снова метод set_error(), и происходит это до тех пор, пока мы не упремся во входные нейроны. Разность весов вычисляется на основе переданного в метод значения val, производной от сигмоидальной функции, в которую подается результат работы get_input_sum() текущего нейрона.
Начнем с добавления производной функции, по аналогии с активационной, для этого в конструктор класса сети def init () добавляем следующую строку:
self.derivate_func = NeuroNetwork.sigmoid_derivative
и метод sigmoid_derivative():
@staticmethod
def sigmoid_derivative(x):
return NeuroNetwork.sigmoid(x) * (1 - NeuroNetwork.sigmoid(x))
в вычисления я опять же не буду углубляться, если хотите – смотрите теорию. Осталось только добавить коэффициент обучения нейросети learning_rate, для этого немного меняем «шапку» в конструкторе сети:
def __init__(self, input_l_size, output_l_size, hidden_layers_count=1, learning_rate=0.5):
self.activate_func = NeuroNetwork.sigmoid
self.derivate_func = NeuroNetwork.sigmoid_derivative
self.learning_rate = learning_rate
то есть, по умолчанию у нас коэффициент обучения будет 0.5
Вот и все! Теперь наша нейросеть способна обучаться!
Осталось научить ее выдавать результат и протестировать ее. Начнем:
new_nn = NeuroNetwork(2, 1)
dataset_or = [[[0, 0], 0], [[0, 1], 1], [[1, 0], 1], [[1, 1], 1]]
new_nn.train(dataset_or, 100000)
test_data = [[0, 0], [0, 1], [1, 0], [1, 1]]
new_nn.test(test_data, 'OR')
Здесь по аналогии с обучением задается список данных test_data, но только уже без ожидаемых результатов, их нам должна будет выдать сама сеть. Ну и как видим, нам необходимо добавить метод test() для нашей сети:
def test(self, data, op_name):
print('nTESTING DATA:')
for case in data:
self.set_input_data(case)
res = self.get_prediction()
print(f'{case[0]} {op_name} {case[1]} ~ {res[0]}')
Тут мы опять же для каждого «случая» из списка передаем данные на вход нейросети, и вызываем метод вычисления результата на выходе. Параметр op_name – это название нашей логической операции в виде строки. Полученные данные мы красиво выводим в консоль с помощью f-строки. Смотрим результат:

Неплохой результат, погрешность меньше одной сотой единицы. Вот и все! Можно поздравить себя с написанием первой маленькой нейросети.
В заключение, хочу поблагодарить автора @AndBohза его проделанный труд и предоставленный материал и «вдохновение» написать эту статью и всех кто будет (надеюсь) ее читать.
Автор: StenGO