Как стать автором
Поиск
Написать публикацию
Обновить

Спайковые нейросети на Swift, часть II: Синапсы

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

Всем привет! В первой части мы обсудили концепцию SNN в общих чертах, выбрали модель Ижикевича для нейрона и реализовали её на Swift. Напомню основные тезисы:

  • Спайковые нейронные сети (SNN) стремятся точнее повторять биологические.

  • Как и живые нейроны, искусственные спайковые нейроны взаимодействуют друг с другом короткими импульсами — спайками.

  • Спайковые нейроны способны интегрировать информацию и обучаться, но механика этих процессов отличается от «традиционных» нейросетей. Там, где важно подчеркнуть различие, будем называть их ANN.

  • Спайковые нейроны, как и биологические, могут быть разных типов — тормозящими, возбуждающими. Они могут различаться паттернами генерации спайков.

  • Степень возбуждения нейрона определяется значением мембранного потенциала — некоторого физического свойства нейрона.

Но от одного нейрона нет никакой практической пользы. Поэтому в этой статье реализуем второй важный элемент — синапсы. Через них наши нейроны смогут общаться. В этом деле нам снова помогут биологические механизмы.

Входы и выходы нейронов

Для дальнейшей дискуссии потребуется ввести дополнительные понятия.

Клетка Пуркинье с разветвлённой сетью дендритов. Рамон-и-Кахаль
Клетка Пуркинье с разветвлённой сетью дендритов. Рамон-и-Кахаль

Дендриты. Отростки нервной клетки, на которых расположены входы нейронов. Могут принимать и интегрировать сигналы от тысяч других клеток.

Аксоны. Выходы нервной клетки. По ним распространяются спайки.

Терминаль. Окончание аксона. Из неё под воздействием спайка выделяются химические вещества — медиаторы.

Синапс. Устойчивый контакт между выходом и входом. То есть, между терминалью одного нейрона и дендритом другого. В более редких случаях соединяет аксон с телом, с другим дендритом и т.п.

Пресинаптический нейрон. Нейрон, который производит спайки и выделяет медиаторы.

Постсинаптический нейрон. Приёмник, который поглощает медиаторы. Может сам быть пресинаптическим для других нейронов сети.

Медиаторы химические вещества, которыми обмениваются пресинаптический и постсинаптический нейроны.

Вес синапса. В модели чувствительность синапса к медиаторам.

Синапсы бывают двух типов: химические и электрические. Пока будем рассматривать основной тип химические.

Обмен сигналами между нейронами через синапсы происходит следующим образом:

  1. Пресинаптический нейрон производит спайк.

  2. Спайк распространяется по аксону, в конце концов попадая на терминаль — окончание аксона.

  3. В терминали начинаются процессы, которые приводят к выбросу медиаторов в окружающее пространство — синаптическую щель.

  4. Партнёр по синапсу — постсинаптический нейрон — впитывает эти медиаторы.

  5. Захваченные медиаторы служат чем-то вроде ключей к замку. Они увеличивают проводимость мембраны нейрона. Через неё начинает течь ток.

  6. Ток изменяет мембранный потенциал: он повышается (возбуждение) или снижается (торможение).

  7. Если мембранный потенциал постсинаптического нейрона достигает нужного уровня, в нём также возникает спайк.

Спайк попадает на терминаль и вызывает выброс медиатора. Мембранный потенциал соседнего нейрона увеличивается
Спайк попадает на терминаль и вызывает выброс медиатора. Мембранный потенциал соседнего нейрона увеличивается

Таким образом, обмен сигналами происходит опосредованно, через синапс. Можно сказать, спайк пресинаптического нейрона только подталкивают соседа сделать собственный.

Так, если синапс слабый, нейрон будет реагировать на подталкивания неохотно. Но, по мере стимуляции, чувствительность повышается. Это часть процесса обучения — синапсы постепенно «раскачиваются» или угасают. Фактически, вся информация в мозге хранится в виде синаптических весов.

Будет синапс тормозящим или возбуждающим, определяется действием медиаторов. Тормозящие медиаторы заставляют ток вытекать из нейрона, разряжая его и снижая мембранный потенциал. Возбуждающие — наоборот.

Перейдём к моделированию поведения синапса.

Моделирование синапса

Для начала вспомним модель Ижикевича, которую мы выбрали в предыдущей части для описания поведения нейрона:

\begin{cases} C\dot{v} = k\,(v - v_r)\,(v - v_t) - u + I(t) \\ \dot{u} = a\big[b\,(v - v_r) - u\big] \end{cases}

Ток I=I(t), который изменяет мембранный потенциал нейрона, теперь станет совокупностью всех токов, которые протекают через синапсы. Токи эти возникают под воздействием входящих спайков.

В реальных нейронах интеграция может быть очень сложна — токи складываются или гасят друг друга. В нашем случае общий ток это просто I(t)=\sum I_{syn}(t), где

I_{syn}(t) = E(t)-vG(t)

То есть, мы рассматриваем нейрон как точечный объект и простой сумматор. ЗдесьI_{syn} — ток через каждый синапс нейрона, а

G(t)=\sum g_i(t),\qquad E(t)=\sum g_i(t)\,E_i(t),

гдеG(t) это текущая суммарная проводимость синапса,E_i — константы. Сейчас нам нужно знать про них только то, что они зависят от типа синапса.

С проводимостью всё сложнее. Это тоже динамическая характеристика, которая зависит от времени. Поэтому для описания g(t) нам потребуется ещё одно дифференциальное уравнение:

\tau_s \dot{g} = -g + w_sS \sum x\delta (t - t^f)

Проводимость постоянно угасает со скоростью \tau_s. Переменная w_s — вес синапса, S — константа масштабирования, x — фактор пластичности синапса в момент спайка.

Выражение с суммой равно 1 тогда, когда в момент t зарегистрирован спайк, и 0 в других случаях. Соответственно t^f — время регистрации спайка пресинаптического нейрона, \delta — функция Дирака.

Как и говорили ранее, важен только факт спайка. Так, если спайки перестанут поступать, синапс со временем "закроется", ток прекратится: G(t) \to 0, I_{syn} \to 0 при t \to \infty. А в момент спайка проводимости увеличатся на w_s\sum{x} и значение мембранного потеницала снова придёт в движение.

В нашем подходе мы не будем передавать I(t) в модель Ижикевича напрямую, а вместо этого будем передавать суммарные значения G(t) и E(t)по всем синапсам.

Подводя итог, каждый новый спайк увеличивает проводимость синапса. Проводимость — величина, обратная сопротивлению из закона Ома. Её рост увеличит интенсивность токов через этот синапс. И если общий ток со всех синапсов окажется достаточно сильным, произойдёт спайк. Так происходит передача информации между нейронами.

Обучение синапсов и нейронов

Свойство синапсов и нейронов изменять свою чувствительность ("вес") и обучаться называют пластичностью. Пластичность бывает нескольких видов, в этой части мы рассмотрим один — Spike-Timing Dependent Plasticity или STDP.

Spike-Timing Dependent Plasticity

В этом случае вес синапса изменяется на достаточно длительный период, но медленно убывает со временем. Этот вид пластичности лежит в основе обучения Хебба, затронутое в первой части. Вес синапса w_s изменяется по следующим правилам:

\begin{aligned} \Delta w=A_{+}e^{(t_{pre}-t_{post})\tau_{+}}, t_{post}\ge t_{pre} \\  \Delta w=-A_{-}e^{-(t_{pre}-t_{post})\tau_{-}}, t_{post}<t_{pre} \end{aligned}

Корректировка выполняется по факту каждого спайка с любой стороны. Идея следующая: когда пресинаптический нейрон спайкует непосредственно до постсинаптического, это увеличивает вес. А если после — уменьшает. И чем больше разница во времени, тем меньше эффект.

То есть, если один нейрон спайкует сразу за другим, связь между ними усиливается. Это усиление зависит от того, насколько "сразу" был спайк. Если через длительный промежуток времени, то считается, что причинно-следственной связи между спайками нет и на вес они почти не влияют. За это отвечают экспоненты формулах.

В то же время, если первый нейрон постоянно спайкует, а второй на это сразу не реагирует, вес связи будет уменьшаться.

Параметры возьмём такие: \tau_+=\tau_-=100,~A_+=1,~A_-=2. То есть, штрафы будут в два раза сильнее бонусов. Два последних параметра можно трактовать как "скорость обучения" (learning rate).

Со временем, с каждым новым спайком, w_s будет бесконтрольно расти или станет отрицательным. Такого нам не надо, поэтому вес мы будем ограничивать так:

\hat{w}=\frac{hw_{max}}{1+(\frac{w}{\theta(1-w})^{-\gamma}}

Где h=3,~\theta=1.25,~\gamma=6, а w_{max} — ограничение сверху, своё для каждого типа пресинаптического нейрона.

Теперь пришло время реализовать эту модель. Приступим.

Реализация синапса

Моделирование синапсов также будем выполнять на Swift. Сначала определим базовые структуры:

struct SynapseParams {
	var presynType: NeuronType // Тип синапса (возбуждающий, тормозящий)
	var wMax: Double // ограничение на вес "сверху"
}

// разные типы проводимости gi
enum ConductanceType {
	case ampa, nmda, gabaa, gabab
}

typealias SynapseOut = (giEi: Double, G: Double)

struct SynapseWeight: Hashable {
	var w: Double
	var giEi: Double
	var G: Double
}

// Идентификатор нейрона. Потребуется в будущем
struct NeuronKey: Hashable {
	var x: Int = 0
	var y: Int = 0
	var T: Int = 0
	var L: Int = 0
	var C: Int = 0
}

Теперь структуру, которая будет отвечать за проводимости:

struct Conductance {
	let tau: Double
	var g: Double = 0.0
	let type: ConductanceType
	let ratio: Double // удельный вес этой проводимости
	let E_L: Double // обратный потенциал

    // глутаматные синапсы NMDA зависит от потенциала,
    // учитываем это здесь
	private func nmda(_ v: Double) -> Double {
		let y = pow((v + 80)/60, 2)
		return y/(1 + y)
	}

    // расчёт проводимости
	func adjustedG(_ v: Double) -> Double {
		let s = switch type {
		case .nmda: nmda(v)*ratio
		default: ratio
		}
		
		return g * s
	}

    // флаг равен true, когда проводимость около нуля
	var isStable: Bool { g < EPSILON }

    // угасание проводимости со временем
	mutating func decay() {
		g -= dt * g / tau
	}

    // задаём начальные параметры. Значения E_L
    // и tau стандартные для своих типов
	init(_ type: ConductanceType, ratio: Double = 1.0)
	{
		switch type {
		case .ampa: E_L = 0; tau = 5
		case .gabaa: E_L = -70; tau = 6
		case .gabab: E_L = -90; tau = 150
		case .nmda: E_L = 0; tau = 150
		}
		self.ratio = ratio
		self.type = type
	}
}

Теперь базовый класс для синапса:

actor Synapse {
	private var w: Double // вес
	private let tau_s: Double = 20 // скорость угасания
	private let scale: Double = 0.01 // переменная масштабирования
	private	var c: [Conductance] = [] // вектор проводимостей
	nonisolated let presynKey: NeuronKey
	nonisolated let postsynKey: NeuronKey
	nonisolated let params: SynapseParams // параметры
	private var postSpikeT: Double = -Double.infinity // время постспайка
	private var presSpikeT: Double = -Double.infinity // время преспайка
	private var postsynV: Double = 0 // постсинаптичнский потенциал
	private let wGABA: Double = 4.0 // постоянный вес для ГАМК
	private let Am: Double = 2.0 // LR для штрафа к весу
	private let Ap: Double = 1.0 // LR для бонуса

    // Синапс в стабильном состоянии, когда все проводимости
    // околонулевые
	var isStable: Bool {
		c.allSatisfy { $0.isStable }
	}
			
	init(
		postsynKey: NeuronKey, presynKey: NeuronKey,
		params: SynapseParams)
	{
		self.params = params
		self.postsynKey = postsynKey
		self.presynKey = presynKey

        // тормозящие синапсы -- ГАМКовые
        // возбуждающие синапсы -- глутаматные (AMPA, NMDA)
		c = switch params.presynType {
		case .inhibitory:
			[Conductance(.gabaa), Conductance(.gabab)]
		case .excitatory:
			[Conductance(.ampa), Conductance(.nmda)]
		}

        // инициализируем начальные веса
		self.w = switch params.presynType {
		case .excitatory:
			Double.random(in: 0...1)
		case .inhibitory:
			wGABA
		}
	}
}
  • Для каждого типа синапса (возбуждающий, тормозящий) будет свой набор проводимостей

  • Веса w инициализируем случайно из диапазона [0, 1] для каждого синапса с возбуждающими нейронами

  • Синапсы с тормозящими нейронами не обучаются, их вес фиксирован

Добавим основную функциональность:

extension Synapse {	
    // сначала режем проводимости по экспоненциальному
    // закону. Если был спайк -- обновляем веса. Дальше
    // рассчитываем общие проводимости по всем типам
    // и возвращаем
	func update(_ wasSpike: Bool, v: Double) -> SynapseWeight {
		self.postsynV = v
		c.indices.forEach { j in c[j].decay() }
		if wasSpike { updateWeights(.postsynSpike) }
		
		return c.reduce(into: SynapseWeight(w: w, giEi: 0, G: 0))
		{ x, y in
			x.giEi += y.adjustedG(v) * y.E_L
			x.G += y.adjustedG(v)
		}
	}

    // если получили спайк от пресинаптического нейрона,
    // корректируем веса соответственно и увеличиваем
    // проводимости
	func presynSpike(_ stsp: Double = 1.0) {
		updateWeights(.presynSpike)

		for j in c.indices {
			c[j].g += w * scale * stsp
		}
	}
}

И, наконец, реализуем обучение синапса через STDP. Сначала служебные структуры:

enum SpikeType {
	case postsynSpike, presynSpike
}

// расширяем Double, добавляем наш ограничитель весов
extension Double {
	mutating func smoothLimit(_ wMax: Self) {
		let (theta, gamma, h) = (1.25, 6.0, 3.0)
		self = alpha*wMax/(1 + pow(self/(theta*(1-self)), -gamma))
	}
}

Теперь основную логику:

extension Synapse {
	private func updateWeights(_ type: SpikeType) {
        // для тормозящих синапсов ничего не делаем
		guard params.presynType == .excitatory
		else { return }

		switch type {
		case .presynSpike:
			let exp0 = -(globalTimer - postSpikeT)/tau_s
			presSpikeT = globalTimer
			w -= Am * exp(exp0)
		case .postsynSpike:
			let exp0 = (presSpikeT - globalTimer)/tau_s
			postSpikeT = globalTimer
			w += Ap * exp(exp0)
		}
		
		w.smoothLimit(params.wMax)
	}
  }
}

Здесь globalTimer — глобальный счётчик времени.

В прошлой части мы возбуждали наш нейрон, передавая ему значение тока напрямую, как константу. Теперь это можно делать через синапс:

var wasPostsynSpike: Bool = false
var injectPresynSpike: Bool = false
let dummyKey = NeuronKey(x: 0, y: 0)
let isRunning = false

// let params = IzhikevichParams(...)
// let neuron = Izhikevich(params)

let sParams = SynapseParams(presynType: .excitatory, wMax: 10.0)
let s = Synapse(postsynKey: dummyKey, presynKey: dummyKey, params: sParams)

while isRunning {
  // 1. Вручную подаём входящий спайк
  if injectPresynSpike {
    await s.presynSpike()
    injectPresynSpike = false
  }

  // 2. и смотрим что получилось
  let synOut = await synapse.update(wasPostsynSpike, v: neuron.V)

  // 3. Передаём проводимости на вход нейрону
  wasPostsynSpike = neuron.updateState(synOut, constI: 0) 

  if wasPostsynSpike {
    //реагируем на выходной спайк, если он был
  }
}
  • В этом примере мы больше не передаём машине состояний нейрона значение тока напрямую, а используем проводимости synOut, которые получили с синапса

  • Подача входящего спайка в синапс делается через presynSpike()

  • injectPresynSpike контролирует, когда нужно подавать спайк

Контроллер Playground из прошлой части теперь можно модифицировать так, чтобы он работал с синапсом, а не напрямую с нейроном.

А пока на этом всё. В следующей части соберём все ингредиенты вместе.

Спайковые нейросети на Swift, часть I: Принципы и модель

Спасибо за прочтение!

Теги:
Хабы:
+5
Комментарии0

Публикации

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