Как получить FRP из прямых ациклических графов?

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

Настройка

У меня есть направленный ациклический граф (DAG) с несколькими входами и выходами, подумайте об искусственной нейронной сети:

Directed acyclic graph

Общим способом обработки такой структуры является обработка всей сети на каждом (временном) этапе. Я считаю, что это метод, используемый библиотеками frp, например netwire.

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

Пример

В изображении выше 5, 7 и 3 находятся входы, 11 и 8 являются "скрытыми", а 2, 9 и 10 - выходными узлами. Изменение на node 5 повлияет только на node 11 и фактически на узлы 2, 9 и 10. Мне не нужно обрабатывать узлы 7, 3 и 8.

Цель

Обработайте такой тип сети с минимальной задержкой. Размер графиков, вероятно, достигнет до 100 тыс. Узлов, при этом выполняется умеренное количество вычислений за node.

План

Я надеюсь, что кто-то активизирует и рекламирует библиотеку X, которая просто выполняет свою работу.

В противном случае мой текущий план - вывести вычисление на вход node из описания графика. Вероятно, я буду использовать монаду Par, чтобы мне самому не приходилось иметь дело с зависимостями данных и до сих пор получаю выгоду от многоядерных машин.

Вопросы

  • Есть ли там библиотека, которая просто делает то, что мне нужно?
  • Является ли мой план Par выполнимым? Сколько это зависит от объема обработки, необходимой в каждом node?

Ответ 1

Такие проблемы обычно кодируются как абстракция Applicative или Arrow. Я обсужу только Applicative. Класс типа Applicative, найденный в Control.Applicative, позволяет предоставлять значения и функции через pure и функции, которые будут применяться к значениям с помощью <*>.

class Functor f => Applicative f where
    -- | Lift a value.
    pure :: a -> f a

    -- | Sequential application.
    (<*>) :: f (a -> b) -> f a -> f b

Ваш примерный граф может быть очень просто закодирован для Applicative (заменяя каждый node добавлением) как

example1 :: (Applicative f, Num a) => f a -> f a -> f a -> f (a, a, a)
example1 five seven three = 
    let
        eleven = pure (+) <*> five   <*> seven
        eight  = pure (+) <*> seven  <*> three
        two    = pure id  <*> eleven
        nine   = pure (+) <*> eleven <*> eight
        ten    = pure (+) <*> eleven <*> three
    in
        pure (,,) <*> two <*> nine <*> ten

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

Есть три оптимизации, которые вы можете пожелать, которые не могут быть реализованы для сети, закодированной, используя только Applicative. Общая стратегия заключается в кодировании проблемы с точки зрения Applicative и нескольких дополнительных классов, необходимых для оптимизации или дополнительных функций. Затем вы предоставляете один или несколько интерпретаторов, которые реализуют необходимые классы. Это позволяет отделить проблему от реализации, позволяя вам написать собственный интерпретатор или использовать существующую библиотеку, например reactive, reactive-banana, или mvc-updates. Я не собираюсь обсуждать, как писать эти интерпретаторы или адаптировать представленное здесь представление к конкретному интерпретатору. Я собираюсь обсудить общее представление программы, которое необходимо для того, чтобы интерпретатор мог использовать эти оптимизации. Все три библиотеки, с которыми я связан, могут избежать перекомпоновки значений и могут вместить следующие оптимизации.

Наблюдаемый общий доступ

В предыдущем примере промежуточный node eleven определяется только один раз, но используется в трех разных местах. Реализация Applicative не сможет увидеть через ссылочную прозрачность, чтобы увидеть, что эти три eleven все одинаковы. Вы можете предположить, что реализация использует некоторую магию IO, чтобы заглянуть через ссылочную прозрачность или определить сеть, чтобы реализация могла видеть, что вычисление повторно используется.

Ниже приведен класс Applicative Functor, где результат вычисления можно разделить и повторно использовать в нескольких вычислениях. Этот класс не определен в библиотеке в любом месте, о котором я знаю.

class Applicative f => Divisible f where
    (</>) :: f a -> (f a -> f b) -> f b

infixl 2 </>

Ваш пример может быть очень просто закодирован для Divisible Functor как

example2 :: (Divisible f, Num a) => f a -> f a -> f a -> f (a, a, a)
example2 five seven three = 
    pure (+) <*> five   <*> seven </> \eleven ->
    pure (+) <*> seven  <*> three </> \eight  ->
    pure id  <*> eleven           </> \two    ->
    pure (+) <*> eleven <*> eight </> \nine   ->
    pure (+) <*> eleven <*> three </> \ten    ->
    pure (,,) <*> two <*> nine <*> ten

Суммы и абелевы группы

Типичный нейрон вычисляет взвешенную сумму своих входов и применяет функцию ответа к этой сумме. Для нейрона с большой степенью суммирования всех его входов занимает много времени. Легкая оптимизация для обновления суммы - это вычесть старое значение и добавить новое значение. Это использует три свойства добавления:

Обратный - a * b * b⁻¹ = a Вычитание является инверсией добавления числа, этот обратный позволяет удалить ранее добавленное число из общего числа

Коммутативность - a * b = b * a Сложение и вычитание достигают того же результата независимо от того, в каком порядке они выполняются. Это позволяет нам достичь того же результата, когда мы вычтем старое значение и добавим новое значение в итого, даже если старое значение не было последним добавленным значением.

Ассоциативность - a * (b * c) = (a * b) * c Сложение и вычитание могут выполняться в произвольных группировках и до сих пор достичь того же результата. Это позволяет нам достичь того же результата, когда мы вычитаем старое значение и добавим новое значение к общей сумме, даже если старое значение было добавлено где-то посередине добавлений.

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

data Abelian a = Abelian {
    id  :: a,
    inv :: a -> a,
    op  :: a -> a -> a
}

Это класс структур, которые могут содержать абелевы группы

class Total f where 
    total :: Abelian a -> [f a] -> f a

Аналогичная оптимизация возможна для построения карт.

Порог и равенство

Другой типичной операцией в нейронных сетях является сравнение значения с порогом и определение ответа полностью на основе того, прошло ли значение порогового значения. Если обновление ввода не меняет, какая сторона порога имеет значение, ответ не изменяется. Если ответ не изменился, нет никаких причин для пересчета всех нижележащих узлов. Возможность обнаруживать, что не было изменения порога Bool, или ответ равен равенству. Ниже приведен класс структур, которые могут использовать равенство. stabilize предоставляет экземпляр Eq для базовой структуры.

class Stabilizes f where
    stabilize :: Eq a => f a -> f a