Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ request.
- `index.qmd` — home
- `packages/index.qmd` — the ecosystem inventory (one filterable table)
- `approaches/` — the modelling approaches (composed distributions, composable
Turing models) and their shared overview
Turing models, composable recurrences) and their shared overview
- `tutorials/index.qmd` — a filterable listing of runnable tutorials, each
living in its own package's documentation
- `gallery.qmd`, `team.qmd`, `funding.qmd`, `get-involved.qmd`, `community.qmd`
Expand Down
1 change: 1 addition & 0 deletions _includes/navbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
<li><a class="dropdown-item" href="https://epiaware.org/approaches/index.html">Overview</a></li>
<li><a class="dropdown-item" href="https://epiaware.org/approaches/composed-distributions.html">Composed distributions</a></li>
<li><a class="dropdown-item" href="https://epiaware.org/approaches/composable-turing-models.html">Composable Turing models</a></li>
<li><a class="dropdown-item" href="https://epiaware.org/approaches/composable-recurrences.html">Composable recurrences</a></li>
</ul>
</li>
<li class="nav-item">
Expand Down
279 changes: 279 additions & 0 deletions approaches/composable-recurrences.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
---
title: "Composable recurrences"
description: "Build renewal processes, autoregressions and reporting delays from two fast, differentiable operators that compose."
toc: true
toc-location: left
page-layout: full
---

:::::: {.side-space}

::: {.what-is-turing-grid .approach-hero .section-start-space .section-end-space}

::: {.feature-text-column}

::: {.feature-item}
::: {.feature-item-header}

```{=html}
<span class="feature-icon icon-composable"></span>
```

##### Two operators, many models

:::

:::

::: {.feature-item}
::: {.feature-item-header}

```{=html}
<span class="feature-icon icon-uncertainty"></span>
```

##### Built for fast reverse-mode gradients

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion This hero heading claims the package is built for fast reverse-mode gradients, but the Status section says the native Mooncake and Enzyme adjoints are still in progress. The 'Plug-in adjoints' section also says the gain is still to be timed. Reword to what exists today, for example 'Differentiable by design', or 'Tested with four AD backends'. That matches the Status paragraph.


:::

:::

::: {.feature-item}
::: {.feature-item-header}

```{=html}
<span class="feature-icon icon-response"></span>
```

##### No epidemiology built in

:::

:::

:::

::: {.code-example-column}
::: {.code-window .box}
::: {.code-window-header}
::: {.code-window-dots}

```{=html}
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
```

:::

::: {.code-window-title}

```{=html}
<span>renewal_then_delay.jl</span>
```

:::

:::

```{.julia .code-overflow-scroll}
using ComposableRecurrences

# Generation interval, lag 1 first: weights on y[t-1], y[t-2], y[t-3]
g = [0.6, 0.3, 0.1]
# Two strata mixed by a contact matrix
K = [0.9 0.1; 0.2 0.8]
infections = Recurrence(g; coupling = K)

# Reporting delay, lag 0 first: P(delay = 0, 1, 2, 3)
reports = Convolution([0.0, 0.5, 0.3, 0.2])

# Operators are callable. R_t is the gain, one column per day
R = fill(1.1, 2, 10)
y = infections(R; history = ones(2, 3))
cases = reports(y)

# Stop, keep the state, and resume for a forecast
y1, state = infections(R[:, 1:5]; history = ones(2, 3),
return_state = true)
y2 = infections(R[:, 6:end]; history = state)
y ≈ hcat(y1, y2)
```

:::
:::

:::

The example runs on the main branch of [ComposableRecurrences.jl](https://github.com/EpiAware/ComposableRecurrences.jl).
The package is not registered yet, so install it from the repository to try it.

## The approach

Renewal processes, random walks, autoregressions and reporting delays share one structure.
Each step reads a window of past values, weights it by a kernel, and produces the next value.
This approach builds all of them from two operators rather than one bespoke loop per model.

A `Recurrence` feeds its output back into its own history.
At each step it convolves the last `L` values with its kernel, mixes strata through a coupling, scales by a gain, adds an input, and passes the result through a tuple of modifiers.
A renewal process is a `Recurrence` with the generation interval as kernel and `R_t` as gain.
A random walk is a `Recurrence([1.0])` with the noise as the added input.

A `Convolution` has no feedback.
It weights current and past inputs by a kernel, which covers reporting delays, moving averages and occupancy.

The package has no epidemiological assumptions.
It takes numbers in (kernels, gains, histories) and returns series out.
Modelling packages build their own named steps on top.

### Lag-first kernels, callable operators

Every kernel is written lag first, the way a generation interval or AR coefficients are written down.
A `Recurrence` kernel starts at lag 1 and a `Convolution` kernel at lag 0, so nothing is ever reversed by hand.
Operators are built once, outside the model, and called with the per-day inputs that are usually sampled.
Chaining is function composition, as in `reports(infections(R; history))`.

### Strata, time variation and coupling

Wrappers name the extra axis of a coefficient.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion The 1.75 to 15 times and 8 to 25 times speed-ups come from prototypes, and the page links to no benchmark or repository script for them. The Status section says the native adjoints are still only planned, so the 8 to 25 times figure describes code that is not in the package. Link the benchmark source, or cut the figures. If they stay, reword the second one to say it is a prototype result that is not yet reproducible from the package.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8ee3780. The prototype figures are cut. The section now links benchmark/gradients.jl on main and says only what it does, which is time plain AD gradients of the operators under Mooncake and Enzyme. It also no longer claims plain AD is already fast.

`PerStratum` gives each stratum its own kernel, `TimeVarying` lets a kernel or coupling change by day, and `Pairwise` gives each pair of strata its own lag kernel.
The coupling mixes strata before the gain, as a contact or mobility matrix does.
Couplings can be dense, sparse or diagonal matrices, and any real element type is accepted, so `Float32` and dual numbers pass through.

### Modifiers

Modifiers are applied in order after each step and carry their own state.
They handle what the linear core cannot, such as susceptible depletion or moving cases between strata.
A modifier is a plain type with an `apply!` method, so a modelling package can add its own.

### Plug-in adjoints

Reverse-mode AD is slow when each step copies its lag window into a new state.
The operators own a history buffer instead, which avoids that copy.
Native adjoints will plug in per operator, modifier or coupling by adding a method, with no AD declarations from the user.
`NoAdjoint(op)` turns an adjoint off, so its gain can be timed and the adjoint dropped if a backend catches up.
A [benchmark script](https://github.com/EpiAware/ComposableRecurrences.jl/blob/main/benchmark/gradients.jl) in the repository times plain AD gradients of the operators under Mooncake and Enzyme.

### How it fits with the other approaches

[Composable Turing models](composable-turing-models.qmd) will build on these operators.
Its renewal, delay and latent process steps become calls to `Recurrence` and `Convolution`, with the priors and observation models staying in the Turing layer.
The [composed distributions](composed-distributions.qmd) stack supplies the delay pmfs that become `Convolution` kernels.
The operators have no dependency on either, so they can also be used in other models.

## Package map

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion The package map takes about 90 lines of hand-placed SVG coordinates. Most of it repeats what packages/index.qmd already lists. Two edges carry the actual message: delay pmf to Convolution, and Recurrence/Convolution to ComposableTuringIDModels. Replace the diagram with a short list, or a much smaller SVG showing only those two edges. That removes about 40 lines of CSS and the new --epi-accent variable in both themes.


Solid arrows are data or code that will flow once the package is adopted.
The dashed grey arrow is a test-time link.
The dashed amber arrow is an optional future backend, not a dependency.

```{=html}
<figure class="cr-map" role="img" aria-labelledby="cr-map-title">
<svg viewBox="0 0 960 560" xmlns="http://www.w3.org/2000/svg">
<title id="cr-map-title">EpiAware packages around ComposableRecurrences</title>
<defs>
<marker id="cr-map-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="cr-ah" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="cr-map-am" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="cr-ah cr-muted" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="cr-map-aw" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="cr-ah cr-warn" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<!-- bands -->
<rect class="cr-band" x="12" y="14" width="936" height="150" rx="8"/>
<text class="cr-band-t" x="24" y="32">DISTRIBUTIONS · DELAY PMFS</text>
<rect class="cr-band" x="12" y="196" width="936" height="130" rx="8"/>
<text class="cr-band-t" x="24" y="214">OPERATORS · TOOLING</text>
<rect class="cr-band" x="12" y="400" width="936" height="146" rx="8"/>
<text class="cr-band-t" x="24" y="418">MODELS · BACKENDS</text>

<!-- distribution layer -->
<rect class="cr-node" x="36" y="48" width="196" height="96" rx="6"/>
<text x="50" y="72" font-size="14" font-weight="600">CensoredDistributions</text>
<text class="cr-sub" x="50" y="92">censored delay distributions</text>
<text class="cr-sub" x="50" y="108">discretised to a delay pmf</text>
<text class="cr-sub" x="50" y="124">lag 0 first</text>

<rect class="cr-node" x="280" y="48" width="208" height="96" rx="6"/>
<text x="294" y="72" font-size="14" font-weight="600">ConvolvedDistributions</text>
<text class="cr-sub" x="294" y="92">sums of delay distributions</text>
<text class="cr-sub" x="294" y="108">convolved series</text>
<text class="cr-sub" x="294" y="124">time-varying delays</text>

<rect class="cr-node" x="536" y="48" width="196" height="96" rx="6"/>
<text x="550" y="72" font-size="14" font-weight="600">Composed / Modified</text>
<text class="cr-sub" x="550" y="92">chained and modified delays</text>
<text class="cr-sub" x="550" y="108">discretise, then convolve</text>

<rect class="cr-node" x="772" y="48" width="160" height="96" rx="6"/>
<text x="786" y="72" font-size="13.5" font-weight="600">Lowered / Reparam.</text>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion The box title is 'Lowered / Reparam.', but its subtitle says 'DistributionsInference' and 'no touch point'. The title and subtitle name different packages, and the packages table lists them separately. Name one package, or split the box. If the point is that these packages do not touch ComposableRecurrences, drop the box. The diagram is already crowded.

<text class="cr-sub" x="786" y="92">DistributionsInference</text>
<text class="cr-sub" x="786" y="116">no touch point</text>

<!-- operator layer -->
<rect class="cr-node cr-focus" x="300" y="224" width="360" height="88" rx="6"/>
<text x="316" y="250" font-size="15" font-weight="700">ComposableRecurrences</text>
<text class="cr-sub" x="316" y="270">Recurrence · Convolution · TimeVarying · PerStratum · Pairwise</text>
<text class="cr-sub" x="316" y="286">in progress: Depletion · Imports · Redistribute · Clamp</text>
<text class="cr-sub" x="316" y="302">in progress: native Mooncake and Enzyme adjoints</text>

<rect class="cr-node" x="36" y="224" width="200" height="88" rx="6"/>
<text x="50" y="250" font-size="14" font-weight="600">EpiAwarePackageTools</text>
<text class="cr-sub" x="50" y="270">scaffold, Taskfile, CI</text>
<text class="cr-sub" x="50" y="286">AD test harness</text>
<text class="cr-sub" x="50" y="302">AD benchmark pages</text>

<rect class="cr-node" x="724" y="224" width="208" height="88" rx="6"/>
<text x="738" y="250" font-size="14" font-weight="600">EpiAwareADTools</text>
<text class="cr-sub" x="738" y="270">shared AD workarounds</text>
<text class="cr-sub" x="738" y="286">used where a backend needs one</text>
<text class="cr-sub" x="738" y="302">no dependency either way</text>

<!-- models and backends -->
<rect class="cr-node" x="160" y="434" width="300" height="96" rx="6"/>
<text x="174" y="458" font-size="14" font-weight="600">ComposableTuringIDModels</text>
<text class="cr-sub" x="174" y="478">renewal, susceptible depletion, imports</text>
<text class="cr-sub" x="174" y="494">mixing, latent delays, AR, MA, random walk</text>
<text class="cr-sub" x="174" y="510">own loops → Recurrence, Convolution</text>

<rect class="cr-node cr-opt" x="520" y="434" width="300" height="96" rx="6"/>
<text x="534" y="458" font-size="14" font-weight="600">Reactant</text>
<text class="cr-sub" x="534" y="478">compiled backend for the operators</text>
<text class="cr-sub" x="534" y="494">optional, not a dependency</text>
<text class="cr-sub" x="534" y="510">later extension</text>

<!-- edges -->
<path class="cr-e" d="M134,144 C134,190 330,190 360,222" marker-end="url(#cr-map-a)"/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion The edge from EpiAwareADTools to ComposableRecurrences is drawn with the dashed grey style, which the legend defines as a 'test-time link'. Its node text says 'no dependency either way'. It has no arrowhead and no label, so the drawing contradicts the node text. Delete this <path>, or label it with what it actually is. The 'tests' label on the EpiAwarePackageTools edge already covers the one real test-time link.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8ee3780. The unlabelled dashed edge from EpiAwareADTools is removed. The node text still says there is no dependency either way, and the legend now names the one test-time link.

<path class="cr-e" d="M384,146 L420,222" marker-end="url(#cr-map-a)"/>
<text class="cr-lbl" x="420" y="186">delay pmf → Convolution kernel</text>
<path class="cr-e cr-dash" d="M238,268 L298,268" marker-end="url(#cr-map-am)"/>
<text class="cr-lbl" x="242" y="258">tests</text>
<path class="cr-e" d="M400,314 L330,432" marker-end="url(#cr-map-a)"/>
<text class="cr-lbl" x="196" y="384">Recurrence, Convolution</text>
<path class="cr-e cr-warn" d="M560,314 L640,432" marker-end="url(#cr-map-aw)"/>
<text class="cr-lbl" x="624" y="384">optional backend</text>
</svg>
</figure>
```

The package has no runtime dependency on any other EpiAware package.
Today ComposableTuringIDModels runs its own renewal, delay and latent process loops.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion This section says ComposableTuringIDModels 'runs its own' loops, and the next sentence says moving to shared operators gives 'one adjoint to maintain'. The adjoint does not exist yet, and the port is not scheduled. Say 'would give' rather than 'gives', or cut the sentence. The Status section already covers the plan.

Moving them onto one set of operators gives one forward kernel and one adjoint to maintain and test.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion 'Tests check them against copies of the ComposableTuringIDModels steps' is a claim about a development branch that readers cannot inspect. The package is unreleased, and the code sample at the top uses using ComposableRecurrences as if it were installable. Add a short note under the sample that it is the intended API on the development branch and cannot yet be installed from the registry. That matches the 'no public API released yet' wording in packages/index.qmd.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8ee3780, with a changed premise. The core operators are now merged on ComposableRecurrences.jl main (PR #5), so the note under the hero says the example runs on main and the package is not yet registered. The use-case tests against the ComposableTuringIDModels copies exist on main but are tagged pending and skipped by default, so the Status section now says they are written but not yet switched on. The packages row is updated to match.

## Status

This approach is under development.
The core operators are merged on the main branch of ComposableRecurrences.jl.
These are `Recurrence`, `Convolution`, the `TimeVarying`, `PerStratum` and `Pairwise` wrappers, and the coupling and modifier interfaces.
The test suite differentiates them with ForwardDiff, ReverseDiff, Mooncake and Enzyme through plain AD.
Use-case tests against copies of the ComposableTuringIDModels steps they will replace are written but not yet switched on.

The built-in modifiers (`Depletion`, `Imports`, `Redistribute`, `Clamp`) and the native Mooncake and Enzyme adjoints are in progress.
A first registered release follows them, and then the port of ComposableTuringIDModels onto the operators.
An FFT method for long fixed kernels and a Reactant backend are later extensions.

## Where next

- The [ComposableRecurrences.jl repository](https://github.com/EpiAware/ComposableRecurrences.jl)
tracks development, and its documentation will follow the first release.
- Building a full model with priors and observations? See the
[composable Turing models approach](composable-turing-models.qmd).
- Need the delay distributions that feed a `Convolution`? See the
[composed distributions approach](composed-distributions.qmd).

::::::
9 changes: 8 additions & 1 deletion approaches/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ This is our first cut, and we would like to be told where it is wrong.
## The approaches

We are not sure yet which of these will work best, so we are trying more than one and keeping them interoperable.
Both work with the same underlying packages, and they can be combined in a single analysis.
They are designed to work together and to combine in a single analysis.

::: {.feature-grid}
::: {.feature-card}
Expand All @@ -61,6 +61,13 @@ Any piece can be swapped for another without rewriting the rest.

[Read the approach &rarr;](composable-turing-models.qmd)
:::
::: {.feature-card}
### Composable recurrences
Build renewal processes, autoregressions and reporting delays from two fast, differentiable operators, one that feeds back on its own history and one that does not.
They carry no epidemiology of their own, and composable Turing models are planned to run on them.

[Read the approach &rarr;](composable-recurrences.qmd)
:::
:::

More approaches may join these as the ecosystem grows.
8 changes: 8 additions & 0 deletions packages/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ Status reflects where a package sits in its release cycle rather than a judgemen
</td>
<td>Assemble infectious disease models from interchangeable infection and observation parts as a single Turing model. The package that ties the ecosystem together.</td>
</tr>
<tr data-category="modelling">
<td class="data-name">
<a href="https://github.com/EpiAware/ComposableRecurrences.jl">ComposableRecurrences.jl</a>
<a class="data-gh" href="https://github.com/EpiAware/ComposableRecurrences.jl" aria-label="ComposableRecurrences.jl on GitHub"><i class="bi bi-github"></i></a>
<span class="status-pill status-experimental">experimental</span>
</td>
<td>Fast, differentiable recurrences and causal convolutions, the renewal, autoregressive and delay steps inside a model. The core operators are on the main branch but not yet registered, and native adjoints are in progress.</td>
</tr>
<tr data-category="distributions">
<td class="data-name">
<a href="https://censoreddistributions.epiaware.org/">CensoredDistributions.jl</a>
Expand Down
38 changes: 38 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,44 @@ h1, h2, h3 { letter-spacing: -0.02em; }
}
.library-links a { font-weight: 600; font-size: 0.92rem; }

/* Package map on the composable recurrences page. Colours come from the
theme variables and currentColor so the diagram follows light and dark
mode. */
.cr-map {
overflow-x: auto;
border: 1px solid var(--border-color);
border-radius: 12px;
margin: 1rem 0 1.5rem;
}
.cr-map svg { display: block; min-width: 760px; width: 100%; height: auto; }
.cr-map text { font-family: inherit; fill: currentColor; }
.cr-map .cr-sub { fill-opacity: 0.68; font-size: 11px; }
.cr-map .cr-lbl { fill-opacity: 0.68; font-size: 10.5px; }
.cr-map .cr-node {
fill: none; stroke: var(--border-color); stroke-width: 1.2;
}
.cr-map .cr-node.cr-focus {
fill: var(--epi-primary); fill-opacity: 0.12;
stroke: var(--epi-primary); stroke-width: 1.6;
}
.cr-map .cr-band {
fill: none; stroke: var(--border-color); stroke-dasharray: 3 4;
}
.cr-map .cr-band-t {
fill-opacity: 0.6; font-size: 10.5px; letter-spacing: 0.08em;
}
.cr-map .cr-node.cr-opt {
stroke: var(--epi-accent); stroke-dasharray: 5 4;
}
.cr-map .cr-e { stroke: var(--epi-primary); stroke-width: 1.5; fill: none; }
.cr-map .cr-e.cr-dash {
stroke: currentColor; stroke-opacity: 0.5; stroke-dasharray: 5 4;
}
.cr-map .cr-e.cr-warn { stroke: var(--epi-accent); stroke-dasharray: 5 4; }
.cr-map .cr-ah { fill: var(--epi-primary); }
.cr-map .cr-ah.cr-muted { fill: currentColor; fill-opacity: 0.5; }
.cr-map .cr-ah.cr-warn { fill: var(--epi-accent); }

/* Value propositions + foundations grid ----------------------------------- */
.feature-grid {
display: grid;
Expand Down
1 change: 1 addition & 0 deletions theme-dark.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ $border-radius: 0.6rem;
--epi-primary-dark: #2a9a94;
--epi-primary-light: #4fc9c0;
--border-color: #26303b;
--epi-accent: #e8965a;
}

.navbar {
Expand Down
1 change: 1 addition & 0 deletions theme-light.scss
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ $border-radius: 0.6rem;
--epi-primary-dark: #0b5e6d;
--epi-primary-light: #1a9aab;
--border-color: #e6ebf0;
--epi-accent: #c46f2f;
}

.navbar {
Expand Down
Loading