Приветствую. Меня зовут Алмаз Хуснутдинов. Я придумываю различные идеи о создании цифрового интеллекта. В этой статье я расскажу как можно вывести правило обучения Хебба из алгоритма обратного распространения ошибки и про идею о динамической нейросети. Я думаю над тем, как создавать знания в системе ИИ динамически, и как можно создать ИИ в принципе. Я не занимаюсь глубоким обучением, так как считаю, что имеет смысл развивать общие представления о создании ИИ.
Этот переход (или правило) я обнаружил случайно. Моей целью было придумать то, как сделать запоминание каждого примера нейросетью по одному. В итоге я пришел к тому, что для этого потребуются нейроны с двумя состояниями. Таким образом я открыл для себя импульсные нейросети, хотя изначально они вроде как основаны на биологической правдоподобности — испускание нейроном электрического потенциала.
Содержание: Как работает оптимизация, описание задачи, модель обычного нейрона, модель нейрона «пороговый интегратор».
Как работает оптимизация
Если интересно, то у меня есть подробный разбор алгоритма обратного распространения ошибки, там я показываю, как выводятся формулы для вычисления производных.
Идея о динамической нейросети исходит из того, что в биологической нейронной сети нету обратного распространения, есть только прямое распространение. Биологическая нейросеть обучается в режиме инференса, в режиме прямого распространения. То есть сеть обучается на основе того, что по ней проходят сигналы, данные. Динамическая память — это свойство, которым обладает естественный интеллект. Мозг животных устроен так, что он может одновременно работать в режиме обучения и в режиме вывода (инференса).
Я думаю, что одно из самых важных свойств системы, обладающей интеллектом, должно быть свойство динамичности памяти. То есть память должна быть динамической, чтобы знания могли сохраняться в ней динамически, прямо в режиме инференса. Это свойство отбрасывает необходимость в каких-либо наборах данных, так как знания будут образовываться динамически, они также могут извлекаться динамически, но с потерями точности из-за «сжатия».
Если говорить о теме статьи, то я представляю динамичность так, что нейросеть может запоминать примеры вопрос-ответ по одному, отдельно от остальных примеров. Что-то вроде словаря. Нейросеть в глубоком обучении так делать не может, так как она является непрерывной функцией, это значит, что такую нейросеть возможно только оптимизировать, оптимизация никак не связана с процессом запоминания. Она может «запоминать» данные, но это следствие оптимизации, запоминание в этом случает не является таковым. Если подавать нейросети примеры по одному, а не сразу все, то она очень быстро перестанет выводить правильные ответы на предыдущие примеры, на которых она обучалась, так как все параметры очень быстро сместятся в сторону новых примеров.
Одно из свойств, которыми должна обладать динамическая нейросеть — динамическое запоминание. Оно может быть обеспечено динамическим созданием синапсов (связей), но для этого потребуется разработать специальную модель импульсного нейрона, которая будет намного сложнее, чем обычный нейрон. Дополнительно можно создавать новые нейроны, чтобы увеличивать размер нейросети. Но обычная нейросеть не сможет так работать.
Если вы знаете как происходит обучение нейросетей на низком уровне, то вы можете видеть саму суть оптимизации. Она заключается в том, что у каждого веса в нейросети есть своя зависимость значения функции ошибки от этого веса. Во время обучения нейросети на конкретном примере происходит смещение каждого веса в сторону «запоминания» этого примера. Таким образом можно сделать вывод, что для запоминания каждого отдельного примера можно выделять в нейросети отдельную группу весов, которая будет хранить знания о конкретном примере.
Я начал думать о том, как можно выделять эти группы весов автоматически, чтобы осуществлять запоминание каждого примера по отдельности. Единственное приемлемое решение, которое я придумал — все нейроны должны иметь два дискретных значения, 0 или 1. Таким образом получится автоматически отслеживать какие веса участвовали в ответе нейросети — все веса, которые соединяют два активных нейрона. Таким образом обратное распространение будет изменять только веса между активными нейронами.
В алгоритме обратного распространения, который я использовал, есть формула, которая показывает как происходит изменение весов. Обучение Хебба — это не дифференцирование, а модель обучения биологических нейросетей. Этот алгоритм обратного распространения как раз описывает то, каким образом изменяются значения каждого синапса на основе простой формулы, и на основе этого изменения можно прийти к модели обучения Хебба. Формула для изменения веса на основе производной показана ниже.
Я заметил, что на всех слоях нейросети веса изменяются грубо говоря только на основе активности нейронов с обеих сторон. Тогда я подумал, что более эффективно будет просто немного изменять вес, чем вычислять производную. Потом я заметил, что это изменение веса сильно похоже на правило обучения Хебба (я знал об этом правиле задолго до того, как я придумал идею из этой статьи). Я узнал, что оно используется в импульсных нейронных сетях и начал интересоваться ими, так как я подумал, что импульсные нейроны как раз удовлетворяют необходимому условию — запоминать примеры по одному. К тому же я подумал, что на их основе можно создавать нейронную сеть динамически — добавлять новые веса или нейроны не влияя на уже запомненные примеры.
Описание задачи
Формулы для вычисления производных для функции ошибки MSE и функций активации «сигмоида».
Для выходных нейронов:
Для скрытых нейронов:
Для весов:
На первом слое весов в качестве будет
. На последнем скрытом слое в качестве
будет производная для выходного слоя.
i — номер нейрона на входном конце связи, j — номер нейрона на выходном конце связи, связь между i-м и j-м нейронами,
или
— входное значение нейрона,
— выходное значение нейрона на выходном слое,
— выходное значение нейрона на скрытом слое, E — значение функции ошибки, m — число нейронов на выходе слоя весов, k — число нейронов на входе слоя весов, n — число нейронов на выходном слое,
— целевое значение нейрона на выходном слое.

Рассмотрим обычные нейроны в ГО. Задача состоит в том, чтобы добавлять новые связи или нейроны в нейросеть, которая уже что-то запомнила. Если добавлять новые нейроны, то нейросеть перестанет нормально работать. Нейросеть должна как-то запоминать новые примеры, но одновременно и работать с теми примерами, на которых она уже обучилась. Как это сделать?
Есть предположение, что это получится, если нейроны будут выводить только два состояния, 0 или 1. Таким образом обратное распространение ошибки будет обучать только те нейроны, которые активировались. На каждом слое в таком случае обязательно должны быть активированные нейроны. Таким образом возможно получится добавлять новые связи или нейроны, не влияя на работу с данными, на которых нейросеть уже обучена.

Реализация отдельных нейронов
Исходя из того, что биологическая нейросеть работает только в режиме инференса, я начал думать о том, как нейросеть может запоминать примеры по одному, не сразу весь набор данных, а постепенно. На вход нужно подавать по одному примеру, и нейросеть должна их запоминать. Первое, с чего я начал — сделал нейрон как отдельный объект, так как так нейроны должны быть обособленными объектами, и так легче думать о том, что нейросеть динамическая — можно в любой момент добавить еще один нейрон в любой слой.
Для работы с алгоритмом обратного распространения ошибки нейронам нужно ссылаться на другие нейроны, которые являются входными и выходными. Входные — для того, чтобы принимать сигналы, выходные — чтобы получать значение производной. Ссылаться на нейроны можно через идентификаторы.
Функция для получения id:
Скрытый текст
NEURON_ID = []
def get_neuron_id(a=0, b=1000000):
neuron_id = randint(a, b)
while neuron_id in NEURON_ID:
neuron_id = randint(a, b)
NEURON_ID.append(neuron_id)
return neuron_id
Класс нейрона:
Скрытый текст
Веса — это веса для входных нейронов. В переменной state хранится значение нейрона после применения функции активации. Derivative — значение производной, оно вычисляется по формуле в зависимости от того, является нейрон скрытым или выходным. inp_neurons и out_neurons — списки входных и выходных нейронов (ссылки на экземпляры классов). is_hidden — является ли нейрон скрытым и id нейрона.
class Neuron:
def __init__(self, is_hidden):
self.weights = dict()
self.state = 0
self.derivative = 0
self.inp_neurons = []
self.out_neurons = []
self.is_hidden = is_hidden
self.id = get_neuron_id()
Метод для присоединения нейрона к входному нейрону.
На вход передается входной нейрон, добавляется в список входных нейронов, создается для него вес и в входной нейрон в его список выходных нейронов добавляется текущий нейрон.
def append_input_neuron(self, input_neuron):
self.inp_neurons.append(input_neuron)
self.weights[input_neuron.id] = init_weight()
input_neuron.out_neurons.append(self)
Методы forward и train:
Скрытый текст
В методе forward вычисляется взвешенная сумма и применяется функция активации. В методе train вычисляется производная по приведенным выше формулам. И изменение веса по градиентному спуску.
def forward(self):
z = 0
for inp_neuron in self.inp_neurons:
z += self.weights[inp_neuron.id] * inp_neuron.state
self.state = sigmoid(z)
def train(self, target=None):
if self.is_hidden:
self.derivative = 0
for out_neuron in self.out_neurons:
self.derivative += out_neuron.weights[self.id] * out_neuron.derivative
self.derivative = self.derivative * sigmoid_der(self.state)
else:
self.derivative = (self.state - target) * sigmoid_der(self.state)
def update_wetghts(self, lr=0.1):
for inp_neuron in self.inp_neurons:
self.weights[inp_neuron.id] -= lr * inp_neuron.state * self.derivative
Пример использования:
Скрытый текст
Создаем список нейронов model. В нем 2 входных, 2 скрытых и 2 выходных нейронов. Потом соединяем каждый нейрон с нужными нейронами. В каждом нейроне автоматически создадутся нужные связи и зависимости.
Сначала нужно сделать инференс, указываем значения входных нейронов и производим прямое распространение. Потом обучаем нейроны, нужно вызывать метод train у каждого нейрона отдельно. Для выходных нейронов передаем целевое значение, а для скрытых просто вызываем метод. Потом смотрим как нейроны обучились — смотрим вывод и целевые значения.
x, t, labels = binary_or()
model = [
Neuron(False), # x1
Neuron(False), # x2
Neuron(True), # h1
Neuron(True), # h2
Neuron(False), # o1
Neuron(False), # o2
]
model[2].append_input_neuron(model[0])
model[2].append_input_neuron(model[1])
model[3].append_input_neuron(model[0])
model[3].append_input_neuron(model[1])
model[4].append_input_neuron(model[2])
model[4].append_input_neuron(model[3])
model[5].append_input_neuron(model[2])
model[5].append_input_neuron(model[3])
for _ in range(100):
for i, inp in enumerate(x):
model[0].state = inp[0]
model[1].state = inp[1]
for neuron in model[2:]:
neuron.forward()
model[-2].train(t[i][0])
model[-1].train(t[i][1])
model[-3].train()
model[-4].train()
for neuron in model[2:]:
neuron.update_wetghts(lr=1.5)
for i, inp in enumerate(x):
model[0].state = inp[0]
model[1].state = inp[1]
for neuron in model[2:]:
neuron.forward()
for j, neuron in enumerate(model[4:]):
print(f"target: {t[i][j]}, output: {neuron.state}")
output:
target: 1, output: 0.8984420049422259
...
target: 0, output: 0.06996659899832215
Чтобы можно было легко создавать нейросеть из отдельных нейронов с любым числом нейронов в слоях, я сделал отдельный класс и пример использования. В статье приводить их не буду, слишком много кода. Если интересно, то разобраться будет не сложно.
Пороговый интегратор
В формуле для изменения веса на основе производной видно, что вес изменяется на основе входного значения и производной выходного значения, то есть на основе значений пресинаптического и постсинаптического нейронов. Если оба будут равны 1, то вес изменится, если только значение одного из нейронов будет равно 1, то вес не изменится.
Здесь я покажу модель нейрона, которая похожа на модель классического LIF-нейрона (leaky integrate and fire neuron, пороговый интегрирующий нейрон с утечкой). Только у этой модели нейрона нет утечки, так как мы здесь не будем работать с последовательностями («many to one»). Для импульсных нейронов все данные нужно представлять в виде последовательностей. Здесь будем обрабатывать данные способом «one to one», то есть один входной вектор и один выходной вектор. На первый скрытый слой нейронов подаются те же пиксели изображений, а на скрытые и выходной слои подаются значения 0 или 1 со скрытых слоев.
В этой модели нейрона, в отличие от предыдущей, нету функции активации, есть только суммация и вывод единицы, если превышен порог, и вывод нуля, если порог не превышен. Обучение у этой модели происходит на основе правила обучения Хебба. Если оба нейрона активны, то вес между ними увеличивается, если активен только входной нейрон, то связь немного уменьшается, если ни один нейрон не активен или активен только выходной нейрон, то ничего не изменяется.

lr — скорость обучения, pre_neuron — пресинаптический нейрон, post_neuron — постсинаптический нейрон. Также правило обучения Хебба похоже на правило обучения «холодно-горячо», в котором нужно убавлять или прибавлять веса в зависимости от ответа.
Функция для правила Хебба:
Скрытый текст
def hebbian_learning(spike_pre, spike_post, w, lr):
if spike_pre and spike_post:
w += lr
elif spike_pre and not spike_post:
w -= lr
return np.clip(w, -1, 1) # ограничение значения от -1 до 1
Рассмотрим ключевые методы этой модели нейрона, которые отличаются от предыдущей.
Скрытый текст
В методе integrate происходит суммация весов от активных входных нейронов — просто вычисляется взвешенная сумма. В методе fire происходит оценка порога и сохранение состояния активности нейрона. Эти два метода вызываются во время инференса один раз. В методе train происходит изменение весов по правилу Хебба на основе целевых значений нейронов и текущих.
def integrate(self):
self.state = 0
for inp_neuron in self.inp_neurons:
self.state += self.synapses[inp_neuron.id] * inp_neuron.state
def fire(self):
if self.state > 1:
self.state = 1
else:
self.state = 0
def train(self, inp, target, lr): # target - a number
# если нейрон не активировался и его целевое значение - 1, то нужно увеличить веса
# если нейрон активировался и его целевое значение - 0, то нужно уменьшить веса
if self.state != target:
for i, inp_i in enumerate(inp):
self.synapses[self.inp_neurons[i].id] = hebbian_learning(
spike_pre=inp_i,
spike_post=target,
w=self.synapses[self.inp_neurons[i].id],
lr=lr
)
Для этой модели нейрона я сделал отдельный класс для создания нейросети. Там все сложнее, чем с обычной моделью нейрона. Я сделал функцию для выбора случайных активных нейронов на каждом слое. Также можно выбирать нейроны, которые активировались во время вывода, а не выбирать их случайно. Обучение нейронов происходит на основе информации о том, какие нейроны были активированы для каждого входного примера.
Для обучения весов между слоями необходимо знать значения нейронов с обеих сторон связей. Для этого я сделал просто случайный выбор нейронов на слое, которые будут активированы для данного входного примера. На каждом слое случайно выбираются нейроны, которые должны активироваться для данного входного вектора. Эта идея о случайном выборе нейронов, которые будут активированы, исходит из того, что я описал в пункте «описание задачи». Нейроны из выходного слоя должны активироваться в соответствии с целевым вектором. Целевой вектор задается на основе разметки.
Приводить код класса нейросети и примера использования не буду. Если интересно, то код находится в папке.
Заключение
Динамическое запоминание похоже на то, как работает словарь, в него можно добавлять новые ключ-значение постепенно, а оптимизация так не работает, она требует запоминать весь набор данных за один раз. В этой реализации обучение нейросети по одному примеру невозможно, тут нужно придумывать какие-то дополнительные механизмы нейронов. Обучение этой нейросети работает только в режиме оптимизации, по одному примеру не работает.
Эта модель нейросети не может сделать то, что я хотел. Но это показывает идею о том, чтобы мыслить о создании динамической нейросети, которая возможно будет обладать очень важными свойствами, необходимыми для создания настоящего ИИ.
Преимущество многослойности нейросети остается, так как тут есть нелинейное преобразование в виде порогового значения. Я смог показать переход от нейросетей в глубоком обучении к импульсным нейронным сетям. Возможно описанное в этой статье сможет навести кого-то на какие-то новые идеи о нейросетях и об обучении, и покажет нейросети с другой стороны.
Папка с кодом на ГитХабе. Я использовал первые 100 изображений мнист, они хранятся в папке «Глубокое обучение/back_propagation/digits» в этом же репозитории, эту папку нужно перенести в папку с кодом. Либо можете использовать другой набор данных.
Мой канал в телеграме.
Автор: neuromancertdi