
В этой статье я расскажу о собственных наработках, которые должны помочь связать признаки из разных слоев внутри одной сети. Как встроить возможность обобщения потока данных в саму механику модели, путем объединения показаний слоев на разных батчах. И о том, как адаптировать технологию для различных архитектур.
Как и всегда, вы имеет возможность изучить материал в разных режимах от простого развлечения до опробывания описанных принципов в своих проектах. Буду рад, если попробуйте и поделитесь результатами в комментариях.
Кроме того, я намеренно скрыл детальную информацию в спойлеры, чтобы не загромождать статью. Если вам интересны технические подробности, то вы знаете, что делать.
Код, предоставленный в спойлерах, это часть моего фреймфорка для экспериментов. Он является неполным и нужен лишь для лучшего понимания идеи. Относитесь к нему как к псевдокоду.
Условные обозначения:
В тексте я буду использовать скобки для представления размера тензоров. Например [B, H, T, P].
B (Batch) — размер батча;
H (Head) — количество голов внимания;
T (Token) — количество токенов в последовательности;
D (D_model) — ширина модели (длина эмбеддингов токенов);
P (Part) — размер партии для каждой головы внимания;
V (Vocab) — размер вокабуляра модели;
MHA (Multi-head attention) — мультиголовое внимание;
MHCA (Multi-head cross-attention) — перекрестное внимание;
FF (Feedforward) — прямой проход;
U (Units) — количество нейронов в полносвязной сети;
Введение и постановка задачи
Вы когда-нибудь задумывались о том, что принципиально отличает настоящую нервную систему от ее искусственных аналогов? Почему нейронные сети, имеющие входные и выходные каналы, вычислительные блоки и способность отключать и усиливать связи при обучении, не могут работать столь же эффективно как нервные клетки? Почему масштабирование модели не дает сопоставимого прироста качества? Не говоря уже об эмерджентных свойствах, которые теоретики пророчат для сложный моделей.
Выдвину ряд гипотез:
Во-первых, предположу, что дело в самом методе: сложность не рождает эффективность. Все системы, что социальные, что инженерные, испытывали проблемы с увеличением своего размера. Это приводило к кризисам, революциям и расколам. ИИ, как часть физической реальности, существующей по единым принципам, также подвержен всем характерным особенностям принципа организации.
Простой пример, если увеличить мозг крысы вдвое, она не станет гением, а, скорее всего, сойдет с ума от избыточного количества связей и нейронного шума.
Во-вторых, структура ИИ на уровне алгоритмов, не оставляет места для эволюции. Все работает на жестко завинченных гайках, с подстраиванием параметров, но без возможности изменения архитектуры. Отсутствие индивидуальности и соревновательного принципа, не оставляет шансов для спонтанного изменения свойств.
Агентность, конкуренция и обмен опытом — это механизмы, которые пока остаются закрытыми для моделей. Если такое и применяется, то пока редко и лишь в качестве прототипов.
В-третьих, это работа на локальном уровне, которая привязана к машинным механикам. Все особенности работы компьютера автоматически наследуются программами. Это означает, что любой алгоритм и модель будут зависимы от железа и его возможностей, а так же фундаментальных пределов.
Простой пример, мозг человека несоизмеримо более эффективен, чем процессор, который имеет КПД ~ 3%. С точки зрения тепловой энергии, это скорее электронагревательный прибор, чем вычислительная машина.
Еще одна особенность современных алгоритмов — линейность. И в отличии от вышеперечисленного, ее можно попытаться исправить только с помощью алгоритмов и без особых вложений.
В этой статье я начну с последнего: линейной структуры нейронных сетей, о том, почему это плохо и что же с этим делать.
Гиперсвязность
Давайте рассмотрим несколько распространенных схем без конкретики:

На схемах видна одна и та же особенность — линейность, будь то полносвязные сети, более сложные структуры с остаточными связями или даже трасформеры. Данные как бы протекают из конца в конец, бесповоротно двигаясь к выходному слою. Описанное можно сравнить со сломанным телефоном, где каждый слой учится на изолированных признаках предыдущего, имея ограниченный контекст без представления общей задачи.
Даже если мы используем ранние состояния слоев, как в Resnet, это не позволит им:
Получить глобальное представление о работе сети;
Передавать информацию обратно к предыдущим слоям;
Уточнять свои показания на основе сигналов будущих слоев.

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

Еще одно важное отличие машин: живые клетки не подчиняются строгим поступательным правилам и не привязаны к тактам. Да, в мозге тоже есть ритмы и структуры, но порядок их настолько сложен, что с точки зрения инженерии выглядит как хаос. Ведь кроме самих нейронов, существуют еще и вспомогательные клетки, а также огромное множество гормонов и нейромедиаторов, назначение которых не всегда понятно.
Я задал себе вопрос: возможно ли выйти за границы жестких ограничений и создать нечто более похожее на живой организм? Ни сколько архитектуру, а скорее универсальную парадигму для разных архитектур, где каждый нейрон или блок сам сможет решать с каким слоем он связан, не зависимо от их расположения; где информация может протекать нелинейно от всех элементов ко всем, даже из будущих слоев в прошлые, используясь повторно, как в цепях обратной связи (например в радио-усилителях). Даже если многие из таких связей будут излишни, обучение модели и ее последующая оптимизация расставят все на свои места. И не смотря на то, что мы всегда будем привязаны к тактам, поскольку этого требует и алгоритмы и железо, данная парадигма может дать преимущество, особенно в работе с данными, где важны ранние и промежуточные состояния.
Итак, далее представлена итоговая схема простой нейросети (поскольку на ее примере это показать проще всего), имеющая гиперсвязную структуру.
Важно сказать, что данный принцип можно адаптировать практически для любой архитектуры, что я и сделаю далее.

Идея вполне проста и прозрачна: все скрытые слои связаны между собой и могут получать сигналы, как из предшествующих слоев, так и из последующих. После, объединенные сигналы слоев могут быть поданы в выходной слой. Он получит доступ к ранним состояниям сети, которые очень важны для определенных задач, например в работе с изображениями.
На этом этапе должен возникнуть разумный вопрос: учитывая поступательную передачу данных, каким образом последние слои передают сигнал предшественникам еще до того, как сами его получили?
Ответ: на основе предварительного пропуска того же самого батча.
Подробности:
Первый пропуск данных (назовем его "Заряжающий") соберет все выходы слоев, на основе которых и будет запущен "Связующий" проход. Пропуск нужен лишь для получения самих показаний слоев.
Алгоритм:
Допустим мы имеем входной батч формы:
X0 [B, F].После последовательного пропуска батча, мы получаем выходы слоев формы:
X1 [B, U1], X2 [B, U2], ..., Xn [B, U3].Они понадобятся нам для следующего шага — пропуска с гиперсвязностью. Для этого мы заранее создаем сборщик состояний Outputs, реализованный в виде списка, который хранит выходы слоев и обновляет их по мере необходимости.
Outputs [X1, X2, ..., Xn]
При этом, на втором этапе наборы сигналов будут подменяться на соответствующие текущему слою.
Пример кода блока вызовов с гиперсвязностью для обычной полносвязной сети:
def call(self, X: np.ndarray, number_batch: int = None, training: bool = True) -> list[Tensor, ...]: """Последовательный вызов слоев сети""" # Подготовка входных данных: X = Tensor(X, requires_grad=False) # Цикл слоев сети: X_input = X for i in range(self.layers): # Работа слоев: X = self.gardener.prune(X, number_batch - 1) X = self.normalize(X, i) # Нормализация X = self.dense(X, i) # Слой X = X.leakyrelu(name=f"Activation N{i}") # Активация X = X.dropout(self.p_dropout, training=training) # Прореживание # Формирование связей: X = self.normalize(X, i + self.layers) self.outputs[i] = X # Обновление выхода # Цикл слоев сети с гиперсвязностью: X = X_input for i in range(self.layers): # Работа слоев: X = self.normalize(X, i) # Нормализация X = self.dense(X, i) # Слой X = X.leakyrelu(name=f"Activation N{i}") # Активация X = X.dropout(self.p_dropout, training=training) # Прореживание # Формирование связей: X = self.normalize(X, i + self.layers) self.outputs[i] = X # Обновление выхода # if self.masking and i < self.layers - 1: # Маскировка выхода с собственным номером # self.outputs[i + 1] = self.outputs[i + 1].masking(name='Self input masting') # Сбор данных с ограничением дальности: outputs = [ratio * out for ratio, out in zip(self.ratios[i], self.outputs)] X = self.gardener.sum(outputs, number_batch, name='Sum outputs') out = self.output(X) return out
Важно отметить, что нам не обязательно связывать все слои или блоки, если их очень много. Мы могли бы использовать сборщик, например, для каждого второго слоя. В любом случае, слои теперь имеют возможность искать полезные сигналы за пределами пространства признаков предыдущих.
Таким образом информация из последующих слоев все же будет присутствовать. И несмотря на то, что с архитектурной точки зрения она приходит из "будущего", в реальности выходы слоев сформированы на основе дополнительного пропуска данных.
Разумеется не все так просто. При такой схеме возникает множество подводных камней, особенно на обратном проходе. Поэтому далее речь пойдет о том, как предотвращается рекурсия и образование замкнутых петель в графе.
Узел Gardener и прунинг
Связь "всех со всеми" обязательно влечет за собой повторное использование одних и тех же слоев. Граф будет разрастаться с увеличением слоев/блоков.
Если Слой-5 передает информацию Слою-1, то на обратном проходе, градиент, дойдя до Слоя-1, снова вернется к Слою 5, через параллельные связи Outputs. После, ему нужно будет пройти "заряжающий проход" до самого конца.

Именно поэтому модель требует ручного разрыва графа в нужных участках.
Для такого разрыва я создал специальный класс Gardener, который нужен для остановки трансляции в предыдущую часть/версию графа. Его узлы, словно светофоры, не дают пройти градиенту в нежелательные участки, сравнивая номер версии текущего узла с номером корневого узла из которого стартовал градиент (Loss). Если они не соответствуют, градиент обрывается.
Подробности:
Технически это происходит в специальных узлах класса Gardener во время топологической сортировки. Если узел относится к предыдущему проходу, то он попросту не попадет в очередь.
Конкретно мой класс имеет несколько методов с возможностью разрыва:
Sum. Метод суммирования, который складывает выходы с обучаемыми коэффициентами, если позволяет их форма. Коэффициенты нужны для создания степени свободы между выходами, поскольку они будут агрегированы перед подачей в следующий слой.
Этот метод требует идентичной формы тензоров у всех слоев/блоков, поэтому хорошо подходит для трансформеров.Concat. Метод конкатенации, который соединяет выходы слоев из конца в конец. После, нам потребуется отдельная обучаемая матрица, чтобы привести выходы к стандартной форме.
Данный метод существенно увеличивает количество параметров сети. Однако, его преимущество в работе с последовательностями разной длины. Поэтому он подходит для полносвязных сетей.Prune. Это сквозной узел, который не влияет на данные на прямом проходе, но может остановить трансляцию на обратном, если заметит несоответствие версии узлов.
Узел для слияния выходов Sum наиболее эффективен, поскольку он практически не требует дополнительных параметров, кроме коэффициентов, представленных всего одним числом для каждого слоя. Для такой реализации потребуется обучаемая матрица формы: [Num_layers, Num_layers, 1]
Еще одни важный момент: Заряжающий и Связующий проходы получают данные разного типа.
Получает исключительно выходы предыдущих слоев;
Получаем данные всех слоев, и предыдущих и последующих.

Это может стать проблемой, поскольку одни и те же обучаемые матрицы в слоях получат разные по смыслу градиенты. Поэтому мы должны, с одной стороны, сохранить связи между выходами слоев, даже если они получены из Заряжающего прохода, а с другой не допустить связей от слоя к слою на backward Заряжающего прохода.

Вот почему так важно правильно расставлять узлы Gardener. Если сигнал приходит из устаревшей части графа, на основе которой работает модель вначале, то градиент обязан пройти по нему. При этом, стоит ограничить межслойную передачу предварительного прохода.
Подробности:
Любой слой, получивший на вход Outputs [X1, X2, ..., Xn], передаст градиент ровно один раз каждому слою в сети. Если ветвь находится в новой связующей части графа, то он передаст градиент предыдущему слою.
Таким образом градиент будет передан каждой обучаемой матрице N раз, где N — это количество слоев/блоков в модели. Никаких рекурсивных передач и замкнутых петель не образуется. Я проверил это на практике.
Итого, граф вычислений будет работать следующим образом:

Обратная связь
Итак, для чего же все это нужно?
Все дело в ограничениях стандартных архитектур, где каждый элемент работает строго с данными предыдущего. При этом, обратная связь между слоями отсутствует. Конечные слои не могут обмениваться информацией с начальными напрямую. Они строят свои показания исключительно на данных из предыдущих слоев. Фактически, каждый раз перед ними предстает новое пространство признаков. А значит они пребывают в своем локальном "мире" и не могут хотя бы частично разнообразить представление о глобальной задаче всей сети.
Сравните это с нейронными цепями, где нейроны/ансамбли умеют возбуждать и тормозить активность у своих соседей или целых цепей. Получать сигналы из дальних областей, не ограничиваясь расстоянием. Передавать выходы конечных цепей в начальные, реализуя биологическую обратную связь.
Некоторые цепочки даже специализируются на этом, например те, что связаны со зрением. Они собирают информацию из сетчатки, обрабатывают и передают обратно на вход, тем самым уточняя сигналы и связывая их во времени.
Поэтому сама возможность таких связей уже может помочь сократить разницу между реальными и искусственными сетями. Цель гиперсвязности: дать саму возможность искусственным нейронам их выстраивать. Даже если малая часть информации будет полезна, это уже шаг вперед.
Межбатчевая связь
Я упомянул вскользь, что некоторые цепочки специализируются на связывании информации во времени. Давайте разберем эту тему подробно, а также, как можно адаптировать технологию гиперсвязности для этой задачи.
В обычных сетях батчи данных и экземпляры внутри обрабатываются в параллельном режиме. Это ускоряет работу, но также приводит к раздробленности. Давайте представим следующую ситуацию: мы получаем изображения объектов с охранной камеры, оснащенной примитивным ИИ. Он обрабатывает поступающие изображения в реальном времени, классифицирует их и выдает предупреждающий сигнал. При этом он не способен рассматривать их, как часть чего-то общего. Много ли даст такая модель, учитывая, что именно возможность связывать кадры воедино, рождает большую часть знаний о происходящих событиях?
Интересно, что в природе встречаются крайне редкое заболевание — акинетопсия. Такие пациенты воспринимают мир в виде отдельных кадров. Они не могут рассчитывать траекторию, воспринимать мимику, видеть быстрые объекты. Другими словами все то, что связано с обобщением потока зрительной информации. Мир для них — это калейдоскоп из отдельных элементов.
Так и современные сети (тут оговорюсь, что речь идет о голых архитектурах, а не комплексных ИИ) испытывают сложность с обобщением параллельно поступающей информации. Например, чистые трансформеры не могут работать с позициями токенов. Сверточные сети чувствительны к кадрам, которые не должны находится слишком близко на временном ряду. Исключением, пожалуй, являются рекуррентные модели, где связь между предыдущим и последующим сигналом устанавливается с помощью механизма памяти.
Но что если информацию о связи можно заложить в саму механику модели?
Вспомним предыдущий слайд с изображением графа с Заряжающим и Связующим проходами.

На изображении видно, что граф теперь связывает батчи между собой с помощью объекта Outputs, который всегда на старте будет иметь в своем составе выходы слоев из предыдущего батча. А значит новый батч свяжется с показаниями слоев на предыдущем. Исключением будет лишь первый запуск, где ячейки для последующих слоев в Outputs будут заполнены заглушками.
Также в такой концепции нет отдельных предварительных проходов. Теперь все проходы являются "Связующими".
Подробности:
Алгоритм такой:
Модель получает данные на вход. Первый слой выдает показания и записывает их в Outputs, который имеет следующий вид:
Outputs[X1, P2, ... Pn], P — это тензор-заглушка правильной формы с параметром requirest_grad = False;Постепенно, проходя слой за слоем, Outputs насыщается выходами.
Outputs[X1, X2, ... Xn];В конце сети, выходы подаются в выходной слой.
Рассчитывается loss. Происходит обратный проход. При этом градиенты могут передаваться ветвям устаревшего графа, собранного на предыдущем батче, если те связаны с текущим. Каждый слой получит ровно N градиентов, где N — это количество слоев в модели.
Также во время составления топологической очереди, происходит обрыв и зачистка более ранних ветвей графа в местах узлов Gardener. Если узел нашел несоответствие своего номера батча и номера корня (текущего), то он оборвет связь с родительскими тензорами и поменяет статус на requirest_grad = False.
Расчет нового батча начнется на основании предыдущих показаний слоев:
[X1, X'2, ... X'n], X' — это показания слоев на основе предыдущего батча.Постепенно, проходя слой за слоем, Outputs насыщается текущими выходами слоев:
Outputs [X1, X'2, ... X'n] -> Outputs [X1, X2, ... X'n] -> Outputs [X1, X2, ... Xn];
Реализация энкодера трансформера с межбатчевой связью:
def encoder(self, X: Tensor, number_batch: int = None, training: bool = False) -> Tensor: """Выделение признаков входящих данных""" # super().encoder(X, training) p = self.p_enc # Цикл слоев: for i in range(self.encoder_blocks): Xl = self.normalize(X, i, 0) # Нормализация 1 Xl = self.MHA(Xl, i) # Блок внимания X = X + Xl.dropout(p=p, training=training) # Прореживание + Residual Xl = self.normalize(X, i, 1) # Нормализация 2 Xl = self.feedforward(Xl, i) # Прямой проход X = X + Xl.dropout(p=p, training=training) # Прореживание + Residual # Обновление выхода: self.outputs_encoder[i] = X # Формирование связей: outputs = [ratio * out for ratio, out in zip(self.ratios_encoder[i], self.outputs_encoder)] X = self.gardener.sum(outputs, number_batch, name='Sum Encoder') return X
Важно отметить, что в теории мы можем регулировать глубину связей в графе. Если задать условия обрыва Gardener как loss.number_batch > node.number_batch - deep, то градиенты могут связать несколько предыдущих состояний графа. То есть, свяжутся не только текущий и предыдущий батчи, а целая цепочка прошлых батчей.
Это вовсе не универсальный метод, но он может пригодиться в специфических случаях, например в работе с временными рядами, где поступающая информация носит упорядоченный характер.

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

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

Учитывая, что торможение, в отличии от усталости, это тоже активный процесс, нейроны могут останавливать работу целых цепей намеренно, что сильно расширяет их возможности.
Давайте реализуем и такой механизм!
Подробности:
Допустим мы имеем обычную сеть, передающую сигналы линейно. Как и до этого, мы можем собирать и обновлять выходы слоев посредством сборщика Outputs, Однако теперь он будет использоваться для расчета подавляющей матрицы или Gate (ворота). Очень похожий механизм реализован в продвинутых рекуррентных ячейках, GRU и LSTM, для контроля памяти.
Алгоритм работы:
Выходы слоев собираются посредством сборщика и обновляются после прохождения каждого слоя:
Outputs [X1, X'2, ... X'n] -> Outputs [X1, X2, ... X'n] -> Outputs [X1, X2, ... Xn];Данные агрегируют перед расчетом Gate (например Concat);
Полученная матрица умножается на специальную матрицу весов для возможности обучения:
Gate_logits = Sum(Outputs) x WG;Функция активации Sigmoid нормирует матрицу в диапазоне [0, 1], что дает ей возможность ослаблять выходы слоев при умножении;
После прохождения слоя/блока, итоговые показания проходят Gate. Для этого проводится поэлементное умножение экземпляров с броадкастом по оси батчей:
X [B, U] * Gate [U, U] = XG [B, U].
Моя реализация блока вызовов для полносвязной сети:
def call(self, X: np.ndarray, number_batch: int = None, training: bool = True) -> list[Tensor, ...]: """Последовательный вызов слоев сети""" # Подготовка входных данных: X = Tensor(X, requires_grad=False) # Цикл слоев сети: for i in range(self.layers): # Работа слоев: X = self.dense(X, i) # Слой X = self.normalize(X, i) # Нормализация X = X.leakyrelu(name=f"Activation N{i}") # Активация X = X.dropout(self.p_dropout, training=training) # Прореживание # Торможение: G = self.merge(self.outputs, number_batch) I = self.inhibitor(G, i) I = I.sigmoid(name=f"Activation Ingibitor N{i}") # Sigmoid X = X * I # Формирование связей: self.outputs[i] = X # Обновление выхода # if self.masking and i < self.layers - 1: # Маскировка выхода с собственным номером # self.outputs[i + 1] = self.outputs[i + 1].masking(name='Self input masting') out = self.output(X) return out
Теперь модель имеет возможность ослаблять выходы на основе информации из других слоев. А так же с помощью отдельной обучаемой матрицы для каждого слоя. Она ни только позволяет привести форму тензора к стандартной, но и делает механизм торможения обучаемым.
В теории, торможение должно дать дополнительную гибкость подстройки сети в контексте ее глобальной задачи.
Заключение
Мои собственные эксперименты с трансформером показали ускорение сходимости на простых тестах вроде sliding_sum и pointer_index.
Важно отметить, что я учитывал "Заряжающий" проход как еще один батч, поэтому дал двухкратную фору классической модели по общему количеству пропускаемых батчей. С другой стороны, повторение одного и того же батча (вместо получения нового) для Заряжающего и Связующего проходов, делает модель более экономной в плане "пожирания" информации.
К сожалению у меня нет оборудования, чтобы изучить принцип на более сложной модели, чем трансформер с десятью блоками. Поэтому буду рад, если кто-то опробует описанное в своих проектах и отпишется о результатах прямо здесь в комментариях.
Спасибо за интерес к моей статье!
Изображения подготовлены в программах Krita и GoogleSheets. Часть фонов для слайдов сгенерирована с помощью ИИ.
