Искусственные нейронные сети находятся на волне популярности. Самые современные модели ИИ способны творить чудеса: поддерживать видимость общения на уровне человека, создавать реалистичные изображения, писать музыку.
Сейчас никого не удивить заявлениями, что искусственный интеллект превзошёл человеческий. Справедливости ради, способности простого калькулятора тоже давно их превзошли. Например, в скорости умножения чисел — даже двузначных. Опередить человека в определённых аспектах — задача не сложная.
Совершенно другое дело — повторить живой разум. Или хотя бы приблизиться к нему. В прессе мы часто встречаем заголовки вида «ИИ научился думать как человек! Сэм Альтман не смог с этим смириться и скрылся в ужасе!». Но будем честны: признаков сознания у любых моделей ИИ пока не наблюдается.
В то же время, создать что-нибудь эдакое хочется. Видимая граница возможностей привычных моделей стимулирует работать в альтернативных направлениях. В числе них — так называемые спайковые нейронные сети (Spiking Neural Networks, SNN).
В чем идея SNN? Успехи в создании мыслящего ИИ пока скромные. Это наводит на мысль: а не упускаем ли мы что-то? Какую-то важную особенность, присущую биологическим нейронам, которая позволяет мозгу думать? Такую, которой нет у распространённых нейросетей?
SNN стремятся точнее повторять функционирование именно биологических нейронных сетей. В надежде таким образом обнаружить свойства, близкие к настоящему мозгу.
В этой и последующих статьях мы будем поэтапно рассматривать SNN. Начиная с принципов работы одиночного нейрона до целых сетей на их основе. Всё моделирование будет выполняться "с нуля". К доступным фреймворкам (snnTorch и другие) прибегать не будем. А пока, чтобы легче было понять суть SNN, разберёмся в характерных особенностях.
Традиционные искусственные нейронные сети (далее — ANN) вдохновлены устройством нервной ткани. Однако, искусственный нейрон ANN наследует лишь малую часть способностей своих биологических собратьев.
Первая способность — интегративная. Искусственный нейрон объединяет несколько сигналов в единый выходной сигнал. Этот сигнал формируется некоторой непрерывной функцией — "функцией активации". Значение этой функции передаётся следующим нейронам в сети или используется как результат.
Вторая — способность к обучению. Входные сигналы объединяются согласно "весу" каждого входа, которые можно настраивать. Настройка выполняется в ходе самостоятельной обучающей процедуры.
Другие свойства биологического нейрона в ANN игнорируются.
Отметим также, что нейроны в архитектуре ANN — абстрактная идея. Они не существуют отдельно. Упрощённо, ANN описывается матрицами смежности, каждый элемент которой определяет вес нейронной связи. Так, всю ANN можно рассматривать как одну очень сложную математическую функцию: пользователь передаёт ей входные данные, затем выполняется расчёт и выдаётся результат. И так до следующего model.fit()
или нажатия клавиши "равно", если вспомнить аналогию с калькулятором.
Кажется, реальный мозг работает несколько иначе.
Обратимся к устройству биологического мозга. На макроскопическом уровне мозг выглядит незамысловато — как сгусток жира (больше 60%). Но на клеточном уровне он представляет собой организованную колонию живых организмов — нейронов. При этом, каждый нейрон в такой колонии автономен.
Нейроны в мозге общаются между собой способом, похожим на морзянку. То есть, короткими импульсами одинаковой формы — "спайками" (spike).
Искусственные нейроны SNN наследуют это свойство генерации спайков. Таким образом, сигналы в них кодируются сериями спайков (spike train), а не конкретными значениями функций активации. Это первое существенное отличие спайковых нейронов от ANN.
Следующее отличие — каждый искусственный спайковый нейрон это отдельная динамическая система. Она описывается дифференциальными уравнениями, которые определяют эволюцию системы во времени. Следовательно, состояние каждого искусственного нейрона требуется постоянно просчитывать. Вычислительная сложность такого подхода высока, а моделировать большие SNN весьма затратно.
Естественным для SNN является так называемое Хеббовское обучение. Этот принцип можно сформулировать так: "нейроны, которые активируются вместе, связываются сильнее" (cells that fire together, wire together). Обучение происходит не в рамках отдельного процесса, а сразу в рабочем режиме. Если один нейрон постоянно раздражает соседа своими спайками, тот становится чувствительнее к ним.
В то же время, большинство ANN обучаются методом обратного распространения (backpropagation). К сожалению, в спайковых сетях этот метод работает плохо.
Заключительное свойство нейронов SNN также берёт начало в биологическом мозге. Нейроны в нём неодинаковы, сортов — сотни. Каждый сорт имеет свой паттерн генерации спайков. Некоторые способны генерировать спайки как из пулемёта, а другие — только короткими сериями высокой частоты. Помимо этого, нейроны отличаются характером воздействия на соседей — некоторые своими спайками подавляют (ингибируют) активность других, некоторые, наоборот, возбуждают.
В заключение скажем, что искусственные нейроны SNN перенимают больше свойств реальных нейронов, чем ANN. Благодаря этому их поведение богаче. Потенциально, такие сети способны показывать эффекты, которые в ANN не проявляются. С другой стороны — спайковые нейроны вычислительно дороже, работать с ними в целом сложнее. Поэтому SNN имеют исследовательский интерес, а промышленное применение пока за ANN.
Далее проиллюстрируем принципы работы SNN на небольшом примере.
На представленной диаграмме цифрами обозначены рецепторы. Это нейроны, которые принимают входной сигнал и генерируют короткую серию спайков в ответ. Буквами обозначены остальные нейроны сети, а стрелками — направления распространения спайков.
Синий цвет обозначает тормозящий (ингибирующий) нейрон, а красный — возбуждающий. Ярко-красным обозначен нейрон, активность которого нас будет интересовать. Установим, что "красные" и "синие" будут иметь разные паттерны активации. "Красных" нужно возбуждать дольше, а частота спайков невелика.
Если начать подавать сигналы 0 и 1 в любом порядке, пока сеть не научилась, нейрон J активироваться не будет. Он будет подавляться либо тормозящим нейроном B (0ABJ), либо I (1IJ). Однако со временем ситуация изменится. Возбуждающий сигнал 0 пробьёт себе дорогу по траектории 0ACDEFG. Тогда каждый сигнал 0 станет активировать нейрон H, который является тормозящим. H станет подавлять активность другого тормозящего нейрона I, снимая оковы с J. Если сигнал 1 поступит в это окно возможностей, J активируется, так как I в этот момент будет подавлен сигналом от H.
Примечательно, что J активируется только в таком порядке — сначала сигнал 0, потом через определённое время 1, ни раньше, ни позже. В других комбинациях, а также с другими интервалами между 0 и 1, активации не будет. Сигнал или не успеет добраться до I, или прибудет слишком поздно.
Также любопытно, что если добавить тормозящий нейрон между J и F, то станет важна не только последовательность и интервал между сигналами 0 и 1, но и интервалы между сериями 0-1.
Как видно, даже столь простая модель способна показывать нетривиальное поведение. Теперь перейдём к описанию подхода к моделированию спайкового нейрона.
Сначала опишем формальную модель одиночного нейрона. Подходящих моделей, которые могут генерировать спайки, несколько. Я назову несколько популярных.
Ходжкина-Хаксли (Hodgkin-Huxley). Подробно моделирует электрохимию каждого нейрона. Состоит из нескольких дифференциальных уравнений. Для практических задач, не связанных с изучением поведения нервной клетки, она мало применима.
Интеграция с утечкой (Leaky Integrate&Fire, LIF). Описывается одним линейным дифференциальным уравнением . Самая быстрая из всех, но и самая бедная — позволяет эмулировать лишь самую простую динамику спайков.
Ижикевича (Izhikevich). Представлена системой из двух дифференциальных уравнений. Позволяет моделировать поведение нейронов разных типов. Приемлема с точки зрения вычислительной сложности. Является компромиссом между первыми двумя, поэтому будем использовать её.
Уравнения Ижикевича описывают эволюцию мембранного потенциала — свойства биологического нейрона, от которого зависит генерация спайков. Разбирать физический смысл мембранного потенциала не будем. Его можно воспринимать просто как степень возбуждённости нейрона. Сами уравнения выглядят следующим образом:
Здесь — мембранный потенциал,
— переменная восстановления, которая противодействует росту потенциала. Динамика
зависит как от самого потенциала, так и дополнительных параметров. Параметр
определяет силу такого противодействия,
— чувствительность
к флуктуациям потенциала
.
По достижению мембранным потенциалом определённого порога происходит сброс. Этот момент считается моментом возникновения спайка. Переменная
задаёт значение, на которое сбрасывается потенциал, а
корректирует
после спайка.
Внешние токи, заряжающие мембрану, заданы переменной . Это "вход" модели. Если передать ей единичный импульс
достаточной силы, то может возникнуть спайк. После него мембранный потенциал стабилизируется на уровне около -70mV (зависит от параметров). Можно сказать, что нейроны "заряжают" друг друга своими спайками. Общий вклад определяется
.
Если , где
— некоторая константа, то модель начнёт осциллировать, производя постоянный поток спайков. Шаблон этой последовательности (частоту, интервалы между сериями и т.п.) будет определяться прочими параметрами модели.
Также важно отметить, что "мощность" спайка никак не зависит от и других параметров. Его форма моделью никак не определяется. Значение мембранного потенциала другим нейронам также не транслируется*. Потенциал является внутренним состоянием системы — важен только факт спайка.
В заключение отмечу, что данная версия модели Ижикевича является упрощённой. Числовые коэффициенты в нелинейном уравнении подобраны Ижикевичем так, чтобы они подходили большинству нейронов. Также, они соответствуют временному разрешению . То есть, 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
.
Результат выглядит примерно так:
Как и положено, определяет частоту спайков, но не их форму. Не следует путать пики на графике с формой спайков — он отображает значение мембранного потенциала
. Спайк же дискретен, он либо есть, либо нет. Если бы мы хотели показать именно спайки, такой график выглядел бы как расчёска.
Пока на этом всё. В следующей части рассмотрим другой важный сетевой элемент — синапсы. Также, научим наши нейроны взаимодействовать с другими и интегрировать сигналы.
Спасибо за внимание! :-)