Pipelines#

A Pipeline wires functions and stream operators into a reusable computation you define once and run either on stored arrays or live, event by event. Run it over whole arrays in one pass, or feed it events one at a time as they arrive; both paths give identical results.

The example is a smoothed spread over two price feeds. The concepts behind the pipeline are in Pipelines.

import numpy as np
import matplotlib.pyplot as plt
from screamer import Input, Pipeline, RollingMean, Sub
from screamer import CombineLatest

Building the pipeline#

Input(name) creates a named source. Applying stream operators and functions to those nodes builds more nodes: CombineLatest aligns the two feeds into one two-column stream, Sub() takes their difference, and RollingMean(20) smooths it. Building the pipeline only records structure; nothing runs until you call it. Compiling it into a Pipeline names which nodes are inputs and which are outputs.

a, b = Input("a"), Input("b")

aligned = CombineLatest()(a, b)    # align the two feeds -> a node
spread  = Sub()(aligned)           # difference          -> a node
signal  = RollingMean(20)(spread)  # smooth              -> a node

pipe = Pipeline(inputs=[a, b], outputs=[signal])

Inspecting the pipeline#

print(pipe) shows the pipeline as an indented tree, rooted at each output and descending to the inputs, with each node's parameters in its label. This requires no optional dependencies, so it works in any environment. Each node also carries an id (#2); a node shared by several consumers is printed once and then referenced by that id.

print(pipe)
Pipeline(2 input(s), 1 output(s), align_outputs=True)
out[0] = RollingMean(window_size=20)  #4
   └─ Sub()  #3
      └─ CombineLatest(emit='when_all')  #2
         ├─ a  #0 (input)
         └─ b  #1 (input)

The same graph is available as a Graphviz DOT string via pipe.to_dot(), which you can feed to any DOT renderer.

print(pipe.to_dot())
digraph screamer_dag {
  rankdir=LR;
  node [fontname="monospace"];
  n0 [label="a", shape=ellipse, style=filled, fillcolor="#e8f0fe"];
  n1 [label="b", shape=ellipse, style=filled, fillcolor="#e8f0fe"];
  n2 [label="CombineLatest(emit='when_all')", shape=box, style="rounded,filled", fillcolor="#fff3e0"];
  n3 [label="Sub()", shape=box, style=filled, fillcolor="#f5f5f5"];
  n4 [label="RollingMean(window_size=20)", shape=box, style=filled, fillcolor="#f5f5f5", penwidth=2, color="#1a73e8"];
  n0 -> n2;
  n1 -> n2;
  n2 -> n3;
  n3 -> n4;
}

In a notebook, displaying the pipe object directly renders that diagram inline.

pipe.to_graphviz()
../_images/5d29369851161355b93b79b23e6ffbad7895d2addc7b745062b8143b797d59e5.svg

Running it: batch and live#

pipe(*feeds) runs the whole pipeline over stored arrays in one pass. Pass generators of (value, index) pairs instead of arrays to run event by event: pipe(gen_a, gen_b) returns an iterator of (value, index) events. Each feed is a (values, index) pair.

rng = np.random.default_rng(0)
n = 200
index = np.arange(1, n + 1, dtype=np.int64)
fa = (100 + np.cumsum(rng.standard_normal(n)), index)   # two feeds on the
fb = (100 + np.cumsum(rng.standard_normal(n)), index)   # same timeline

batch_v, batch_idx = pipe(fa, fb)          # whole arrays in

# Event by event via generators
gen_a = ((v, k) for v, k in zip(fa[0], fa[1]))
gen_b = ((v, k) for v, k in zip(fb[0], fb[1]))
events = list(pipe(gen_a, gen_b))
live_v   = np.array([e[0] for e in events])
live_idx = np.array([e[1] for e in events])

import pandas as pd
m = ~np.isnan(batch_v)
pd.DataFrame({"batch": batch_v[m][:5], "live": live_v[m][:5]},
             index=batch_idx[m][:5]).rename_axis("index")
batch live
index
20 -0.025896 -0.025896
21 -0.360765 -0.360765
22 -0.550543 -0.550543
23 -0.853429 -0.853429
24 -1.141419 -1.141419

Multiple outputs#

A pipeline can expose more than one output. Adding spread alongside signal gives back the raw difference and its smoothed version together, each as its own (values, index) pair. With align_outputs=True (the default), all outputs are co-indexed onto a shared sorted axis, so every pair has the same length and outputs can be compared sample for sample. Set align_outputs=False to get independent per-output streams whose lengths may differ.

dag2 = Pipeline(inputs=[a, b], outputs=[spread, signal])
(raw_v, raw_idx), (sig_v, sig_idx) = dag2(fa, fb)

fig, ax = plt.subplots(figsize=(9, 3.5))
ax.plot(raw_idx, raw_v, lw=0.6, color="0.7", label="spread (raw)")
ax.plot(sig_idx, sig_v, lw=1.6, color="crimson", label="RollingMean(20)")
ax.axhline(0, color="k", lw=0.4)
ax.set_xlabel("index")
ax.set_ylabel("a - b")
ax.legend(loc="upper left")
plt.tight_layout()
../_images/87fc9703b5e5f8501871c7708ad93df56e551b812f18b2f5462f161fb4ffdb56.png

Saving and reloading as JSON#

pipe.to_json() serializes the whole pipeline, input names, every node with its parameters, the outputs, and align_outputs, into readable JSON you can write to a config file. Pipeline.from_json(text) rebuilds a runnable pipeline. (to_dict / from_dict do the same with a plain dict.)

import json

config = pipe.to_json()
print(json.dumps(json.loads(config), indent=2))

restored = Pipeline.from_json(config)
restored_v, _ = restored(fa, fb)
{
  "screamer_dag": 1,
  "inputs": [
    "a",
    "b"
  ],
  "align_outputs": true,
  "nodes": [
    {
      "id": 0,
      "kind": "input",
      "name": "a"
    },
    {
      "id": 1,
      "kind": "input",
      "name": "b"
    },
    {
      "id": 2,
      "kind": "operator",
      "op": "CombineLatest",
      "params": {
        "emit": "when_all"
      },
      "in": [
        0,
        1
      ]
    },
    {
      "id": 3,
      "kind": "functor",
      "cls": "Sub",
      "params": {},
      "in": [
        2
      ]
    },
    {
      "id": 4,
      "kind": "functor",
      "cls": "RollingMean",
      "params": {
        "window_size": 20
      },
      "in": [
        3
      ]
    }
  ],
  "outputs": [
    4
  ]
}