EMA aftershock example

This example builds a Bayesian network for earthquake mainshock-aftershock analysis on the Eastern Massachusetts highway network. It combines regional seismicity, edge-level ground motion and damage, binary component survival, and a system-state classifier based on RSR reference sets.

The full mathematical model is described in EMA_aftershock_models.pdf. The EMA benchmark network and the hypothetical areal seismic source zone are shown below:

EMA highway network and seismic source zone

The main BN structure is:

Bayesian network for the EMA aftershock example

At a high level, the model has one mainshock and up to K_max aftershock slots. The mainshock has location L_0, magnitude M_0, and induced edge variables A_{0,n}, D_{0,n}, and X_{0,n}. The number of aftershocks K is sampled from M_0. Each aftershock slot t has distance R_t, angle V_t, location L_t, magnitude M_t, and the same edge-level sequence A_{t,n}, D_{t,n}, and X_{t,n}. The system state S_t is computed from all component states X_{t,n}.

The example workflow has three analysis steps:

  1. Define the BN variables and probability objects.

  2. Run unconditional forward sampling and summarise prior behaviour.

  3. Estimate P(S_t | X_{0,n}=0) by modifying a damage node P(D_{0,n} | A_{0,n}).

Step 1: define the model

The file examples/EMA_aftershock/s01_define_model.py assembles the BN. It provides helper functions to load the highway topology, load RSR reference sets, create variables, and attach each variable to its probability object.

The main inputs are:

  • data/nodes.json and data/edges.json for the EMA highway topology.

  • data/probs_eq.json for edge-level single-shock survival/failure data.

  • rsr_res/refs_up_*.pt and rsr_res/refs_low_*.pt for system-state classification.

The key construction functions are:

  • load_topology() reads nodes and edges and computes edge midpoints.

  • load_rsr_refs() loads the reference tensors used by S_t.

  • define_variables() creates names such as L_x_0, M_0, K, A_0_e0001, D_0_e0001, X_0_e0001, and S_0.

  • define_probs() creates the corresponding probability objects from l.py, m.py, k.py, r.py, v.py, a.py, d.py, x.py, and s.py.

The model separates L_x and L_y into different variables because the inference engine expects each probability object to own a unique child variable name. Mainshock location coordinates are independent uniforms, while aftershock coordinates are deterministic functions of L_0, R_t, and V_t.

  1"""
  2Assemble the BN for the EMA mainshock-aftershock example.
  3
  4The model includes:
  5
  6* Mainshock variables — L_0 (split into L_x_0, L_y_0), M_0, K and, for
  7  each edge n: A_{0,n}, D_{0,n}, X_{0,n}. The system state S_0.
  8* Aftershock slots ``t = 1, ..., K_max`` — R_t, V_t, L_x_t, L_y_t, M_t,
  9  and per-edge A_{t,n}, D_{t,n}, X_{t,n}, plus S_t.
 10
 11For tractability you can pass smaller ``K_max`` / ``edge_subset`` values;
 12the default reads the full EMA topology from ``./data/``.
 13"""
 14
 15import json
 16import os
 17import sys
 18from pathlib import Path
 19
 20import numpy as np
 21import torch
 22
 23BASE = Path(__file__).resolve().parent
 24sys.path.insert(0, str(BASE))
 25
 26REPO_ROOT = BASE.parent.parent
 27if str(REPO_ROOT) not in sys.path:
 28    sys.path.insert(0, str(REPO_ROOT))
 29
 30# Optional: add rsr repo if it lives next to tbnpy
 31RSR_REPO = REPO_ROOT.parent / "rsr"
 32if RSR_REPO.exists() and str(RSR_REPO) not in sys.path:
 33    sys.path.insert(0, str(RSR_REPO))
 34
 35import tbnpy
 36from tbnpy import variable
 37
 38try:
 39    from ndtools import fun_binary_graph as fbg  # type: ignore[import]
 40    from ndtools.graphs import build_graph        # type: ignore[import]
 41except ImportError:
 42    fbg = None
 43    build_graph = None
 44
 45import l as l_mod
 46import m as m_mod
 47import k as k_mod
 48import r as r_mod
 49import v as v_mod
 50import a as a_mod
 51import d as d_mod
 52import x as x_mod
 53import s as s_mod
 54
 55
 56DATA_DIR = BASE / "data"
 57RSR_DIR = BASE / "rsr_res"
 58
 59
 60def load_topology(edge_subset=None):
 61    """Load EMA nodes/edges and compute each edge's midpoint (x, y)."""
 62    with open(DATA_DIR / "nodes.json", "r") as f:
 63        nodes = json.load(f)
 64    with open(DATA_DIR / "edges.json", "r") as f:
 65        edges = json.load(f)
 66
 67    if edge_subset is not None:
 68        keep = set(edge_subset)
 69        edges = {k: v for k, v in edges.items() if k in keep}
 70
 71    midpoints = {}
 72    for e_id, e in edges.items():
 73        a = nodes[e["from"]]
 74        b = nodes[e["to"]]
 75        midpoints[e_id] = ((a["x"] + b["x"]) / 2.0, (a["y"] + b["y"]) / 2.0)
 76
 77    return nodes, edges, midpoints
 78
 79
 80def load_rsr_refs(max_st=2, device="cpu"):
 81    """Load the upper/lower reference tensors built for the EMA system."""
 82    refs_upper, refs_lower = {}, {}
 83    for s_st in range(1, max_st + 1):
 84        refs_upper[s_st] = torch.load(
 85            RSR_DIR / f"refs_up_{s_st}.pt", map_location=device
 86        )
 87        refs_lower[s_st] = torch.load(
 88            RSR_DIR / f"refs_low_{s_st - 1}.pt", map_location=device
 89        )
 90    return refs_upper, refs_lower
 91
 92
 93def make_s_fun(dests=None):
 94    """Build and return an ``s_fun`` callable for use with :class:`s_mod.S`.
 95
 96    Uses ``fbg.eval_population_accessibility`` (ndtools) to resolve component
 97    states that the RSR classifier leaves unknown.
 98    """
 99    assert fbg is not None and build_graph is not None, (
100        "ndtools is not importable; make sure the ndtools repo is on sys.path"
101    )
102    if dests is None:
103        dests = ["n22", "n66"]
104
105    with open(DATA_DIR / "nodes.json") as f:
106        nodes = json.load(f)
107    with open(DATA_DIR / "edges.json") as f:
108        edges = json.load(f)
109    with open(DATA_DIR / "probs_eq.json") as f:
110        probs_dict = json.load(f)
111
112    G_base = build_graph(nodes, edges, probs_dict)
113
114    def s_fun(comps_st):
115        conn_pop_ratio, sys_st, _ = fbg.eval_population_accessibility(
116            comps_st, G_base, dests,
117            avg_speed=60.0,
118            target_time_max=0.25,
119            target_pop_max=[0.95, 0.99],
120            length_attr="length_km",
121            population_attr="population",
122        )
123        return conn_pop_ratio, sys_st, None
124
125    return s_fun
126
127
128def region_from_nodes(nodes, margin_frac=0.20):
129    """Bounding box of node coordinates, expanded by ``margin_frac`` on each side.
130
131    ``margin_frac`` is relative to the span of the data in that axis, so a
132    value of 0.20 adds 20% of the x-range on the left and right, and 20%
133    of the y-range at the top and bottom.
134    """
135    xs = [n["x"] for n in nodes.values()]
136    ys = [n["y"] for n in nodes.values()]
137    x_min, x_max = min(xs), max(xs)
138    y_min, y_max = min(ys), max(ys)
139    dx = (x_max - x_min) * margin_frac
140    dy = (y_max - y_min) * margin_frac
141    return (x_min - dx, x_max + dx, y_min - dy, y_max + dy)
142
143
144def define_variables(edge_names, K_max=3, max_st=2):
145    """Create all tbnpy Variable instances.
146
147    System-state variables ``S_i`` take values ``{0, 1, ..., max_st}`` —
148    states are 0-indexed, matching the RSR ref-dict convention where
149    ``refs_up_{s}`` / ``refs_low_{s-1}`` describe the boundary between
150    ``S >= s`` and ``S <= s - 1``.
151    """
152    varis = {}
153    cont = lambda name: variable.Variable(name=name, values=(-torch.inf, torch.inf))
154
155    sys_states = list(range(max_st + 1))  # 0..max_st
156
157    # Mainshock
158    varis["L_x_0"] = cont("L_x_0")
159    varis["L_y_0"] = cont("L_y_0")
160    varis["M_0"] = cont("M_0")
161    varis["K"] = variable.Variable(name="K", values=list(range(K_max + 1)))
162
163    for n in edge_names:
164        varis[f"A_0_{n}"] = cont(f"A_0_{n}")
165        varis[f"D_0_{n}"] = cont(f"D_0_{n}")
166        varis[f"X_0_{n}"] = variable.Variable(name=f"X_0_{n}", values=[0, 1])
167    varis["S_0"] = variable.Variable(name="S_0", values=sys_states)
168
169    # Aftershock slots
170    for t in range(1, K_max + 1):
171        varis[f"R_{t}"] = cont(f"R_{t}")
172        varis[f"V_{t}"] = cont(f"V_{t}")
173        varis[f"L_x_{t}"] = cont(f"L_x_{t}")
174        varis[f"L_y_{t}"] = cont(f"L_y_{t}")
175        varis[f"M_{t}"] = cont(f"M_{t}")
176        for n in edge_names:
177            varis[f"A_{t}_{n}"] = cont(f"A_{t}_{n}")
178            varis[f"D_{t}_{n}"] = cont(f"D_{t}_{n}")
179            varis[f"X_{t}_{n}"] = variable.Variable(
180                name=f"X_{t}_{n}", values=[0, 1]
181            )
182        varis[f"S_{t}"] = variable.Variable(name=f"S_{t}", values=sys_states)
183
184    return varis
185
186
187def define_probs(varis, edges, midpoints, region,
188                 refs_upper, refs_lower, K_max=3,
189                 s_fun=None, device="cpu"):
190    """Create all probability objects keyed by their child variable name."""
191    edge_names = list(edges.keys())
192    probs = {}
193
194    # ---- Mainshock ----
195    x_min, x_max, y_min, y_max = region
196    probs["L_x_0"] = l_mod.L0(
197        childs=[varis["L_x_0"]], low=x_min, high=x_max, device=device,
198    )
199    probs["L_y_0"] = l_mod.L0(
200        childs=[varis["L_y_0"]], low=y_min, high=y_max, device=device,
201    )
202    probs["M_0"] = m_mod.M0(childs=[varis["M_0"]], device=device)
203    probs["K"] = k_mod.K(
204        childs=[varis["K"]], parents=[varis["M_0"]],
205        K_max=K_max, device=device,
206    )
207
208    for n in edge_names:
209        probs[f"A_0_{n}"] = a_mod.A(
210            childs=[varis[f"A_0_{n}"]],
211            parents=[varis["M_0"], varis["L_x_0"], varis["L_y_0"]],
212            edge_midpoint=midpoints[n], device=device,
213        )
214        probs[f"D_0_{n}"] = d_mod.D0(
215            childs=[varis[f"D_0_{n}"]],
216            parents=[varis[f"A_0_{n}"]], device=device,
217        )
218        probs[f"X_0_{n}"] = x_mod.X(
219            childs=[varis[f"X_0_{n}"]],
220            parents=[varis[f"D_0_{n}"]], device=device,
221        )
222
223    probs["S_0"] = s_mod.S(
224        childs=[varis["S_0"]],
225        parents=[varis[f"X_0_{n}"] for n in edge_names],
226        refs_dict_upper=refs_upper, refs_dict_lower=refs_lower,
227        row_names=edge_names, s_fun=s_fun, device=device,
228    )
229
230    # ---- Aftershocks ----
231    for t in range(1, K_max + 1):
232        probs[f"R_{t}"] = r_mod.R(
233            childs=[varis[f"R_{t}"]], parents=[varis["K"]],
234            slot_idx=t, device=device,
235        )
236        probs[f"V_{t}"] = v_mod.V(
237            childs=[varis[f"V_{t}"]], parents=[varis["K"]],
238            slot_idx=t, device=device,
239        )
240        probs[f"L_x_{t}"] = l_mod.Lt(
241            childs=[varis[f"L_x_{t}"]],
242            parents=[varis["L_x_0"], varis[f"R_{t}"], varis[f"V_{t}"]],
243            axis="x", device=device,
244        )
245        probs[f"L_y_{t}"] = l_mod.Lt(
246            childs=[varis[f"L_y_{t}"]],
247            parents=[varis["L_y_0"], varis[f"R_{t}"], varis[f"V_{t}"]],
248            axis="y", device=device,
249        )
250        probs[f"M_{t}"] = m_mod.Mt(
251            childs=[varis[f"M_{t}"]],
252            parents=[varis["M_0"], varis["K"]],
253            slot_idx=t, device=device,
254        )
255
256        for n in edge_names:
257            probs[f"A_{t}_{n}"] = a_mod.A(
258                childs=[varis[f"A_{t}_{n}"]],
259                parents=[varis[f"M_{t}"], varis[f"L_x_{t}"], varis[f"L_y_{t}"]],
260                edge_midpoint=midpoints[n], device=device,
261            )
262            probs[f"D_{t}_{n}"] = d_mod.Dt(
263                childs=[varis[f"D_{t}_{n}"]],
264                parents=[varis[f"A_{t}_{n}"], varis[f"D_{t - 1}_{n}"]],
265                device=device,
266            )
267            probs[f"X_{t}_{n}"] = x_mod.X(
268                childs=[varis[f"X_{t}_{n}"]],
269                parents=[varis[f"D_{t}_{n}"]], device=device,
270            )
271
272        probs[f"S_{t}"] = s_mod.S(
273            childs=[varis[f"S_{t}"]],
274            parents=[varis[f"X_{t}_{n}"] for n in edge_names],
275            refs_dict_upper=refs_upper, refs_dict_lower=refs_lower,
276            row_names=edge_names, s_fun=s_fun, device=device,
277        )
278
279    return probs
280
281
282if __name__ == "__main__":
283    device = "cuda" if os.environ.get("USE_CUDA", "0") == "1" else "cpu"
284    K_max = 2     # keep small for the smoke test
285    max_st = 2    # S in {0, 1, 2}; matches the rsr_res files
286
287    nodes, edges, midpoints = load_topology()
288    region = region_from_nodes(nodes)
289    refs_upper, refs_lower = load_rsr_refs(max_st=max_st, device=device)
290
291    varis = define_variables(list(edges.keys()), K_max=K_max, max_st=max_st)
292    probs = define_probs(varis, edges, midpoints, region,
293                         refs_upper, refs_lower, K_max=K_max, device=device)
294
295    print(f"Defined {len(varis)} variables and {len(probs)} probability nodes.")
296
297    # Each Variable and each probability object prints as one line. The model is
298    # large, so show the mainshock and the first aftershock slot, for one edge.
299    edge = next(iter(edges))
300    shown = ["L_x_0", "L_y_0", "M_0", "K",
301             f"A_0_{edge}", f"D_0_{edge}", f"X_0_{edge}", "S_0",
302             "R_1", "V_1", "L_x_1", "L_y_1", "M_1",
303             f"A_1_{edge}", f"D_1_{edge}", f"X_1_{edge}", "S_1"]
304    print()
305    for name in shown:
306        print(repr(varis[name]))
307    print()
308    for name in shown:
309        print(f"{name:<10} {probs[name]!r}")
310
311    # str() (or print) gives the longer reading: each variable's domain, and
312    # the system state's rule sets. A long list of parents is shortened.
313    print()
314    print(probs[f"D_1_{edge}"])
315    print()
316    print(probs["S_1"])
317
318    # Sample a few rows through the whole model to confirm that every object
319    # keeps tbnpy's protocol, before running inference on it.
320    print()
321    print(tbnpy.check_model(probs))

Inspecting and checking the model

Every probability class of this example inherits tbnpy.Probability and says what its distribution is in a form() method, so a variable or a probability object prints as one line. With K_max = 2, the model has 1,178 of each; running python s01_define_model.py prints the mainshock and the first aftershock slot for edge e0001:

Variable(name='L_x_0', values=(-inf, inf))
Variable(name='M_0', values=(-inf, inf))
Variable(name='K', values=[0, 1, 2])
Variable(name='X_0_e0001', values=[0, 1])
Variable(name='S_0', values=[0, 1, 2])
...
L_x_0      L0(P(L_x_0): uniform(low=-15.4647, high=92.7881))
L_y_0      L0(P(L_y_0): uniform(low=-15.088, high=90.528))
M_0        M0(P(M_0): truncated exponential(rate=0.76) on [6, 8.5])
K          K(P(K | M_0): truncated Poisson on 0..2, mean by modified Omori from M_0 (T=7, c=0.05, p=1.08, a=-1.67, b=0.91))
A_0_e0001  A(P(A_0_e0001 | M_0, L_x_0, L_y_0): lognormal(loc=Campbell-1997 mean of M_0 and the distance from (L_x_0, L_y_0) to (56.8678, 73.0335), scale=0.52); 0 if M_0 = 0)
D_0_e0001  D0(P(D_0_e0001 | A_0_e0001): lognormal(loc=-1.91 + 2.51 ln A_0_e0001, scale=0.7))
X_0_e0001  X(P(X_0_e0001 | D_0_e0001): X_0_e0001 = 1 if D_0_e0001 < 0.4, else 0)
S_0        S(P(S_0 | X_0_e0001, X_0_e0002, X_0_e0003, ..., X_0_e0129 (129)): system state 0..2 from RSR rules over 129 edges; unclassified samples get state -1)
R_1        R(P(R_1 | K): density r^-1.35 on [1, 50] if K >= 1, else 0)
V_1        V(P(V_1 | K): uniform(low=0, high=2 pi) if K >= 1, else 0)
L_x_1      Lt(P(L_x_1 | L_x_0, R_1, V_1): L_x_1 = L_x_0 + R_1 cos(V_1))
L_y_1      Lt(P(L_y_1 | L_y_0, R_1, V_1): L_y_1 = L_y_0 + R_1 sin(V_1))
M_1        Mt(P(M_1 | M_0, K): truncated exponential(rate=0.76) on [4.5, M_0 - 1.2] if K >= 1, else 0)
A_1_e0001  A(P(A_1_e0001 | M_1, L_x_1, L_y_1): lognormal(loc=Campbell-1997 mean of M_1 and the distance from (L_x_1, L_y_1) to (56.8678, 73.0335), scale=0.52); 0 if M_1 = 0)
D_1_e0001  Dt(P(D_1_e0001 | A_1_e0001, D_0_e0001): lognormal(loc=-1.65 + 0.71 ln A_1_e0001 + 0.19 ln D_0_e0001 - 0.33 ln A_1_e0001 ln D_0_e0001, scale=0.68) truncated below at D_0_e0001; D_0_e0001 if A_1_e0001 = 0)
X_1_e0001  X(P(X_1_e0001 | D_1_e0001): X_1_e0001 = 1 if D_1_e0001 < 0.4, else 0)
S_1        S(P(S_1 | X_1_e0001, X_1_e0002, X_1_e0003, ..., X_1_e0129 (129)): system state 0..2 from RSR rules over 129 edges; unclassified samples get state -1)

print gives one object at length. For the system state, the list of 129 parents is shortened, and the class adds how many RSR reference sets each level has:

P(S_1 | X_1_e0001, X_1_e0002, X_1_e0003, ..., X_1_e0129 (129))  [S]
  form    system state 0..2 from RSR rules over 129 edges; unclassified samples get state -1
  child   S_1        discrete, 3 states: 0, 1, 2
  parent  X_1_e0001  discrete, 2 states: 0, 1
  parent  X_1_e0002  discrete, 2 states: 0, 1
  ...
  parent  X_1_e0008  discrete, 2 states: 0, 1
  parent  ... 120 more (129 parents in all)
  parent  X_1_e0129  discrete, 2 states: 0, 1
  rules for S >= 1 vs S <= 0: 324 upper, 15 lower reference sets
  rules for S >= 2 vs S <= 1: 530 upper, 82 lower reference sets

Last, tbnpy.check_model samples a few rows through all 1,178 objects and confirms that each keeps tbnpy’s protocol:

Model check: 1178 probability objects, 1178 variables, 8 sampled rows: no problems found

The check earned its place on this model. It found that the aftershock damage nodes D_t returned a log density of +inf for two thirds of active aftershock rows: when the truncation bound \(\ln D_{t-1}\) lay more than about 14 standard deviations above the mean, erfc underflowed to zero in single precision. Forward samples were unaffected, but likelihood weighting and MCMC on those nodes were not. d.py now uses torch.special.log_ndtr, which stays finite in the tail.

Step 2: forward inference

The file examples/EMA_aftershock/s02_forward_inference.py performs prior forward inference with no evidence. It builds the full BN using s01_define_model.py and samples every variable:

filled = inference.sample(
    probs=probs,
    query_nodes=query_nodes,
    n_sample=n_sample,
    batch_size=50_000,
)

The batch_size argument controls how many samples are generated at once, so that a large number of samples can be generated without running out of memory.

The script writes two kinds of output:

  • results/forward_stats_Kmax{K}_maxst{M}_n{N}.csv with long-form summary statistics.

  • results/histograms/Kmax{K}_maxst{M}_n{N}/ with one histogram PNG per variable.

Representative prior histograms are shown below.

Prior histogram of mainshock magnitude M_0 Prior histogram of the number of aftershocks K Prior distribution of system state D_10_e0001 Prior distribution of component state X_10_e0001 Prior distribution of system state S_10

Run the script from the example directory:

cd examples/EMA_aftershock
python s02_forward_inference.py

Step 3: conditioning on component failure

The file examples/EMA_aftershock/s03_cal_s_x0.py estimates

\[P(S_t \mid X_{0,n}=0)\]

for each edge n and each time slice t. Here X_{0,n}=1 means that edge n survives the mainshock, so X_{0,n}=0 means the edge fails.

The conditioning is implemented by a small model surgery. Since X_{0,n} is a deterministic threshold of D_{0,n}, the script replaces the original probability object for D_{0,n} with DeltaD:

probs_mod[f"D_0_{edge_id}"] = DeltaD(
    childs=[varis[f"D_0_{edge_id}"]],
    parents=orig.parents,
    value=1.0,
    device=device,
)

This sets D_{0,n}=1.0 regardless of the values of its parent variables. Because 1.0 is above the damage threshold used by X_{0,n}, the modified BN represents the event X_{0,n}=0. Forward sampling under this modified model therefore gives Monte Carlo samples from P(S_t | X_{0,n}=0):

marg = estimate_S_marginals(
         probs_mod,
         varis,
         K_max,
         n_sample,
         max_st=max_st,
         batch_size=batch_size,
     )

where estimate_S_marginals() is a helper function that runs forward sampling using inference.sample with the updated probabilities probs_mod.

The surrogate node keeps the original parents so that ancestor ordering and downstream dependencies remain valid, but its sample and log_prob methods ignore those parent values.

The output is a long-form CSV:

results/S_given_X0_zero_Kmax{K}_maxst{M}_n{N}.csv

with columns:

  • edge

  • t

  • S

  • P(S_t=S | X_0_n=0)

  • cov, the coefficient of variation of the empirical probability estimate

Run the script from the example directory:

cd examples/EMA_aftershock
python s03_cal_s_x0.py

The __main__ block can be adjusted for larger or smaller sample budgets, GPU use, and a subset of target edges.

Computational cost scaling

The file examples/EMA_aftershock/s04_eval_performance.py benchmarks how the wall-clock cost of forward sampling changes as the number of Monte Carlo samples increases. By default, it samples all variables in the EMA BN, matching the forward-sampling setup used in s02_forward_inference.py.

Wall-clock sampling time as the number of samples increases

The cost generally increases with the number of samples, but the scaling benefit from vectorisation diminishes as the requested sample count reaches the internal batch_size. Beyond that point, the sampler splits the work into multiple batches, which adds overhead and can make the computation scale less smoothly.

Run the benchmark from the example directory:

cd examples/EMA_aftershock
python s04_eval_performance.py