Всем привет! В первой части мы обсудили концепцию 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=1,~\theta=1.25,~\gamma=6, а w_{max} — ограничение сверху, своё для каждого типа пресинаптического нейрона.

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

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

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

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

struct SynapseOutput: Hashable, Identifiable {
  	var w: Double  // вес синапса
	var G: Double = 0 // суммарная проводимость
	var giEi: Double = 0
	var id: ObjectIdentifier // идентификатор
	var key: NeuronKey // пресинаптический ключ
	var stsp: Double // зарезервировано
	var E: Double // зарезервировано

	static func == (lhs: SynapseOutput, rhs: SynapseOutput) -> Bool {
		return lhs.id == rhs.id
	}

	func hash(into hasher: inout Hasher) {
		hasher.combine(id)
	}
}

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

// представление в виде строки для удобства
extension NeuronKey: CustomStringConvertible {
	var description: String {
		"[(\(x), \(y)), T:\(T), L:\(L), C:\(C)]"
	}
}

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

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

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: Identifiable {
	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 для бонуса
  	private var r: Double = 0.5 // зарезервировано
    private var stspX: Double = 1.0 // зарезервировано
	private var E: Double = 0 // зарезервировано

    // Синапс в стабильном состоянии, когда все проводимости
    // околонулевые
	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) -> SynapseOutput {
		if wasSpike {
			w = updateWeights(.postsynSpike)
		}
		
		let output = c.reduce(
			into: SynapseOutput(w: w, id: id, key: presynKey, 
                                stsp: stspX, E: E)
        ) { x, y in
			x.giEi += y.adjustedG(v) * y.E_L
			x.G += y.adjustedG(v)
		}

		c.indices.forEach { j in c[j].decay() }
		
		return output
	}

    // если получили спайк от пресинаптического нейрона,
    // корректируем веса соответственно и увеличиваем
    // проводимости
	func presynSpike(_ stsp: Double = 1.0) {
		w = updateWeights(.presynSpike)
		for j in c.indices { c[j].g += w * scale * x }
	}
}

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

fileprivate enum SpikeType {
	case postsynSpike, presynSpike
}

// расширяем Double, добавляем наш ограничитель весов
fileprivate extension Double {
	func smooth(_ scale: Self) -> Self {
		let (theta, gamma, h) = (1.25, 6.0, 1.0)
		return h*scale/(1 + pow(self/(theta*(1-self)), -gamma))
	}
}

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

extension Synapse {
	private func updateWeights(_ type: SpikeType) -> Double {
		var dW: Double = 0
        guard params.presynType == .excitatory
		else { return w }

		let globalTimer = Double(
			atomicGt.load(ordering: .sequentiallyConsistent)
		)
		
		switch type {
		case .presynSpike:
			let exp0 = -(globalTimer - postSpikeT)/tau_s
			presSpikeT = globalTimer
			dW = -Am * exp(exp0)
		case .postsynSpike:
			let exp0 = (presSpikeT - globalTimer)/tau_s
			postSpikeT = globalTimer
			dW = +Ap * exp(exp0)
		}

        let updatedW = w + dW
      
		return updatedW.smooth(params.weightLimit)
	}
}

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

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

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, weightLimit: 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: Принципы и модель

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