Source code for pycsamt.ai.nets.gcn

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
Graph Convolutional Network for spatial EM inversion.

:class:`GCNNet` implements Kipf & Welling (2017) spectral graph
convolutions adapted for geophysical data:

    :math:`H^{(l+1)} = \\sigma\\!\\left(\\tilde{D}^{-1/2}
    \\tilde{A}\\tilde{D}^{-1/2} H^{(l)} W^{(l)}\\right)`

where :math:`\\tilde{A} = A + I` (self-loops),
:math:`\\tilde{D}_{ii} = \\sum_j \\tilde{A}_{ij}`.

No external graph library (PyTorch Geometric, DGL) is required; the
normalised adjacency matrix is pre-computed once from station
coordinates and passed as a dense tensor to every forward call.
"""

from __future__ import annotations

from collections.abc import Sequence

import numpy as np

__all__ = ["GCNNet", "build_adjacency"]


# ─────────────────────────────────────────────────────────────────────────────
# Adjacency utilities (framework-free)
# ─────────────────────────────────────────────────────────────────────────────


[docs] def build_adjacency( coords: np.ndarray, radius: float, *, self_loops: bool = True, normalise: bool = True, ) -> np.ndarray: """ Build a symmetric adjacency matrix from 2-D station coordinates. Parameters ---------- coords : ndarray, shape (n_stations, 2) Station (x, y) positions in any consistent unit (metres, degrees). radius : float Maximum inter-station distance for an edge to exist. Uses the same unit as *coords*. self_loops : bool If ``True`` (default), add :math:`\\tilde{A} = A + I`. normalise : bool If ``True`` (default), apply symmetric normalisation :math:`\\tilde{D}^{-1/2}\\tilde{A}\\tilde{D}^{-1/2}`. Returns ------- A : ndarray, shape (n_stations, n_stations), float32 Adjacency matrix, optionally normalised. """ coords = np.asarray(coords, dtype=np.float64) len(coords) diff = coords[:, np.newaxis, :] - coords[np.newaxis, :, :] # (n, n, 2) dist = np.sqrt((diff**2).sum(axis=-1)) # (n, n) A = (dist <= radius).astype(np.float32) if not self_loops: np.fill_diagonal(A, 0.0) else: np.fill_diagonal(A, 1.0) if normalise: deg = A.sum(axis=1) # (n,) deg_inv_sqrt = np.where(deg > 0, deg**-0.5, 0.0) D = np.diag(deg_inv_sqrt) A = D @ A @ D return A.astype(np.float32)
# ───────────────────────────────────────────────────────────────────────────── # PyTorch GCN # ─────────────────────────────────────────────────────────────────────────────
[docs] class GCNNet: """ Factory that builds a PyTorch or TensorFlow GCN. Call :meth:`build` after import to obtain the framework module. Parameters ---------- n_features : int Dimensionality of per-node input features (e.g. 2 × n_freqs). n_out : int Per-node output size (2n-1 for an n-layer model: n resistivities + n-1 thicknesses). hidden : sequence of int Hidden-layer widths for each GCN message-passing step. dropout : float Dropout probability applied after each hidden GCN layer. """ def __init__( self, n_features: int, n_out: int, hidden: Sequence[int] = (256, 128, 64), dropout: float = 0.1, ) -> None: self.n_features = int(n_features) self.n_out = int(n_out) self.hidden = tuple(int(h) for h in hidden) self.dropout = float(dropout) # ── PyTorch ──────────────────────────────────────────────────────────────
[docs] def build(self) -> torch.nn.Module: """Return a ``torch.nn.Module`` for this architecture.""" import torch import torch.nn as nn import torch.nn.functional as F hidden = self.hidden n_in = self.n_features n_out = self.n_out dropout = self.dropout class _GCNLayer(nn.Module): def __init__(self, in_f: int, out_f: int) -> None: super().__init__() self.W = nn.Linear(in_f, out_f, bias=True) self.bn = nn.BatchNorm1d(out_f) def forward( self, H: torch.Tensor, A: torch.Tensor ) -> torch.Tensor: # H : (n_nodes, in_f) A : (n_nodes, n_nodes) agg = A @ H # (n_nodes, in_f) out = self.W(agg) # (n_nodes, out_f) out = self.bn(out) return F.relu(out) class _GCNModule(nn.Module): def __init__(self) -> None: super().__init__() dims = [n_in] + list(hidden) self.layers = nn.ModuleList( [ _GCNLayer(dims[i], dims[i + 1]) for i in range(len(dims) - 1) ] ) self.drop = nn.Dropout(dropout) self.head = nn.Linear(dims[-1], n_out) def forward( self, H: torch.Tensor, A: torch.Tensor, ) -> torch.Tensor: """ Parameters ---------- H : Tensor (n_nodes, n_features) — node feature matrix A : Tensor (n_nodes, n_nodes) — normalised adjacency Returns ------- out : Tensor (n_nodes, n_out) — per-node predictions """ for layer in self.layers: H = self.drop(layer(H, A)) return self.head(H) return _GCNModule()
# ── TensorFlow / Keras ────────────────────────────────────────────────────
[docs] def build_tf(self) -> tf.keras.Model: """Return a ``tf.keras.Model`` for this architecture.""" import tensorflow as tf n_in = self.n_features n_out = self.n_out hidden = self.hidden dropout = self.dropout class GCNLayer(tf.keras.layers.Layer): def __init__(self, units: int, **kw) -> None: super().__init__(**kw) self.dense = tf.keras.layers.Dense(units, activation=None) self.bn = tf.keras.layers.BatchNormalization() def call( self, inputs: tuple[tf.Tensor, tf.Tensor], training: bool = False, ) -> tf.Tensor: H, A = inputs # H: (n, f) A: (n, n) agg = tf.linalg.matmul(A, H) out = self.dense(agg) out = self.bn(out, training=training) return tf.nn.relu(out) H_in = tf.keras.Input(shape=(n_in,), name="node_features") A_in = tf.keras.Input(shape=(None,), name="adj_row") # We build the Keras model as a pure function (adjacency is data input) # H_in : (n_nodes, n_in) A_in : (n_nodes, n_nodes) — passed as batches H = H_in for units in hidden: layer = GCNLayer(units) H = layer([H, A_in]) H = tf.keras.layers.Dropout(dropout)(H) out = tf.keras.layers.Dense(n_out, name="output")(H) return tf.keras.Model(inputs=[H_in, A_in], outputs=out, name="GCNNet")