Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение16 мин
Количество просмотров28K

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

Теги:
Хабы:
Всего голосов 18: ↑17 и ↓1+18
Комментарии4

Публикации

Работа

Data Scientist
49 вакансий

Ближайшие события