Search
Write a publication
Pull to refresh

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

Level of difficultyMedium
Reading time11 min
Views1K

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

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

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

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

Скромность успехов в попытках научить машину думать ставит вопросы: а не упускаем ли мы что-то важное? Какую-то особенность, присущую биологическим нейронам? Такую, которой не обладают распространённые нейросетевые модели?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Нейронные связи как биологического мозга, так и SNN или ANN, могут усиливаться или ослабляться. Это выглядит как изменение «весов» связей. В SNN такое обучение происходит сразу в рабочем режиме — нейроны обучают сами себя.

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

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

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

Различные паттерны генерации спайков в ответ на стимул. Обратите внимание на резонатор (RZ) — его паттерн достаточно необычен. Оригинал
Различные паттерны генерации спайков в ответ на стимул. Обратите внимание на резонатор (RZ) — его паттерн достаточно необычен. Оригинал

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

\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}

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

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

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

Часто, для численного решения этих уравнений применяется метод Эйлера:

\begin{aligned} v(t+\tau) &= v(t) + \tau\,f_v\big(v(t),u(t),I(t)\big),\\ u(t+\tau) &= u(t) + \tau\,f_u\big(v(t),u(t)\big), \end{aligned}

где

\begin{aligned} f_v(v,u,I) &= \frac{1}{C}\,\Big(k\,(v - v_r)\,(v - v_t) - u + I\Big),\\ f_u(v,u) &= a\big[b\,(v - v_r) - u\big] \end{aligned}

Далее мы воспользуемся рекомендациями Ижикевича по улучшению численной стабильности модели. Обновлять v(t) будем следующим образом:

v(t+\tau)=\frac{v(t)+\tau\big[f_v\big(v(t),u(t),I(t)\big)+E(t)\big]}{1+\tau\,G(t)},

где

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

Разбор смысла G(t) и E(t) отложим на будущее, а пока просто приравняем их к нулю: G(t)=0, ~E(t) = 0.

Также, при обновлении переменной u, будем использовать линейную интерполяцию вместо фиксированного шага \tau:

u(t+\tau) \;=\; u(t) \;+\; f_u\big(v(t),u(t)\big)\;\frac{V_p - v(t)}{\,v(t+\tau)-v(t)\,}.

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

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

И последнее: для практического применения нужно выбрать шаг dt. Примем временное разрешение dt=1~ms. То есть, 60 циклов в течении 1 секунды будут моделировать 60мс «жизни» нейрона. Подробнее можно ознакомиться в статьях автора.

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

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

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

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

protocol ExcitableParams: CustomStringConvertible {
    var code: String { get } // короткий код нейрона
    var type: NeuronType { get } // тип нейрона: возбуждающий, тормозящий
    var vr: Double { get } // потенциал покоя
}

struct GP {
    static let dt = 1.0 // Временной шаг
    static let epsilon = 1e-7 // Порог стабильности
}

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

protocol Excitable: Sendable {
    func stabilityCheck() -> Bool
    associatedtype P: ExcitableParams
    mutating func updateState(_ s: SynapsesOut, constI: Double) -> Bool
    var V: Double { get } // Текущий мембранный потенциал
    init(_ params: P)
}

Перечисление NeuronType будет определять тип нейрона — тормозящий или возбуждающий. IzhikevichParams -- параметры нейрона, которые определяют его поведение. Структура согласуется с ExcitableParams.

enum NeuronType {
    case excitatory, inhibitory
}

struct IzhikevichParams: ExcitableParams {
	// Основные коэффициенты модели
    var a: Double
    var b: Double
    var c: Double
    var d: Double 
    
    let type: NeuronType

    var code: String
    var description: String

    var vP: Double
    var v0: Double { c }
	
	// Расширенные коэффициенты модели
    var k: Double
    var C: Double
    var vr: Double
    var vt: Double
}

Теперь определим машину состояний для нейрона Izhikevich:

struct Izhikevich: Sendable {
    private var isStable: Bool = true // Флаг стабильности
    // Переменные состояния: мембранный потенциал v и 
    // переменная восстановления u
    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 {
    // Для доступа к значению v снаружи
    var V: Double { state.v }

    func stabilityCheck() -> Bool {
        isStable
    }
                
    mutating func updateState(
        _ s: SynapsesOut, constI: Double
    ) -> Bool {
	    // 1. Задаём временные шаги для расчёта v и u,
        // сохраняем начальное состояние
        var u_dt = GP.dt
        let (v0, u0) = state
        let vr = params.vr
        let vt = params.vt
        let v_dt = GP.dt
        
	    // 2. Рассчитываем обновление для v
        let dv = params.k * (v0 - vr) * (v0 - vt) - u0 + constI
        let enumerator = dv / params.C + s.giEi
        let v1 = (v0 + v_dt * enumerator) / (1 + v_dt * s.G)

		// 3. Проверяем на спайк и интерполируем,
        // рассчитывая шаг для u. Для этого строим прямую между
        // текущим и прошлым значениями v. Актуальное время 
        // спайка будет на пересечении кривой (t,v) прямой Vp.
        // Соответственно, обновлять нужно на шаг, равный
        // расстоянию между этой точкой и t0, а не
        // на фиксированное значение 1мс
        var isFire = false
        if v1 >= params.vP {
            u_dt = (params.vP - v0) / (v1 - v0)
            isFire = true
        }

		// 4. Обновляем переменную восстановления u
        let du = params.a * (params.b * (v0 - vr) - u0)
        let u1 = u0 + du * u_dt
        
	    // 5. Если был спайк, сбрасываем v и u, иначе
        // обновляем состояние как обычно
        if isFire {
            state = (v: params.c, u: u1 + params.d)
        } else {
            state = (v: v1, u: u1)
        }
        
	    // 6. Определяем достижение равновесия
        isStable = abs(dv) < GP.epsilon && !isFire 
        && abs(du) < GP.epsilon
        
        return isFire
    }
}

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

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

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

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

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

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

@MainActor final class Playground: ObservableObject {  
    private var neuron: Izhikevich  
    private let params: IzhikevichParams  
    @Published var Iconst: Double = 99  // Постоянный ток
    @Published var pulse: Double = 0 // Разовый импульс
    @Published private(set) var isRun: Bool = false  
    @Published private(set) var V: [ChartPoint] = []  
    @Published private(set) var spikes: [Int] = []  
    private var task: Task<Void, Never>?  

    func stop() { 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((0.0, 0.0), constI: await I()) {
                    spikes.append(spikes.count)
                }
                
                V.append(ChartPoint(v: neuron.V))
                if V.count > 1000 { V.removeFirst() }
                
                try? await Task.sleep(nanoseconds: 1_000_000)
            }
            isRun = false
        }
    }

    init() {  
	    // Начальные параметры для простого нейрона
        params = IzhikevichParams(
	        a: 0.01, b: 5, c: -60, d: 400, 
	        type: .excitatory, code: "RS", description: "Regular spiking", 
	        vP: 50, k: 3, C: 100, vr: -60, vt: -50
	        )
        neuron = Izhikevich(params)  
    }  
}

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

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

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

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

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

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

Рабочий пример здесь: https://github.com/dt0wer/snn-playgrounds

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

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

PS: Существуют другие нелинейные системы, принципиально схожие с моделью Ижикевича. Например, квадрат v^2 в f_v можно заменить на выражение с экспонентой. Существуют исследования, которые показывают более точное соответствие такой модели динамике возбуждения реального нейрона. Но здесь это несущественно.

PPS: Также применяются два способа кодирования: rate coding и temporal coding. В первом используется усреднённая интенсивность спайков, а во втором точные тайминги каждого спайка, то есть отдельные спайки различимы. Я использую второй.

Tags:
Hubs:
+8
Comments1

Articles