Created
February 28, 2026 15:17
-
-
Save FrankRuns/c4757dbd95da878ad2fc138ef4d4961b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Supply Chain Network Inference from Inventory Snapshots | |
| ======================================================== | |
| Companion code for "Chain Reactions Are Delayed" — Affective Analytics | |
| https://frankcorrigan.substack.com | |
| Reproduces the three-method comparison from the article: | |
| 1. Correlation | |
| 2. Lag Correlation | |
| 3. Convergent Cross-Mapping (NIPS paper method) | |
| Paper: Singhal B, Kiss IZ, Li J-S. | |
| "Network Inference with Partial State measurements using forced-delay embedding." | |
| PNAS Nexus, Vol. 5, Issue 1, January 2026. | |
| Requirements: numpy, scipy, matplotlib | |
| pip install numpy scipy matplotlib | |
| """ | |
| import numpy as np | |
| import matplotlib.pyplot as plt | |
| import matplotlib.patches as mpatches | |
| from scipy.stats import pearsonr | |
| # ── RANDOM SEED ─────────────────────────────────────────────────────────────── | |
| np.random.seed(42) | |
| rng = np.random.default_rng(42) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 1. GROUND TRUTH NETWORK | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| NODE_NAMES = ["Supplier", "DC1", "DC2", "Store1", "Store2", "Store3"] | |
| N = len(NODE_NAMES) | |
| # True directed edges: (source, destination, lead_time_days) | |
| TRUE_EDGES = [ | |
| {"from": 0, "to": 1, "lag": 7}, # Supplier → DC1, 7 days | |
| {"from": 0, "to": 2, "lag": 5}, # Supplier → DC2, 5 days | |
| {"from": 1, "to": 3, "lag": 3}, # DC1 → Store1, 3 days | |
| {"from": 1, "to": 4, "lag": 4}, # DC1 → Store2, 4 days | |
| {"from": 2, "to": 5, "lag": 2}, # DC2 → Store3, 2 days | |
| ] | |
| TRUE_SET = set((e["from"], e["to"]) for e in TRUE_EDGES) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 2. SIMULATE INVENTORY | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def simulate_inventory(T=200, seed=42): | |
| """ | |
| Event-driven, mass-conserving inventory simulation. | |
| Each node holds inventory. Downstream nodes consume (demand). | |
| When a node drops below its reorder point, the upstream node | |
| ships a replenishment that arrives after the true lead time. | |
| The supplier is replenished from outside when it runs low. | |
| Returns: inv array of shape (N, T) | |
| """ | |
| rng = np.random.default_rng(seed) | |
| BASE_DEMAND = [0, 6, 5, 3, 3, 3] # units consumed per day | |
| REORDER_POINT = [150, 60, 60, 30, 30, 30] | |
| ORDER_QTY = [100, 50, 50, 20, 20, 20] | |
| SUPPLIER_REORDER = 150 | |
| inv = np.zeros((N, T)) | |
| inv[:, 0] = [200, 100, 100, 50, 50, 50] | |
| # In-transit pipeline queues: one array per edge of length = lag | |
| # pipelines[i][t] = units arriving at destination in t more days | |
| pipes = {(e["from"], e["to"], e["lag"]): np.zeros(e["lag"]) for e in TRUE_EDGES} | |
| for t in range(1, T): | |
| inflows = np.zeros(N) | |
| for (s, d, lag), pipe in pipes.items(): | |
| inflows[d] += pipe[-1] | |
| demand = np.array([ | |
| max(0, BASE_DEMAND[i] + rng.normal(0, 1.2)) | |
| for i in range(N) | |
| ]) | |
| ext = SUPPLIER_REORDER + rng.normal(0, 8) if inv[0, t-1] < SUPPLIER_REORDER else 0 | |
| for i in range(N): | |
| inv[i, t] = max(5, inv[i, t-1] - demand[i] + inflows[i] + (ext if i == 0 else 0)) | |
| for (s, d, lag), pipe in pipes.items(): | |
| pipes[(s, d, lag)] = np.roll(pipe, 1) | |
| pipes[(s, d, lag)][0] = 0 | |
| if inv[d, t] < REORDER_POINT[d] and inv[s, t] > REORDER_POINT[s] * 0.5: | |
| qty = max(5, min( | |
| ORDER_QTY[d] + rng.normal(0, 3), | |
| inv[s, t] - REORDER_POINT[s] * 0.4 | |
| )) | |
| inv[s, t] -= qty | |
| pipes[(s, d, lag)][0] = qty | |
| return inv | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 3. INFERENCE METHODS | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def method_correlation(inv, threshold=0.25): | |
| """ | |
| Method 1: Pearson correlation between all pairs of inventory time series. | |
| Direction is ambiguous — all pairs above threshold treated as bidirectional, | |
| then deduplicated by keeping i→j if corr(i,j) > corr(j,i). | |
| """ | |
| edges = [] | |
| scores = np.zeros((N, N)) | |
| for i in range(N): | |
| for j in range(N): | |
| if i == j: | |
| continue | |
| r, _ = pearsonr(inv[i], inv[j]) | |
| scores[i, j] = abs(r) | |
| for i in range(N): | |
| for j in range(N): | |
| if i != j and scores[i, j] > threshold and scores[i, j] >= scores[j, i]: | |
| edges.append({"from": i, "to": j, "weight": round(scores[i, j], 3)}) | |
| return edges | |
| def method_lag_correlation(inv, max_lag=15, threshold=0.30): | |
| """ | |
| Method 2: Cross-correlation with lag sweep. | |
| For each ordered pair (i, j), find the lag where correlation peaks. | |
| If lag > 0 and i→j is stronger than j→i, infer i drives j. | |
| """ | |
| best_r = np.zeros((N, N)) | |
| best_lag = np.zeros((N, N), dtype=int) | |
| for i in range(N): | |
| for j in range(N): | |
| if i == j: | |
| continue | |
| for lag in range(1, max_lag + 1): | |
| r, _ = pearsonr(inv[i, :-lag], inv[j, lag:]) | |
| if abs(r) > abs(best_r[i, j]): | |
| best_r[i, j] = r | |
| best_lag[i, j] = lag | |
| edges = [] | |
| for i in range(N): | |
| for j in range(N): | |
| if i == j: | |
| continue | |
| if abs(best_r[i, j]) > threshold and abs(best_r[i, j]) > abs(best_r[j, i]): | |
| edges.append({ | |
| "from": i, "to": j, | |
| "lag": int(best_lag[i, j]), | |
| "weight": round(float(abs(best_r[i, j])), 3) | |
| }) | |
| return edges | |
| def delay_embed(x, E, tau): | |
| """ | |
| Construct Takens delay-embedding matrix. | |
| Row t: [x(t), x(t - tau), x(t - 2*tau), ..., x(t - (E-1)*tau)] | |
| Shape: (len(x) - (E-1)*tau, E) | |
| """ | |
| start = (E - 1) * tau | |
| return np.array([ | |
| [x[t - e * tau] for e in range(E)] | |
| for t in range(start, len(x)) | |
| ]) | |
| def ccm_score(x, y, E=3, tau=1): | |
| """ | |
| Convergent Cross-Mapping: does X drive Y? | |
| Tests whether Y's shadow manifold (delay embedding of y) can | |
| reconstruct X's states via nearest-neighbor prediction. | |
| High skill → X drives Y. | |
| Uses full library (single score, not convergence test). | |
| For the convergence test, call with increasing lib_size subsets. | |
| """ | |
| My = delay_embed(y, E, tau) | |
| n = len(My) | |
| x_aligned = x[(E - 1) * tau : (E - 1) * tau + n] | |
| x_pred = [] | |
| x_true = [] | |
| for i in range(n): | |
| dists = np.sqrt(((My - My[i]) ** 2).sum(axis=1)) | |
| dists[i] = np.inf # exclude self | |
| nn_idx = np.argsort(dists)[:E + 1] | |
| nn_dists = dists[nn_idx] | |
| if nn_dists[0] == 0: | |
| w = np.zeros(E + 1); w[0] = 1.0 | |
| else: | |
| w = np.exp(-nn_dists / nn_dists[0]) | |
| w /= w.sum() | |
| x_pred.append((w * x_aligned[nn_idx]).sum()) | |
| x_true.append(x_aligned[i]) | |
| r, _ = pearsonr(x_true, x_pred) | |
| return r | |
| def method_ccm(inv, max_lag=12, threshold=0.35, E=3): | |
| """ | |
| Method 3: Convergent Cross-Mapping with forced lag (NIPS paper approach). | |
| For each ordered pair (i, j) and each candidate lag, embed j's time series | |
| at tau = candidate_lag. Test if j's manifold can reconstruct i. | |
| High CCM score at a specific lag → i drives j with that lead time. | |
| Note: CCM was designed for smooth continuous dynamical systems. | |
| Threshold-triggered inventory data (event-driven, short time series) | |
| is a domain mismatch — see article for full discussion. | |
| """ | |
| best_score = np.zeros((N, N)) | |
| best_lag = np.zeros((N, N), dtype=int) | |
| for i in range(N): | |
| for j in range(N): | |
| if i == j: | |
| continue | |
| for lag in range(1, max_lag + 1): | |
| score = ccm_score(inv[i], inv[j], E=E, tau=lag) | |
| if score > best_score[i, j]: | |
| best_score[i, j] = score | |
| best_lag[i, j] = lag | |
| edges = [] | |
| for i in range(N): | |
| for j in range(N): | |
| if i == j: | |
| continue | |
| if best_score[i, j] > threshold and best_score[i, j] > best_score[j, i]: | |
| edges.append({ | |
| "from": i, "to": j, | |
| "lag": int(best_lag[i, j]), | |
| "weight": round(float(best_score[i, j]), 3) | |
| }) | |
| return edges | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 4. SCORING | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def score(edges, label): | |
| found = set((e["from"], e["to"]) for e in edges) | |
| tp = len(found & TRUE_SET) | |
| fp = len(found - TRUE_SET) | |
| fn = len(TRUE_SET - found) | |
| precision = tp / (tp + fp) if (tp + fp) > 0 else 0 | |
| recall = tp / (tp + fn) if (tp + fn) > 0 else 0 | |
| print(f" {label:<30} edges={len(edges):2d} TP={tp} FP={fp} FN={fn}" | |
| f" Precision={precision:.0%} Recall={recall:.0%}") | |
| return {"label": label, "edges": edges, "tp": tp, "fp": fp, "fn": fn, | |
| "precision": precision, "recall": recall} | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 5. VISUALIZATION | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Node positions for network diagrams | |
| NODE_POS = { | |
| 0: (0.5, 0.90), # Supplier | |
| 1: (0.22, 0.50), # DC1 | |
| 2: (0.78, 0.50), # DC2 | |
| 3: (0.08, 0.10), # Store1 | |
| 4: (0.38, 0.10), # Store2 | |
| 5: (0.78, 0.10), # Store3 | |
| } | |
| NODE_COLORS_PLOT = ["#222", "#444", "#444", "#999", "#999", "#999"] | |
| def draw_network(ax, edges, edge_color, title, stats_str, highlight_true=True): | |
| ax.set_xlim(-0.05, 1.05) | |
| ax.set_ylim(-0.05, 1.05) | |
| ax.set_aspect("equal") | |
| ax.axis("off") | |
| ax.set_title(title, fontsize=9, fontfamily="monospace", pad=6) | |
| for e in edges: | |
| s, d = NODE_POS[e["from"]], NODE_POS[e["to"]] | |
| is_true = (e["from"], e["to"]) in TRUE_SET | |
| color = "#2a7a4b" if (is_true and highlight_true) else edge_color | |
| alpha = 0.85 if is_true else 0.20 | |
| lw = 1.8 if is_true else 0.8 | |
| ax.annotate( | |
| "", xy=d, xytext=s, | |
| arrowprops=dict( | |
| arrowstyle="-|>", color=color, | |
| lw=lw, alpha=alpha, | |
| connectionstyle="arc3,rad=0.08", | |
| mutation_scale=10 | |
| ) | |
| ) | |
| for i, (x, y) in NODE_POS.items(): | |
| ax.plot(x, y, "o", color=NODE_COLORS_PLOT[i], markersize=10, zorder=5) | |
| ax.text(x, y - 0.10, NODE_NAMES[i], ha="center", va="top", | |
| fontsize=7, fontfamily="monospace", color="#555") | |
| ax.text(0.5, -0.04, stats_str, ha="center", va="top", | |
| fontsize=7.5, fontfamily="monospace", color="#888", | |
| transform=ax.transAxes) | |
| def plot_inventory(inv, T_plot=60): | |
| colors = ["#111", "#b84a2e", "#2563a8", "#b8872e", "#7a2e8a", "#2a7a4b"] | |
| fig, ax = plt.subplots(figsize=(9, 3.2)) | |
| for i in range(N): | |
| ax.plot(inv[i, :T_plot], color=colors[i], lw=1.8 if i == 0 else 1.2, | |
| alpha=1 if i == 0 else 0.7, label=NODE_NAMES[i]) | |
| ax.set_xlabel("Day", fontfamily="monospace", fontsize=9) | |
| ax.set_ylabel("Inventory", fontfamily="monospace", fontsize=9) | |
| ax.set_title("What we observe — inventory snapshots (60 days)", | |
| fontfamily="monospace", fontsize=10) | |
| ax.legend(fontsize=8, loc="upper right") | |
| ax.spines[["top", "right"]].set_visible(False) | |
| ax.tick_params(labelsize=8) | |
| fig.tight_layout() | |
| return fig | |
| def plot_networks(results): | |
| configs = [ | |
| ("Ground Truth", TRUE_EDGES, "#111", False, | |
| "5 edges | Precision: 100% | Recall: 100%"), | |
| (results[0]["label"], results[0]["edges"], "#b84a2e", True, | |
| f"{len(results[0]['edges'])} edges | " | |
| f"Precision: {results[0]['precision']:.0%} | Recall: {results[0]['recall']:.0%}"), | |
| (results[1]["label"], results[1]["edges"], "#2563a8", True, | |
| f"{len(results[1]['edges'])} edges | " | |
| f"Precision: {results[1]['precision']:.0%} | Recall: {results[1]['recall']:.0%}"), | |
| (results[2]["label"], results[2]["edges"], "#2a7a4b", True, | |
| f"{len(results[2]['edges'])} edges | " | |
| f"Precision: {results[2]['precision']:.0%} | Recall: {results[2]['recall']:.0%}"), | |
| ] | |
| fig, axes = plt.subplots(1, 4, figsize=(14, 4)) | |
| fig.suptitle("Network Recovery — Side by Side", | |
| fontfamily="monospace", fontsize=11, y=1.01) | |
| for ax, (title, edges, color, ht, stats) in zip(axes, configs): | |
| draw_network(ax, edges, color, title, stats, highlight_true=ht) | |
| # Legend for true edges | |
| true_patch = mpatches.Patch(color="#2a7a4b", label="True edge") | |
| false_patch = mpatches.Patch(color="#bbb", label="False positive") | |
| fig.legend(handles=[true_patch, false_patch], loc="lower center", | |
| ncol=2, fontsize=8, frameon=False, bbox_to_anchor=(0.5, -0.06)) | |
| fig.tight_layout() | |
| return fig | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # 6. MAIN | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| if __name__ == "__main__": | |
| print("=" * 60) | |
| print("SUPPLY CHAIN NETWORK INFERENCE") | |
| print("Chain Reactions Are Delayed — Affective Analytics") | |
| print("=" * 60) | |
| # ── Simulate ────────────────────────────────────────────────── | |
| print("\n[1] Simulating inventory...") | |
| inv = simulate_inventory(T=200) | |
| for i, name in enumerate(NODE_NAMES): | |
| print(f" {name:<10} mean={inv[i].mean():.1f} " | |
| f"std={inv[i].std():.1f} " | |
| f"min={inv[i].min():.1f} " | |
| f"max={inv[i].max():.1f}") | |
| # ── Run inference ────────────────────────────────────────────── | |
| print("\n[2] Running inference methods...") | |
| true_edge_str = [(NODE_NAMES[e["from"]], NODE_NAMES[e["to"]], f"lag={e['lag']}") for e in TRUE_EDGES] | |
| print(f" True edges: {true_edge_str}\n") | |
| corr_edges = method_correlation(inv) | |
| lag_edges = method_lag_correlation(inv) | |
| print(" Running CCM (this takes ~30 seconds)...") | |
| ccm_edges = method_ccm(inv) | |
| # ── Score ────────────────────────────────────────────────────── | |
| print("\n[3] Results:") | |
| results = [ | |
| score(corr_edges, "Correlation"), | |
| score(lag_edges, "Lag Correlation"), | |
| score(ccm_edges, "Convergent Cross-Mapping"), | |
| ] | |
| # ── Plot ─────────────────────────────────────────────────────── | |
| print("\n[4] Generating plots...") | |
| fig_inv = plot_inventory(inv) | |
| fig_inv.savefig("inventory_snapshots.png", dpi=150, bbox_inches="tight") | |
| print(" Saved: inventory_snapshots.png") | |
| fig_net = plot_networks(results) | |
| fig_net.savefig("network_recovery.png", dpi=150, bbox_inches="tight") | |
| print(" Saved: network_recovery.png") | |
| plt.show() | |
| print("\nDone.") | |
| print("Read the article: https://frankcorrigan.substack.com") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment