среда, 3 февраля 2010 г.

Монада моделирования

После начала изучения хаскеля прошедшей осенью придумал простую монаду, которая позволяет моделировать сложные динамические системы, заданные с помощью дифференциальных уравнений, где производная берется по времени. Сначала идея была реализована на хаскеле, и мне особенно нравилось, что благодаря ленивости языка можно было записывать дифференциальные уравнения в произвольном порядке так, как это принято в математике и в специальных моделирующих программах типа Vensim и ithink, а также написанных мною Simtegra MapSys и MapSim. Последний является самодостаточным движком предыдущего.

Но с этой реализацией идеи возникла проблема, которую я пока не знаю как красиво разрешить в хаскеле. Моделируемая динамическая система на самом деле может быть стохастической, т.е. недетерминированной. Каждый новый прогон модели может порождать новые результаты. То есть, запускающая имитацию функция не является чистой. Тут на помощь пришел язык F#, который я начал изучать чуть позже. В нем уже достаточно легко сочетать функциональный и императивный подходы. В итоге это привело к любопытному результату, оформленному в виде библиотеки на F# с рабочим названием Aivika, которую я здесь и попытаюсь описать.

Сначала приведу пример простой динамической системы, записанной на языке MapSim:

A = integ (-F, 100);
B = integ (F - G, 0);
C = integ (G, 0);

F = ka * A;
G = kb * B;

ka = 1;
kb = 1;

Здесь функция integ задает интеграл. Ее первым параметром является производная по времени, а вторым – начальное значение. То есть, A(t) является интегралом (еще говорят резервуаром), производная которого равна dA/dt = –ka*A(t). В начальный момент времени значение A(t0) равно 100. Это задача Коши.

Такая система может быть интегрирована методами Эйлера и Рунге-Кутта второго и четвертого порядков. Задается начальное время starttime, конечное stoptime и основной шаг интегрирования dt. Затем временная шкала разбивается на узлы: starttime, starttime + dt, starttime + 2*dt, …, stoptime. В случае методов Рунге-Кутта создаются еще дополнительные внутренние под-узлы. В итоге каждый узел сетки интегрирования однозначно задается парой целых чисел: номером итерации и номером фазы. Метод Эйлера имеет всего одну фазу. Метод Рунге-Кутта второго порядка – две фазы. Метод четвертого порядка – четыре фазы.

type Iteration = int
type Phase = int
type Method = Euler | RungeKutta2 | RungeKutta4

Здесь и далее я буду использовать F# для записи кода.

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

type Randomness = SimpleRnd | StrongRnd

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

type Specs = {
    spcStartTime: float; spcStopTime: float; spcDT: float;
    spcMethod: Method; spcRandomness: Randomness
}

Для нашего примера мы могли бы взять следующие параметры: начальное время – 0, конечное время – 10, основной шаг интегрирования – 0,1, метод – Рунге-Кутта четвертого порядка, а режим генератора – «сильная» псевдо-случайность, хотя последний никак не используется в этой модели.

let specs = {
    spcStartTime=0.0; spcStopTime=10.0; spcDT=0.1;
    spcMethod=RungeKutta4; spcRandomness=StrongRnd
}

Саму систему мы можем начать описывать с констант ka и kb.

let ka = 1.0
let kb = 1.0

Затем объявляем интегралы и задаем для них начальные значения.

let a = Integ 100.0
let b = Integ 0.0
let c = Integ 0.0

Каждый из трех интегралов имеет свойства Value, Inflow и Outflow. Эти свойства связаны дифференциальными уравнениями, одно из которых я покажу на примере интеграла a:

d/dt (a.Value) = (a.Inflow – a.Outflow)

Такие же уравнения имеют место для других интегралов. Свойство Value возвращает текущее значение интеграла, и оно доступно только по чтению. Свойства Inflow и Outflow можно менять. Они оба вместе задают производную. По-умолчанию, они представляют нулевые значения. То есть, если мы не зададим ни одно из свойств Inflow и Outflow, то производная будет равна нулю, а интеграл окажется константой.

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

let f = ka * a.Value
let g = kb * b.Value

И завершаем описание задачи собственно самими дифференциальными уравнениями:

a.Inflow <- -f
b.Inflow <- f – g
c.Inflow <- g

Удивительно, но это все – что нам нужно, чтобы немедленно запросить библиотеку промоделировать, скажем, переменную a и вернуть результат:

> runDynamics a.Value specs;;
val it : float [] =
  [|100.0; 90.48375; 81.87309014; 74.0818422; 67.03202889; 60.65309344;
    54.88119344; 49.65856187; 44.93292897; 40.65699912; 36.78797744;
    33.28714154; 30.11945393; 27.2532114; 24.65972767; 22.31304633;
    20.18968106; 18.26838054; 16.52991577; 14.95688766; 13.53355284;
    12.24566612; 11.08033792; 10.02590526; 9.071815051; 8.208518451;
    7.427375314; 6.720567711; 6.081021686; 5.50233646; 4.978720367;...|]

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

> runDynamics Dynamics.ofArray [| time; a.Value; b.Value; c.Value |] specs;;
[|[|0.0; 100.0; 0.0; 0.0|];
  [|0.1; 90.48375; 9.048333333; 0.4679166667|];
  [|0.2; 81.87309014; 16.37454263; 1.752367234|];
  [|0.3; 74.0818422; 22.22445032; 3.693707481|];
  [|0.4; 67.03202889; 26.81268809; 6.155283021|];
  [|0.5; 60.65309344; 30.32640707; 9.020499487|]; ...|]

Поразительная краткость, не правда ли?

Все это стало возможным благодаря использованию монады моделирования, которая является вариацией стандартной монады Reader. Еще меня натолкнула на ее написание замечательная книга «The Haskell School of Expression» автора Paul Hudak.

Итак, мы моделируем некоторый динамический процесс, который в каждый момент времени возвращает некоторое значение типа 'a. Обозначим этот полиморфный тип как Dynamics<'a>.

[]
type Dynamics<'a> (iter: Iterator<'a>) =
    member d.Iterator = iter

let iterator (p: Dynamics<'a>) = p.Iterator

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

type Iterator<'a> = Run -> Specs -> Iteration -> Phase -> 'a

То есть, итератор – это просто функция. Здесь появился новый тип Run, c которым связан недетерминизм и не только. В упрощенном виде (после удаления некоторых оптимизаций) этот тип выглядит так.

type Run () =
    let mutable disposed = false
    let disposedEvent = new Event<_>()
    member x.Disposed = disposedEvent.Publish
    member x.IsDisposed = disposed
    interface IDisposable with
        member x.Dispose() =
            if not disposed then
                disposedEvent.Trigger()
                disposed <- true

Когда мы запускаем имитацию, мы создаем уникальный экземпляр типа Run. Затем во время интегрирования мы передаем это значение в итератор динамического процесса. Ориентируясь на такое значение, любая функция может создать некоторые внутренние данные, которые будут актуальны, пока существует значение типа Run. Например, это может быть таблица кеширования предыдущих значений для интеграла внутри объекта типа Integ. Когда имитация заканчивается, значение типа Run разрушается, но предварительно вызывается метод Dispose, который посылает сигнал Disposed. Одна из внутренних функций интеграла или уровнем ниже ловит этот сигнал, и разрушает таблицу кеширования. Так достигается следующее.

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

Сам запуск динамического процесса очень прост. Его вызов уже использовался в примерах выше.

let runDynamics d specs = [|

    use r = new Run ()
   
    let f1 = iterator d r specs
    let f2 = fun n -> f1 n 0
   
    for i = 0 to (Specs.iterations specs - 1) do yield f2 i
|]

Здесь функция iterations возвращает общее число итераций по заданным спекам.  На выходе нас интересуют значения динамического процесса в основных узлах сетки интегрирования – поэтому запрашивается нулевая фаза. Ниже будет приведено определение функции integ, на примере которого можно понять то, как работают фазы.

Собственно, все. Дальше монада моделирования определяется просто. Ее функции return и bind почти такие же, как и в случае монады Reader.

[]
type DynamicsBuilder() =
    member d.Return (a) =
        new Dynamics<'a> (fun r s n ph -> a)
    member d.Bind (m, k) =
        new Dynamics<'b> (fun r s n ph ->
            let a = iterator m r s n ph
            let m' = k a
            iterator m' r s n ph)

Тогда мы можем определить свой workflow. Назовем его dynamics.  Он позволяет использовать синтаксический сахар в F#, похожий на нотацию do из хаскеля, где let! будет эквивалентен стрелке.

let dynamics = new DynamicsBuilder()

Более того, мы можем определить арифметические операции и основные функции типа синуса в соответствии с образцом:

type Dynamics<'a> with
    static member (+) (a: Dynamics, b: Dynamics) =
        Dynamics.lift2 (+) a b

Здесь функция lift2 достаточно идиоматична. Она берет некоторую функцию и два значения, обернутые в монаду. Значения извлекаются, к ним применяется функция, и результат снова заворачивается в монаду.

module Dynamics =
    let lift2 f m1 m2 = dynamics {
        let! a1 = m1
        let! a2 = m2
        return f a1 a2}

Но я использовал версию оптимальнее, которая уже учитывает внутреннее устройство монады:

module Dynamics =
    let lift2 f m1 m2 = Dynamics<_> (fun r s n ph ->
        let a1 = iterator m1 r s n ph
        let a2 = iterator m2 r s n ph
        in f a1 a2)

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

module Dynamics =
    val memo: Dynamics<'a> -> Dynamics<'a>

Здесь для краткости приведу лишь функцию integ – аналог одноименной функции MapSim, причем только для интегрирования по методу Рунге-Кутта второго порядка. Для других методов определены аналогичные внутренние функции integEuler и integRK4.

    let integ (f: Dynamics) (i: Dynamics) =
       
        // integEuler and integRK4
       
        let integRK2 (y: Dynamics<_>) (f: Dynamics<_>) (i: Dynamics<_>) r s =
            let vy n = iterator y r s n 0
            let vi n = iterator i r s n 0
            let k1 n = iterator f r s n 0
            let k2 n = iterator f r s n 1
            fun n ph ->
                match n, ph with
                    | 0, 0 -> vi 0
                    | n, 0 -> vy (n-1) + s.spcDT/2.0 * (k1 (n-1) + k2 (n-1))
                    | n, 1 -> vy n + s.spcDT * k1 n
                    | _ -> failwithf "integRK2: incorrect phase = %i" ph
       
        let rec y = Dynamics.memo z
        and z = Dynamics<_> (fun r s ->
            match s.spcMethod with
                | Euler -> integEuler y f i r s
                | RungeKutta2 -> integRK2 y f i r s
                | RungeKutta4 -> integRK4 y f i r s)
        in y

Но одной функции integ недостаточно в F#, потому что нужно разруливать как-то рекурсивные связи. Поэтому придумал трюк с отдельным типом Integ и тремя его свойствами Value, Inflow и Outflow. Сам тип Integ использует внутри функцию integ.

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

В заключение несколько слов о скорости имитации. Она ужасна. Мои тесты показали, что библиотека Aivika медленнее MapSim в десятки или сотни раз, хотя и там, и там – .NET. И автор один и тот же J.

Теперь вот думаю, а не написать ли мне подобное на Common Lisp – там некоторые вещи можно сделать быстрее за счет более умной кодогенерации.

Комментариев нет:

Отправить комментарий