Простейшая нейронная сеть на Python для начинающих

в 10:15, , рубрики: python, для чайников, нейросети

Доброго времени суток всем, кто зашел почитать эту статью! Итак, сейчас на дворе 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js