Общие сведения

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

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

Совершенно другое дело — повторить живой разум. Или хотя бы приблизиться к нему. В прессе мы часто встречаем заголовки вида «ИИ научился думать как человек! Сэм Альтман не смог с этим смириться и скрылся в ужасе!». Но будем честны: признаков сознания у любых моделей ИИ пока не наблюдается.

В то же время, создать что-нибудь эдакое хочется. Ограничения привычных моделей стимулируют работать в альтернативных направлениях. В их числе — так называемые спайковые нейронные сети (Spiking Neural Networks, SNN).

В чем идея SNN? Успехи в создании мыслящего ИИ пока скромные. Это наводит на мысль: а не упускаем ли мы что-то важное? Какую-то особенность, присущую биологическим нейронам, которая позволяет мозгу думать? Такую, которой нет у распространённых нейросетевых моделей?

SNN стремятся точнее подражать именно биологическим нейронным сетям. В надежде таким образом обнаружить эффекты, похожие на работу настоящего мозга.

В этой и последующих статьях мы будем поэтапно рассматривать SNN. Начиная с принципов работы одиночного нейрона до целых сетей на их основе. Всё моделирование будет выполняться "с нуля". К доступным фреймворкам (snnTorch и другие) прибегать не будем. А пока, чтобы легче было понять суть SNN, разберёмся в характерных особенностях.

Различия SNN и классических сетей

Традиционные искусственные нейронные сети (далее — ANN) вдохновлены устройством нервной ткани. Однако, искусственный нейрон ANN наследует лишь малую часть способностей своих биологических собратьев.

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

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

Другие свойства биологического нейрона в ANN игнорируются. При этом свойств таких много.

Отметим также, что нейроны в архитектуре ANN абстрактная идея. Они не существуют отдельно. Упрощённо, ANN описывается матрицами смежности, каждый элемент которой определяет вес нейронной связи. Так, всю ANN можно рассматривать как одну очень сложную математическую функцию: пользователь передаёт ей входные данные, затем выполняется расчёт и выдаётся результат. И так до следующего model.fit() или нажатия клавиши "равно", вспоминая аналогию с калькулятором.

Кажется, реальный мозг работает несколько иначе.

Обратимся к устройству биологического мозга. На макроскопическом уровне мозг выглядит незамысловато — как сгусток жира (больше 60%). Но на клеточном уровне он представляет собой организованную колонию живых организмов — нейронов. При этом, каждый нейрон в такой колонии довольно самостоятелен.

Нейроны в мозге общаются между собой способом, похожим на морзянку. То есть, короткими импульсами примерно одинаковой формы — "спайками" (spike).

Искусственные нейроны SNN наследуют это свойство генерации спайков. Таким образом, сигналы в них кодируются сериями спайков (spike train), а не конкретными значениями функций активации. Это первое существенное отличие спайковых нейронов от ANN.

Спайковый нейрон как динамическая система

Следующее отличие — каждый искусственный спайковый нейрон это отдельная динамическая система. Она описывается дифференциальными уравнениями, которые определяют эволюцию системы во времени. То есть, в отличие от ANN, нейрон в SNN моделируется явно.

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

Хеббовский принцип обучения нейронов SNN

Естественным для SNN является так называемое Хеббовское обучение. Этот принцип можно сформулировать так: "нейроны, которые активируются вместе, связываются сильнее" (cells that fire together, wire together). Обучение происходит не в рамках отдельного процесса, а сразу в рабочем режиме. Если один нейрон постоянно раздражает соседа своими спайками, тот становится чувствительнее к ним.

В то же время, большинство ANN обучаются методом обратного распространения (backpropagation). К сожалению, в спайковых сетях этот метод работает плохо.

Множество разновидностей нейронов

Заключительное свойство нейронов SNN также берёт начало в биологическом мозге. Нейроны в нём неодинаковы, сортов — сотни. Каждый сорт имеет свой паттерн генерации спайков. Некоторые способны генерировать спайки как из пулемёта, а другие — только короткими сериями высокой частоты. Помимо этого, нейроны отличаются характером воздействия на соседей — некоторые своими спайками подавляют (ингибируют) активность других, некоторые, наоборот, возбуждают.

Различные паттерны генерации спайков. Оригинал https://www.izhikevich.org/publications/spikes.htm
Различные паттерны генерации спайков. Оригинал https://www.izhikevich.org/publications/spikes.htm

В заключение скажем, что искусственные нейроны SNN перенимают больше свойств реальных нейронов, чем ANN. Благодаря этому их поведение богаче. Потенциально, такие сети способны показывать эффекты, которые в ANN не проявляются. С другой стороны — спайковые нейроны вычислительно дороже, работать с ними в целом сложнее. Поэтому SNN имеют исследовательский интерес, а промышленное применение пока за ANN.

Далее проиллюстрируем принципы работы SNN на небольшом примере.

Иллюстрация работы простой SNN

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

Архитектура простой SNN
Архитектура простой SNN

Синий цвет обозначает тормозящий (ингибирующий) нейрон, а красный — возбуждающий. Ярко-красным обозначен нейрон, активность которого нас будет интересовать. Установим, что "красные" и "синие" будут иметь разные паттерны активации. "Красных" нужно возбуждать дольше, а частота спайков невелика.

Если начать подавать сигналы 0 и 1 в любом порядке, то пока сеть не научилась, нейрон J активироваться не будет. Его подавляют тормозящие нейроны B (0ABJ) и I (1IJ). Однако, со временем ситуация изменится. Возбуждающий сигнал 0 проложит себе дорогу по траектории 0ACDEFG. Тогда каждый сигнал 0 станет активировать нейрон H, который является тормозящим. В свою очередь, он станет подавлять активность тормозящего нейрона I, снимая оковы с J. Если сигнал 1 поступит в это окно возможностей, J активируется.

Примечательно, что J активируется только в таком порядке — сначала сигнал 0, потом через определённое время 1, ни раньше, ни позже. В других комбинациях, а также с другими интервалами между 0 и 1, активации не будет. Сигнал или не успеет добраться до I, или прибудет слишком поздно.

Также любопытно, что если добавить тормозящий нейрон между J и F, то для активации станет важен не только порядок 0 и 1. Теперь учитываются интервалы между последовательностями 0-1. Сеть реагирует на временную динамику.

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

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

Сначала опишем формальную модель одиночного нейрона. Подходящих моделей, которые могут генерировать спайки, несколько. Я назову несколько популярных.

  • Ходжкина-Хаксли (Hodgkin-Huxley). Подробно моделирует электрохимию каждого нейрона. Состоит из нескольких дифференциальных уравнений. Для практических задач, не связанных с изучением поведения нервной клетки, мало применима.

  • Интеграция с утечкой (Leaky Integrate&Fire, LIF). Описывается одним линейным дифференциальным уравнением \tau_m \dot{V} = -(V - V_{rest}) + R_m I. Самая быстрая из всех, но и самая бедная — позволяет эмулировать лишь самую простую динамику спайков.

  • Ижикевича (Izhikevich). Представлена системой из двух дифференциальных уравнений. Позволяет моделировать поведение нейронов разных типов. Приемлема с точки зрения вычислительной сложности. Является компромиссом между первыми двумя, поэтому будем использовать её.

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

\begin{cases} \dot{v} = 0.04v^2 + 5v + 140 - u + I \\ \dot{u} = a(bv - u) \end{cases}

Здесь v — мембранный потенциал, u — переменная восстановления, которая противодействует росту потенциала. Динамика u зависит как от самого потенциала, так и дополнительных параметров. Параметр a определяет силу такого противодействия, b — чувствительность u к флуктуациям потенциала v.

\text{if } v \geq V_p \text{, then} \begin{cases} v \leftarrow c \\ u \leftarrow u + d \end{cases}

При достижении мембранным потенциалом определённого порога V_p \geq 30mVпроисходит сброс. Этот момент считается моментом возникновения спайка. Переменная c задаёт значение, на которое сбрасывается потенциал, а d корректирует u после спайка.

Внешние токи, заряжающие мембрану, заданы переменной I(t). Это "вход" модели. Если передать ей единичный импульс I(0)достаточной силы, это может породить спайк. После него мембранный потенциал стабилизируется на уровне около -70mV (зависит от параметров). Можно сказать, что нейроны "заряжают" друг друга своими спайками. Общий вклад определяется I(t).

Если I(t)=A, где A — некоторая константа, то модель начнёт осциллировать, производя постоянный поток спайков. Частота и интервалы между сериями будут зависеть от параметров модели.

Также важно отметить, что "мощность" спайка никак не зависит от I(t) и других параметров. Его форма моделью не определяется. Значение мембранного потенциала другим нейронам напрямую не транслируется*. Потенциал является внутренним состоянием системы.

В заключение отмечу, что данная версия модели Ижикевича является упрощённой. Числовые коэффициенты в нелинейном уравнении подобраны Ижикевичем так, чтобы они подходили большинству нейронов. Также, они соответствуют временному разрешению dt=1~ms. То есть, 60 циклов в течении 1 секунды будут моделировать 60мс "жизни" нейрона. Подробнее можно ознакомиться в статьях автора. В реализации мы будем использовать упрощённую модель.

Реализация спайкового нейрона

Теперь приступим к реализации. Поскольку я не профессиональный разработчик, то буду делать это на том ЯП, который мне лучше всего знаком — на Swift. Весть дальнейший код — это фрагменты из экспериментального фреймворка, который я делал несколько лет назад для себя.

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

Первое, что сделаем — определим несколько протоколов. Excitable будет абстрактно описывать любую структуру, которая управляет состоянием отдельного нейрона. У нас такая структура будет только одна — Izhikevich. Аналогично, ExcitableParams будет описывать структуры с параметрами моделей.

protocol Excitable: Sendable  {
  associatedtype P: ExcitableParams
  init( params: P)
  mutating func updateState( Isyn: Double) -> Bool
  func stabilityCheck() -> Bool
  func v() -> Double
}

protocol ExcitableParams: Sendable {
  var type: NeuronType { get }
  var code: String { get }
}

Далее определим две вспомогательные структуры. Перечисление NeuronType будет определять тип нейрона — тормозящий или возбуждающий. NeuronKey станет ключом (идентификатором) нейрона, но пока он нам не потребуется.

enum NeuronType {
  case excitatory, inhibitory
}

struct NeuronKey: Hashable {
  var x: UInt16
  var y: UInt16
  var z: UInt8 = 0
  var L: UInt8 = 0
}

Теперь определим структуру IzhikevichParams со всеми параметрами, нужными для описания нейрона Ижикевича. Согласуем её с протоколом ExcitableParams.

struct IzhikevichParams: ExcitableParams {
  var a: Double = 0.02
  var b: Double = 0.2
  var c: Double = -65
  var d: Double = 2
  let type: NeuronType
  var code: String = ""
  var peak: Double = 30.0
  var v0: Double = -65
}

Такие значения по умолчанию описывают один из самых простых типов нейронов с регулярными спайками. Здесь peak это порог для генерации спайка, а v0 — значения мембранного потенциала, с которым инициализируется нейрон. Остальные значения описаны выше.

Теперь определим основную структуру Izhikevich, которая будет управлять состоянием нейрона:

struct Izhikevich: Sendable {
  private var isStable: Bool = true
  private var state: (v: Double, u: Double)
  private let params: IzhikevichParams
	
  init(_ params: IzhikevichParams) {
    self.state = (v: params.v0, u: params.v0*params.b)
	self.params = params
  }
}

И согласуем её с протоколом Excitable:

extension Izhikevich: Excitable {
  func stabilityCheck() -> Bool {
    isStable
  }
	
  func v() -> Double { state.v }
			
  mutating func updateState(_ Isyn: Double) -> Bool {
    var isFired = false

    if state.v >= params.peak {
    	state.v = params.c
		state.u += params.d
		isFired = true
	}
		
	let (v, u) = state
	let dv = 0.04*v*v + 5*v + 140 - u + Isyn
	let du = params.a*(params.b*v - u)
	state = (v: v+dv*dt, u: u+du*dt)

	isStable = abs(dv) < EPSILON && !isFired
	&& abs(du) < EPSILON

	return isFired
  }
}

Функция updateState() будет делать расчёты, обновлять внутреннее состояние и возвращать факт возникновения спайка в качестве результата. Эту функцию можно считать аналогом "активации" в ANN, хоть и весьма отдалённым.

Здесь стоит отметить переменную isStable. Она станет true, когда dv и du окажутся меньше глобальной переменной EPSILON = 1e-7. Переменная isStable потребуется, чтобы не делать лишнюю работу. Когда система находится в стабильном состоянии и нейрон неактивен, холостые расчёты нам не нужны.

Параметр Isyn будем получать извне. Это скалярный вход нашего нейрона.

Как видно, в updateState() применяется самый простой метод решения дифференциальных уравнений — Эйлера с постоянным dt. Значение dt = 1 также задаётся глобальной переменной. Использовать более точные методы (Рунге-Кутты и других) представляется нецелесообразным.

Ижикевич в своих работах предлагает дополнительные методы, улучшающих численную стабильность системы. Например интерполяцию при обновлении u. Но мы отложим их на потом.

Пока с моделью всё. Такой нейрон пока не умеет обучаться, да и интегрировать сигналы он не может. Да, это немного. Зато, уже сейчас мы можем увидеть, как работает спайковый нейрон.

Далее определим класс-модель для SwiftUI — Playground.

struct ChartPoint {
  var t = Date.now
  var v: Double
}

@MainActor final class Playground: ObservableObject {
  private var neuron: Izhikevich
  private let params: IzhikevichParams
  private var task: Task<Void, Never>?
  @Published private(set) var isRun: Bool = false
  @Published private(set) var V: [ChartPoint] = []
  @Published private(set) var spikes: [Int] = []
  @Published var Iconst: Double = 3
  @Published var pulse: Double = 0

  func stop() {
    if let task { task.cancel() }
  }

  private func I() async -> Double {
    let I = Iconst + pulse; pulse = 0
    return I
  }
    
  func start() {
    guard !isRun else { return }
    V.removeAll()
      
    task = Task {
      isRun = true
      
      while !Task.isCancelled {
        if neuron.updateState(await I()) {
          spikes.append(spikes.count)
        }
        
        V.append(ChartPoint(v: neuron.v()))
        if V.count > 1000 { V.removeFirst() }
        
        try? await Task.sleep(for: .microseconds(1))
      }
      
      isRun = false
    }
  }
  
  init() {
    params = IzhikevichParams(type: .excitatory)
    neuron = Izhikevich(params)
  }
}

Этот простейший класс реализует два полезных метода. start() для запуска задачи, внутри которой в цикле обновляется состояние. А также stop() — для остановки активной задачи. Этим методы можно вызывать из View.

Код представления (View) SwiftUI я здесь приводить не буду. Его несложно написать самостоятельно по своему вкусу: все нужные свойства для графика и для работы кнопок вынесены в @Published.

Результат выглядит примерно так:

Генерация спайков в SwiftUI
Генерация спайков в SwiftUI

Как и положено, I_{const} определяет частоту спайков, но не их форму. Не следует путать пики на графике со спайками — он показывает только значение мембранного потенциала v. Момент сброса совпадает со спайком. Сам спайк дискретен — он либо есть, либо нет. Диаграмма самих спайков выглядела бы как расчёска.

Пока на этом всё. В следующей части рассмотрим другой важный сетевой элемент — синапсы. Также, научим наши нейроны взаимодействовать с другими и интегрировать сигналы.

Спасибо за внимание! :-)

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