Некоторое время назад у меня впервые зародилось желание написать свою нейросеть и поэкспериментировать с ней, с тех пор я собирал попадающуюся мне информацию, но до дела у меня дошли руки только сейчас. Я твердо решил написать свою нейросеть с блекджеком, с разными методами обучения и без сторонних библиотек. Собственно это я и сделал, а так как у меня самого опыта в этом еще не было, я подумал, что это может быть полезно и для других людей, которые хотят в этом разобраться. Хочу сказать, что смысл этой статьи не в самом оптимизированном способе создания нейросетей, а в способе понять, что такое нейросети и наконец перейти к практике. Итак, поехали.

Немного необходимой теории.

Вероятно вы уже множество раз прочитали что‑нибудь подобное, так что постараюсь покороче. Говоря простым языком: нейронная сеть — несколько слоев, состоящих из искусственных нейронов и синапсов, которые их соединяют. Значение нейрона формируется из активированной суммы дочерних нейронов, умноженных на вес их синапсов. Первый (следующий после нулевого) слой формируется из активированных входных данных, тоже умноженных на веса синапсов. Обычно веса синапсов изначально генерируются случайно, а потом корректируются в зависимости от процесса обучения. «Активированное значение» — значение, которое преобразовано с помощью выбранной функции активации.

Почти переходим к практике

Дело в том, что когда я «твердо решил написать свою нейросеть», я совершенно не подумал о том, какую задачу эта нейросеть будет решать, так что это я решил на ходу:

Задумавшись над задачей для нейронной сети, я решил выбрать что‑нибудь подходящее под два критерия: наглядное, чтобы на выходом было какое‑то графическое действие и не очень тяжелое, ибо мой текущий компьютер не справится. После длительного отбора идей, я вспомнил статью про эксперименты над обучением одноклеточных организмов и пришел к выводу, что правильным решением будет создать примитивную нейросеть, которая будет выполнять роль клетки в чашке Петри. Предварительный анализ задачи показал, что логичней будет ограничить поле зрения: я выбрал поле 5 на 5 вокруг клетки. В итоге я решил сделать нейронную сеть, имеющую входной слой в 25 нейрона, скрытый в 16 и выходной слой в 14. Почему именно столько? В конструировании нейросетей нет четких правил, но для нашей задачи больше одного слоя не требуется (вообще эта задача может решаться без скрытых слоев вообще, но тогда у нейросети будут очень примитивные решения) количество нейронов в скрытом слою, принято делать между количеством во входном и выходном, а дальше корректировать, в зависимости от эмпирических данных, так что спустя несколько попыток, я выбрал именно 16. Ещё нужна функция активации, чтобы значение нейрона для удобства варьировалось между -1 и 1. Я выбираю стандартный гиперболический тангенс, который на самом деле является модифицированной экспонентой.

Пишем код

Писать я буду на python, хотя принцип остается тем же и для других языков. Обычно для нейронных сетей используют NumPy с его многомерными массивами, но мне показалось, что для первой нейросети это слишком не наглядно, так что, вдохновившись идеей о создании нейросети методами ООП, я решил реализовать ее через классы. Что я имею ввиду? Я создам класс нейросети, а потом уже буду с этим работать. Нейросеть должна содежать форму(кол‑во слоев и нейронов в них) и массив синапсов.

import math
import random

activation = math.tanh

class NeuralNetwork:
    def __init__(self, neurons_size:list, weights:list=None):
        self.neuron_size = neurons_size
        for i in range(len(neurons_size)-1):#добавляем нейрон смещения в форму нейросети
            self.neuron_size[i] += 1

        if weights is None:#если веса не заданы
            self.weights = []
            #создаем случайные веса
            for i in range(len(neurons_size)-1):
                self.weights.append([[random.uniform(-1, 1) for x in range(neurons_size[i+1])] for y in range(neurons_size[i])])
        else:
            self.weights = weights
Мне понравилась эта картинка для объяснения, что такое нейрон смещения.
Мне понравилась эта картинка для объяснения, что такое нейрон смещения.

Добавляем функцию вывода. Вывод каждого нейрона считается по формуле - activation(∑neuron * weight)(активированная сумма всех нейронов предыдущего слоя умноженных на соответствующие веса):

    def out(self, inp):
        out=[inp + [1]]# + [1] - это добавление нейрона смещения
        for i in range(1, len(self.weights)+1):
            a = []
            for j in range(self.neuron_size[i]):
                s = sum([out[i-1][k] * self.weights[i-1][k][j] for k in range(self.neuron_size[i-1])])
                a.append(activation(s))
            if i != len(self.weights):
                a += [1]
            out.append(a)
        return out

Функция принимает параметр inp – массив входных значений. Функция возвращает массив значений нейронов.

Самая важная функция - обучение обратным распространением ошибки:

    def correct(self, inp, answer, learning_rate=0.1):
        out = self.out(inp)
        errors = [[answer[i] - out[-1][i] for i in range(len(out[-1]))]]#считаем ошибку
  
        #считаем ошибку каждого нейрона
        for i in range(len(self.weights) - 1, 0, -1):
            a = []
            for j in range(self.neuron_size[i]):
                s = sum([errors[0][k] * self.weights[i][j][k] for k in range(self.neuron_size[i + 1])])
                a.append((1 - out[i][j] ** 2) * s)#корректируем ошибку с производной функции активации(если у вас не tanh - измените)
            errors.insert(0, a)

        #обновляем веса
        for i in range(len(self.weights)):
            for j in range(self.neuron_size[i]):
                for k in range(self.neuron_size[i + 1]):
                    self.weights[i][j][k] += learning_rate * errors[i][k] * out[i][j]

        error_count = sum([sum(abs(en) for en in el) for el in errors])
        return out, error_count
Полный код нейросети (добавил несколько функций для удобства)
import math
import random
import time
import datetime
import json
from copy import copy

activation = math.tanh
deactivation = math.tanh

class NeuralNetwork:
    def __init__(self, neurons_size:list, weights:list=None):
        self.neuron_size = neurons_size
        for i in range(len(neurons_size)-1):
            self.neuron_size[i] += 1

        if weights is None:
            self.weights = []
            for i in range(len(neurons_size)-1):
                self.weights.append([[random.uniform(-1, 1) for x in range(neurons_size[i+1])] for y in range(neurons_size[i])])
        else:
            self.weights = weights
        
    def out(self, inp):
        out=[inp + [1]]
        for i in range(1, len(self.weights)+1):
            a = []
            for j in range(self.neuron_size[i]):
                s = sum([out[i-1][k] * self.weights[i-1][k][j] for k in range(self.neuron_size[i-1])])
                a.append(activation(s))
            if i != len(self.weights):
                a += [1]
            out.append(a)
        return out
            
    def correct(self, inp, answer, learning_rate=0.1):
        out = self.out(inp)
        errors = [[answer[i] - out[-1][i] for i in range(len(out[-1]))]]

        for i in range(len(self.weights) - 1, 0, -1):
            a = []
            for j in range(self.neuron_size[i]):
                s = sum([errors[0][k] * self.weights[i][j][k] for k in range(self.neuron_size[i + 1])])
                a.append((1 - out[i][j] ** 2) * s)
            errors.insert(0, a)

        for i in range(len(self.weights)):
            for j in range(self.neuron_size[i]):
                for k in range(self.neuron_size[i + 1]):
                    self.weights[i][j][k] += learning_rate * errors[i][k] * out[i][j]

        error_count = sum([sum(abs(en) for en in el) for el in errors])
        return out, error_count

    def save(self, name):
        with open(name, 'w') as f:
            neuron_size = copy(self.neuron_size)
            for i in range(len(neuron_size) - 1):
                neuron_size[i] -= 1
            f.write(json.dumps({'shape': neuron_size, 'weights': self.weights}))

    def open(name):
        with open(name, 'r') as f:
            data = json.loads(f.read())
            return NeuralNetwork(data['shape'], data['weights'])

    def show(self, name):
        import matplotlib.pyplot as plt
        import networkx as nx
        from networkx.drawing.nx_agraph import graphviz_layout

        G = nx.DiGraph()

        for layer in range(len(self.neuron_size)):
            for neuron in range(self.neuron_size[layer]):
                G.add_node((layer, neuron))

        for layer in range(len(self.neuron_size) - 1):
            for from_neuron in range(self.neuron_size[layer]):
                for to_neuron in range(self.neuron_size[layer + 1]):
                    weight = self.weights[layer][from_neuron][to_neuron]
                    G.add_edge((layer, from_neuron), (layer + 1, to_neuron), weight=weight)

        pos = graphviz_layout(G, prog='dot', args="-Grankdir=LR")

        edge_widths = [2 + abs(G.edges[edge]['weight']) for edge in G.edges]
        edge_alpha = [abs(activation(G.edges[edge]['weight']))/2 for edge in G.edges]
        edge_colors = ['green' if G.edges[edge]['weight'] >= 0 else 'red' for edge in G.edges]

        nx.draw_networkx_nodes(G, pos, node_size=300, node_color='skyblue', alpha=0.8)

        nx.draw_networkx_edges(G, pos, width=edge_widths, alpha=edge_alpha, edge_color=edge_colors, arrows=True)

        layer_labels = {}
        for layer in range(len(self.neuron_size)):
            for neuron in range(self.neuron_size[layer]):
                    layer_labels[(layer, neuron)] = f"{neuron}"
        nx.draw_networkx_labels(G, pos, labels=layer_labels, font_size=12, font_color='r')

        plt.axis('off')
        plt.title(f"Neural Network Weights")
        plt.savefig(f'{name}.png')

На этом сама нейросеть закончена, пора приступать к разработке среды обучения. Подробное описание процесса разработки среды не имеет ценности для темы, так что я просто опишу принцип работы:

Изначально создается массив, который является картой среды. Массив изначально состоит из 0.1, а потом каждый ход наполняется 1 и -1 случайным образом. Также создается клетка, которая управляется нейросетью, которой на вход подается массив из значений полей в квадрате 5*5, а на выходе число от 0 до 3, обозначающие ход (0 — шаг вверх, 1 — вниз, 2 — вправо, 3 — влево). Проверяется по одной клетке вокруг клетки и если находится 1 — то по этому направлению применяется положительное подкрепление, а если -1 — то отрицательное. Также я добавил к этому графический интерфейс на tkinter.

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

Код среды
from neuro import NeuralNetwork
from tkinter import *
import matplotlib.pyplot as plt
import matplotlib.animation as animation
import random
import asyncio

mind = NeuralNetwork([25, 4])
canvas_size = 1280
realsize = 32

pix = canvas_size / realsize

canvas = [[0.1 for x in range(realsize)] for y in range(realsize)]
cellx, celly = 15, 5


def cellvision(vis):
	global cellx
	global celly
	global canvas
	inp = []

	if vis != -1:
		for j in range(vis):
			for i in range(vis):
				inp.append(canvas[int(cellx - vis // 2 + i) % realsize][int(celly - vis // 2 + j) % realsize])
		return inp

	inp.append(canvas[int(cellx + 0) % realsize][int(celly - 1) % realsize])
	inp.append(canvas[int(cellx + -1) % realsize][int(celly + 0) % realsize])
	inp.append(canvas[int(cellx + 1) % realsize][int(celly + 0) % realsize])
	inp.append(canvas[int(cellx + 0) % realsize][int(celly + 1) % realsize])

	return inp


def move(out):
	global cellx
	global celly
	if out == 0:
		celly -= 1
	if out == 1:
		cellx -= 1
	if out == 2:
		cellx += 1
	if out == 3:
		celly += 1
	if cellx == realsize:
		cellx = 0
	if cellx == -1:
		cellx = realsize - 1
	if celly == realsize:
		celly = 0
	if celly == -1:
		celly = realsize - 1
	cell(cellx, celly)
	return


def goodpoint(x, y):
	color = "#476042"
	x, y = x * pix, y * pix
	x1, y1 = (x - pix / 2), (y - pix / 2)
	x2, y2 = (x + pix / 2), (y + pix / 2)
	w.create_oval(x1, y1, x2, y2, outline=color, fill=color)


def badpoint(x, y):
	color = "#ff0000"
	x, y = x * pix, y * pix
	x1, y1 = (x - pix / 2), (y - pix / 2)
	x2, y2 = (x + pix / 2), (y + pix / 2)
	w.create_oval(x1, y1, x2, y2, outline=color, fill=color)


def cell(x, y):
	color = "#ffffff"
	x, y = x * pix, y * pix
	x1, y1 = (x - pix / 2), (y - pix / 2)
	x2, y2 = (x + pix / 2), (y + pix / 2)
	w.create_oval(x1, y1, x2, y2, outline=color, fill=color)


def canvas_print():
	global canvas
	w.delete("all")
	ans = ''
	for y in range(realsize):
		for x in range(realsize):
			ans += str(canvas[x][y]) + " "
			if canvas[x][y] == 1:
				goodpoint(x, y)
			if canvas[x][y] == -1:
				badpoint(x, y)
			if canvas[x][y] == 0:
				cell(x, y)
		ans += "\n"


def usergoodpoint(event):
	x, y = int(event.x / pix), int(event.y / pix)
	canvas[x][y] = 1


def userbadpoint(event):
	x, y = int(event.x / pix), int(event.y / pix)
	canvas[x][y] = -1


master = Tk()
master.title("Среда обучения")
w = Canvas(master, bg="black",
		   width=canvas_size,
		   height=canvas_size)
w.pack(expand=YES, fill=BOTH)
w.bind("<B1-Motion>", usergoodpoint)
w.bind("<B3-Motion>", userbadpoint)

iterat = -1
allg = 0
graphic = []
while True:
	iterat += 1
	if iterat % 200 == 0:
		plt.plot(graphic)
		plt.pause(0.0000001)
	good = 0
	if iterat % 10000 == 0:
		plt.close()
		mind.show()

	canvas[random.randint(0, realsize - 1)][random.randint(0, realsize - 1)] = 1
	canvas[random.randint(0, realsize - 1)][random.randint(0, realsize - 1)] = -1

	canvas_print()

	visn = cellvision(5)
	visnn = cellvision(-1)

	out = mind.out(visn)
	move_ = out[-1].index(max(out[-1]))
	mind.correct(visn, visnn, 0.1)

	answer = [0]*4
	answer[move_] = 1
	move(move_)

	if canvas[cellx][celly] == 1:
		good += 50
		canvas[cellx][celly] = 0.1

	elif canvas[cellx][celly] == -1:
		good -= 50
		canvas[cellx][celly] = 0.1

	# print(input())
	allg += good
	graphic.append(allg)

	master.title("Среда обучения: " + " i:" + str(iterat) + " good:" + str(good))
	master.update()

plt.show()
master.mainloop()

Первый запуск
Первый запуск
График обучения
График обучения
Веса до обучения
Веса до обучения
Веса после обучения ( 7, 11, 13, 17 - входные данные клеток вокруг нейросети)
Веса после обучения ( 7, 11, 13, 17 - входные данные клеток вокруг нейросети)

Гитхаб репозиторий: ссылка

Заключение

Нейронки это круто! Сохраняйте в закладки и поставьте апвоут, пожалуйста, это очень мотивирует.

Клиффхэнгер

Следующая часть про эволюционный алгоритм уже доступна!

спойлеры!
спойлеры!

Комментарии (13)


  1. egaoharu_kensei
    17.08.2023 09:20
    +9

    Респект за реализацию с нуля - это как раз то чего не хватает. Практически во всех туториалах привыкли юзать pytorch или tensorflow без объяснения как нейросети устроены изнутри и это печально.

    Надо побольше подобного рода статей.


    1. da-nie
      17.08.2023 09:20
      +2

      Вот тут посмотрите: https://programforyou.ru/poleznoe/convolutional-network-from-scratch-part-zero-introduction


      Но только это на CPU. Чтобы на GPU перенести вам понадобится заиметь оптимизированное (блочное с кэшированием) умножение тензоров, потому как обычное распараллеленное умножение "в лоб" работает на порядок медленнее этого самого оптимизированного. А потом научиться делать свёртки с помощью этого самого умножения.
      Вот так: https://da-nie.livejournal.com/6864.html
      Поскольку до выкладывания на github моя сеть ещё не доросла, то вот вам она в архиве.


      Вот проект на С++


      Там в папке cuda/netmodel как раз три сети — сортировщик, основа GAN и сама GAN. Увы, шаги свёртки и обратной свёртки допустимы в слоях только 1 (сама же функция для свёртки работает с любым шагом). Причина в обратной свёртке — там шаг трактуется в пространстве выходного тензора и вот с этим у меня пока проблема. А так как в обучении они парно связаны (туда<->обратно), то отсюда и следует запрет на шаг, отличный от 1.


      1. egaoharu_kensei
        17.08.2023 09:20
        +2

        Спасибо за полезные ссылки. Честно говоря, я сам хочу сделать большую (лонгрид) статью/курс на хабре по реализации ml-алгоритмов с нуля т.к. те, что есть в интернете, либо неправильные либо неполные.

        Хорошо, что есть люди вроде вас, которые бесплатно помогают другим.


      1. pzrnqt1vrss Автор
        17.08.2023 09:20
        +2

        Да, спасибо, после завершения цикла статей про эту реализацию(работающую только на одном ядре процессора), я собираюсь сделать несколько статей про переход к более оптимизированным вариантам


        1. egaoharu_kensei
          17.08.2023 09:20
          +1

          оо, крутяк. Я делал похожим образом с нуля random forest с помощью joblib (библиотека для многопроцессорности и многопоточности) для ускорения вычислений.


    1. TimID
      17.08.2023 09:20
      +2

      Эх, коллега. Всё наоборот. Не должно быть никакого респекта за "реализацию с нуля до 0,1". Количество статей дилетантского уровня "давайте сделаем первый шажок в программирование - включим компьютер вместе, а дальше и я сам не знаю" настолько зашкаливает в Сети, что поощрять такое просто недопустимо.
      Вот если бы человек разобрался, к примеру, как свёрточные ядра обучаются и с нуля написал код с примерами, а затем описал бы всё в подробном туториале - тогда была бы "респект и уважуха".
      Да и ничего на самом-то деле в статье не раскрывается...
      P.S. Надеюсь автор её понимает, что в данном случае - "ничего личного, просто трезвая оценка положения в инфорпространстве и представленного материала".


      1. pzrnqt1vrss Автор
        17.08.2023 09:20

        Спасибо за комментарий, это цикл статей и до сверточных нейросетей, тензорфлоу и оптимизации мы еще дойдем


  1. SkazhuNet
    17.08.2023 09:20

    Крутой материал , спасибо !


  1. barsik_unlimited
    17.08.2023 09:20
    +1

    Многие скажут, что бесполезно вникать в нюансы, когда есть готовые решения, но когда-то сам пытался реализовать на плюсах простейшую нейросеть без использования готовых библиотек, и это огромное удовольствие, когда твой флаппи бёрд после десятка глупых прыжков начинает набирать по полторы тысячи очков за игровую сессию. А подобные статьи как раз помогают "разжевать" тему, вместо того, чтоб объяснить какие две строчки прописать, чтоб "создать" свою нейросеть.


  1. ligor
    17.08.2023 09:20

    Упс AttributeError: 'NeuralNetwork' object has no attribute 'plot_weights_graph'


    1. pzrnqt1vrss Автор
      17.08.2023 09:20

      Опечатка после рефакторинга, это в какой строке?

      /должно быть не .plot_weights_graph, а .show/