Credit Contagion Networks — Graph Neural Networks for Default Risk:

Corporate default risk is not independent across firms. Supply-chain linkages, shared creditors and sector clustering mean distress propagates through a network: a healthy-looking firm can be dragged down when a key supplier or a co-financed peer fails. This project encodes that structure explicitly. It builds a firm-level graph — nodes are companies with accounting features (leverage, coverage, profitability, size, liquidity, sector), edges are supplier/customer links and common-creditor / ownership overlaps — draws a 12-month credit-distress label whose hazard depends on a firm’s own books and on its graph neighbours, and asks one honest question: does a graph neural network predict default better than a graph-blind tabular model and the classic Altman-Z rule? Two GNNs are implemented in plain PyTorch via dense adjacency-matrix message passing (no torch-geometric): GraphSAGE (mean neighbour aggregation) and a GAT (learned edge attention). They are benchmarked head-to-head against a tabular XGBoost model and a closed-form Altman-Z score on AUC and Brier, with a degree-preserving edge-permutation significance test to prove the lift comes from real topology, a DebtRank-style contagion stress test, and a spread-widening back-test. The honest result: the graph adds real, significant signal over the fitted tabular model — but a simple un-fit Altman-Z rule remains a stubbornly strong (if poorly calibrated) ranker. Built on Python 3.11+ with torch, xgboost, numpy, scipy, scikit-learn, pandas, plotly, streamlit, pydantic v2 and typer; packaged with hatchling and tested with pytest against deterministic seed-42 fixtures.

I. Interactive Dashboard:

The dashboard below runs entirely in the browser via stlite (Streamlit on WebAssembly — no server). PyTorch and XGBoost cannot run in Pyodide, so the in-browser demo trains a one-layer GraphSAGE in pure NumPy (mean neighbour aggregation + logistic head, by gradient descent) alongside a graph-blind logistic baseline and the closed-form Altman-Z rule — reproducing the identical pipeline (firm graph with contagion, held-out node split, ROC comparison, and a live contagion stress cascade) instantly. The full project trains the torch GraphSAGE and GAT and the XGBoost baseline. First load downloads Pyodide and may take 20–40 seconds.

II. Project Layout:


credit_gnn/
├── pyproject.toml                              # Build config, deps, ruff + pytest settings
├── .env.example                                # SEC EDGAR User-Agent (optional, real-data)
├── dashboard.html                              # Self-contained stlite demo (numpy GraphSAGE stand-in)
├── scripts/
│   ├── make_thumbnail.py                       # Real matplotlib thumbnail (contagion network)
│   └── download_data.py                        # Optional SEC EDGAR real-graph builder
├── src/credit_gnn/
│   ├── data/
│   │   ├── synthetic.py                        # Seed-42 firm-graph generator (contagion hazard)
│   │   ├── schemas.py                          # Pydantic v2 graph / score / stress records
│   │   └── fetchers.py                         # SEC EDGAR 10-K / 13-F graph builder (optional)
│   ├── models/
│   │   ├── gnn.py                              # GraphSAGE + GAT — PLAIN torch message passing
│   │   └── tabular.py                          # XGBoost / sklearn GBM + Altman-Z baseline
│   ├── eval/
│   │   ├── train.py                            # Transductive node training, stratified split
│   │   └── metrics.py                          # AUC, Brier, average precision, ROC (no sklearn)
│   ├── analysis/
│   │   ├── centrality.py                       # Eigenvector / PageRank + edge-permutation test
│   │   └── contagion.py                        # Fixed-point distress cascade / stress test
│   ├── backtest/
│   │   └── spread_signal.py                    # Spread-widening long-short, GNN vs Altman-Z
│   ├── report/plots.py                         # Plotly: force-directed network, ROC, bars
│   ├── cli.py                                  # Typer CLI: compare | permtest | stress | backtest | stats
│   └── app.py                                  # Streamlit server-side dashboard
└── tests/                                      # Seed-42 fixtures; graph, models, analysis, backtest
  

III. The Firm Graph & the Contagion Hazard (data/synthetic.py):

Each firm \(i\) carries five standardised accounting features \(x_i\) (leverage, coverage, profitability, size, liquidity) plus a sector one-hot. Edges come from two channels: directed-but-symmetrised supplier/customer links (sparse, biased within/adjacent sectors) and within-sector common-creditor overlaps, collapsed into one symmetric weighted adjacency \(A\). The 12-month distress label is drawn from a structural hazard that combines a firm’s own (non-linear) fundamentals with contagion — distress propagated from neighbours through the graph:

\[ \text{latent}_i = \underbrace{\beta^\top x_i^{\text{true}} + \gamma\,(\text{lev}_i)_+(-\text{cov}_i)_+}_{\text{own fundamentals (non-linear)}} \;+\; \lambda c\,\underbrace{\big[(I - cP)^{-1}\,\text{drivers} - \text{drivers}\big]_i}_{\text{multi-hop neighbour distress}}, \qquad p_i = \sigma(a_0 + \kappa\,\text{latent}_i). \]

Here \(P = D^{-1}A\) is the row-normalised adjacency and \(c\) is the contagion strength; the Neumann inverse \((I-cP)^{-1}\) is the same Katz / Leontief cascade used in financial-network DebtRank. Crucially, models observe only a noisy proxy \(x_i = x_i^{\text{true}} + \varepsilon\) of the true fundamentals (filed accounting numbers are noisy and stale) — that noise is what leaves room for the graph to add information a firm’s own books cannot supply. On the default \(n = 600\) graph this produces a 22% default rate, edge homophily \(0.68\) (vs a random-label baseline \(0.65\)), and a neighbour-distress / own-default correlation of \(0.37\): distress genuinely clusters along the network.

IV. GraphSAGE & GAT in Plain PyTorch (models/gnn.py):

Both GNNs are implemented directly with dense \((N,N)\) adjacency multiplies — the graphs are small (hundreds of nodes), so message passing is exact, fast and trivially differentiable, with no torch-geometric. GraphSAGE (Hamilton, Ying & Leskovec 2017) uses a mean aggregator; GAT (Veličković et al. 2018) learns single-head additive attention so it can decide which supplier/creditor neighbours carry distress:

\[ h_i^{(l+1)} = \sigma\!\Big(W_{\text{self}}\,h_i^{(l)} + W_{\text{nbr}}\!\!\sum_{j\in\mathcal N(i)}\!\tfrac{1}{|\mathcal N(i)|} h_j^{(l)}\Big) \quad\text{(SAGE)}, \qquad \alpha_{ij} = \operatorname{softmax}_j\!\big(\text{LeakyReLU}(a_s^\top W h_i + a_d^\top W h_j)\big),\;\; h_i^{(l+1)} = \sigma\!\Big(\sum_j \alpha_{ij} W h_j\Big)\;\text{(GAT)}. \]

class SAGELayer(nn.Module):
    def forward(self, h, A_rownorm):           # A_rownorm = D^{-1} A  (mean aggregator)
        nbr = A_rownorm @ h                     # mean of each node's neighbours
        return self.w_self(h) + self.w_nbr(nbr)

class GATLayer(nn.Module):
    def forward(self, h, A_mask):
        wh = self.w(h)
        e = F.leaky_relu(wh @ self.a_src + (wh @ self.a_dst).unsqueeze(0))
        e = e.masked_fill(~adjacency_or_self_loop, float("-inf"))  # attend to neighbours only
        alpha = F.softmax(e, dim=1)             # learned edge weights
        return alpha @ wh

Training is transductive: the model sees the entire topology and all node features during message passing, but the class-weighted BCE loss is computed only on training nodes and every metric only on a held-out, class-stratified node set.

V. Prediction Results (cli.py compare):

Out-of-sample on the held-out nodes (\(n = 600\) firms, contagion strength \(0.85\), 400 epochs). The default class is rare, so AUC and Brier (calibration) matter more than raw accuracy:

ModelAUCBrierAccuracyAvg precision
Altman-Z (un-fit rule)0.7750.2260.6060.490
Tabular GBM (XGBoost)0.6960.1790.7590.380
GraphSAGE0.7190.2200.7630.384
GAT0.6270.2350.6850.354

Honestly reported: GraphSAGE beats the graph-blind tabular GBM by \(+0.023\) AUC — the network genuinely adds information beyond a firm’s own books, exactly the thesis. But the Altman-Z rule, a fixed linear combination of the ratios with zero fitted parameters, posts the best AUC of all (0.775); it is a stubbornly strong ranker, though its Brier (0.226) is the worst — it ranks firms well but is badly calibrated. And the fancier GAT under-performs GraphSAGE: learned attention overfits on a few-hundred-node graph, a candid reminder that more machinery is not automatically better.

VI. Does the Edge Come From Real Topology? (analysis/centrality.py):

A GNN has more parameters than a logistic model, so a higher AUC could in principle be capacity, not the graph. The edge-permutation test settles it: re-train GraphSAGE on many degree-preserving edge-shuffled graphs (node features and labels fixed) and compare the real-graph AUC to the shuffled-graph null distribution.

QuantityValue
Real-graph AUC0.722
Shuffled-null AUC (mean ± std)0.698 ± 0.018
p-value (one-sided)0.04

The real-graph AUC sits in the tail of the shuffled null (\(p = 0.04\)): the lift is driven by the actual supplier/creditor wiring, not by the GNN’s extra parameters. This is the significance check that separates “the graph has signal” from “the model just has more capacity.”

VII. Contagion Stress Test (analysis/contagion.py):

Beyond prediction, the graph lets us run a DebtRank-style stress test: fully distress one firm and propagate the shock to a fixed point, \(d = \operatorname{clip}\!\big(d_0 + \text{shock}\cdot e_{\text{seed}} + c\,P(d - d_0),\,0,\,1\big)\), then sum the resulting PD increase across all other firms. Seeding the most eigenvector-central firm (19 direct neighbours) yields a second-order amplification of 6.9 in summed PD, with 33% of firms affected — network-borne risk a firm-by-firm view would entirely miss. In the dashboard you can seed any firm and watch the cascade spread; central firms export far more systemic risk than peripheral ones.

VIII. The Back-test: A Spread-Widening Signal (backtest/spread_signal.py):

A better PD ranking should forecast a widening credit spread. We form a cross-sectional long-short book — short (buy protection on) the top-decile PD names, long the bottom decile — and measure realised spread-widening capture and rank IC, head-to-head against Altman-Z:

SignalRank ICL/S capture (bps)Hit rate
Altman-Z0.460+2070.854
GraphSAGE0.345+1350.792

Here the candour is the deliverable: the GraphSAGE signal does not beat Altman-Z on this spread-widening trade (rank IC 0.345 vs 0.460). The graph improves default classification over a fitted tabular model, but it does not magically out-trade a simple, well-ranked credit screen. The precise, un-oversold contribution of the GNN is: better ranking than a fitted tabular model, plus well-calibrated probabilities, plus a contagion stress engine — not a free alpha signal.

IX. CLI — cli.py:

# Install
pip install -e ".[dev]"

# Leaderboard: Altman-Z vs Tabular GBM vs GraphSAGE vs GAT
creditgnn compare

# Is GraphSAGE's edge from REAL topology? (degree-preserving permutation test)
creditgnn permtest

# Contagion stress test: shock the most-central firm, propagate, amplification
creditgnn stress

# Spread-widening signal back-test: GraphSAGE vs Altman-Z
creditgnn backtest

# Launch the server-side Streamlit dashboard
streamlit run src/credit_gnn/app.py
  
CommandKey optionsOutput
creditgnn compare--n, --cs, --epochsAUC / Brier / AP leaderboard across four models
creditgnn permtest--n-perm, --epochsReal vs shuffled-null AUC and a p-value
creditgnn stress--firm (-1 = most central)Second-order amplification, fraction affected

X. Test Suite:

Thirty tests, fully offline, seed-42. Graph tests verify adjacency symmetry, the sector one-hot, a reasonable default rate, and — the heart of the thesis — that distress clusters along edges above the random-label baseline, and that turning contagion off removes the neighbour signal. Model tests confirm forward-pass shapes, that GAT attention rows sum to one, that training reduces the loss (no full-batch underfit), and a clean isolation test that GraphSAGE on the real graph beats the same model with edges removed. Analysis tests check centrality, that central firms amplify more than peripheral ones, contagion monotonicity, and that the real graph beats the permutation null. Metric tests validate AUC against scikit-learn; backtest tests confirm the signal’s sign and the honest head-to-head.

def test_gnn_uses_the_graph(graph):
    real, _ = train_gnn("graphsage", graph, tr, te, epochs=150)
    g_noedge = replace(graph, A=np.zeros_like(graph.A))          # edges removed -> an MLP
    mlp, _  = train_gnn("graphsage", g_noedge, tr, te, epochs=150)
    assert roc_auc(y_te, real) >= roc_auc(y_te, mlp) - 0.02      # the lift comes from edges

def test_permutation_test_real_beats_shuffled():
    r = edge_permutation_test(g, n_perm=8, epochs=250)
    assert r["real_auc"] > r["null_mean"]                        # real topology carries the signal

XI. Configuration & Setup:


cd assets/projects/credit_gnn
python -m venv .venv && .venv\Scripts\Activate.ps1        # Windows
pip install -e ".[dev]"
creditgnn compare                                         # reproduce the leaderboard
creditgnn permtest                                        # graph-permutation significance
pytest -q                                                 # 30 tests, offline
streamlit run src/credit_gnn/app.py
  

No data download is required: the models, tests and dashboard all run on the synthetic firm-graph generator with no API keys. The optional scripts/download_data.py and data/fetchers.py build a real corporate graph from SEC EDGAR (10-K Item 1 “significant customers” supplier/customer links and 13-F holding overlaps), gated behind a polite SEC_USER_AGENT in .env — never touched by CI.


Team:

Theodosios Dimitrasopoulos, personal project.

Tools & methods:

Python 3.11, PyTorch (in-house GraphSAGE + GAT via dense adjacency message passing, no torch-geometric), XGBoost, scikit-learn, NumPy, SciPy, pandas, Pydantic v2, Typer, rich, Plotly, Streamlit, pytest, ruff, hatchling. Methods: graph neural networks for node classification (GraphSAGE, Hamilton et al. 2017; GAT, Veličković et al. 2018); credit-contagion / financial-network modelling and DebtRank-style distress cascades; the Altman-Z structural credit score; transductive node-level train/test splitting; AUC / Brier / average-precision evaluation; degree-preserving edge-permutation significance testing; eigenvector / PageRank centrality; cross-sectional spread-widening back-test.