| """ |
| L-RMC Anchored GCN vs. Plain GCN (dynamic robustness evaluation) |
| ============================================================== |
| |
| This script trains a baseline two‑layer GCN and a new **anchor‑gated** GCN on |
| Planetoid citation networks (Cora/Citeseer/Pubmed). The anchor‑gated GCN uses |
| the top‑1 L‑RMC cluster (loaded from a provided JSON file) as a *decentralized |
| core*. During message passing it blends standard neighborhood aggregation |
| (`h_base`) with aggregation restricted to the core (`h_core`) via a per‑node |
| gating network. Cross‑boundary edges are optionally down‑weighted by a |
| damping factor `γ`. |
| |
| After training on the static graph, the script evaluates *robustness over |
| time*. Starting from the original adjacency, it repeatedly performs random |
| edge rewires (removes a fraction of existing edges and adds the same number |
| of random new edges) and measures test accuracy at each step **without |
| retraining**. The area under the accuracy–time curve (AUC‑AT) is reported |
| for both the baseline and the anchored model. A higher AUC‑AT indicates |
| longer resilience to graph churn. |
| |
| Usage examples:: |
| |
| # Train only baseline and report dynamic AUC |
| python 2.5_lrmc_bilevel.py --dataset Cora --seeds path/to/lrmc_seeds.json --variant baseline |
| |
| # Train baseline and anchor models, evaluate AUC‑over‑time on 30 steps with 5% rewiring |
| python 2.5_lrmc_bilevel.py --dataset Cora --seeds path/to/lrmc_seeds.json --variant anchor \ |
| --dynamic_steps 30 --flip_fraction 0.05 --gamma 0.8 |
| |
| Notes |
| ----- |
| * The seeds JSON must contain an entry ``"clusters"`` with a list of clusters; the |
| cluster with maximum (score, size) is chosen as the core. |
| * For fairness, both models are trained on the identical training mask and |
| evaluated on the same dynamic perturbations. |
| * Random rewiring is undirected: an edge (u,v) is treated as the same as (v,u). |
| * Cross‑boundary damping and the gating network use only structural |
| information; features are left unchanged during perturbations. |
| """ |
|
|
| import argparse |
| import json |
| import random |
| from pathlib import Path |
| from typing import Tuple, List, Optional, Set |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from torch import Tensor |
| from torch_geometric.datasets import Planetoid |
| from torch_geometric.nn import GCNConv |
|
|
| from rich import print |
|
|
| |
| |
| |
|
|
| def _pick_top1_cluster(obj: dict) -> List[int]: |
| """ |
| From LRMC JSON with structure {"clusters":[{"seed_nodes":[...],"score":float,...},...]} |
| choose the cluster with the highest (score, size) and return its members as |
| 0‑indexed integers. If no clusters exist, returns an empty list. |
| """ |
| clusters = obj.get("clusters", []) |
| if not clusters: |
| return [] |
| |
| best = max(clusters, key=lambda c: (float(c.get("score", 0.0)), len(c.get("seed_nodes", [])))) |
| return [nid - 1 for nid in best.get("seed_nodes", [])] |
|
|
|
|
| def load_top1_assignment(seeds_json: str, n_nodes: int) -> Tuple[Tensor, Tensor]: |
| """ |
| Given a path to the LRMC seeds JSON and total number of nodes, returns: |
| |
| * core_mask: bool Tensor of shape [N] where True indicates membership in the |
| top‑1 LRMC cluster. |
| * core_nodes: Long Tensor containing the indices of the core nodes. |
| |
| Nodes not in the core form the periphery. If the JSON has no clusters, |
| the core is empty. |
| """ |
| obj = json.loads(Path(seeds_json).read_text()) |
| core_list = _pick_top1_cluster(obj) |
| core_nodes = torch.tensor(sorted(set(core_list)), dtype=torch.long) |
| core_mask = torch.zeros(n_nodes, dtype=torch.bool) |
| if core_nodes.numel() > 0: |
| core_mask[core_nodes] = True |
| return core_mask, core_nodes |
|
|
|
|
| |
| |
| |
|
|
| class GCN2(nn.Module): |
| """Plain 2‑layer GCN (baseline).""" |
|
|
| def __init__(self, in_dim: int, hid_dim: int, out_dim: int, dropout: float = 0.5): |
| super().__init__() |
| self.conv1 = GCNConv(in_dim, hid_dim) |
| self.conv2 = GCNConv(hid_dim, out_dim) |
| self.dropout = dropout |
|
|
| def forward(self, x: Tensor, edge_index: Tensor, edge_weight: Optional[Tensor] = None) -> Tensor: |
| |
| x = F.relu(self.conv1(x, edge_index, edge_weight)) |
| x = F.dropout(x, p=self.dropout, training=self.training) |
| x = self.conv2(x, edge_index, edge_weight) |
| return x |
|
|
|
|
| |
| |
| |
|
|
| class AnchorGCN(nn.Module): |
| """ |
| A two‑layer GCN that injects a core‑restricted aggregation channel and |
| down‑weights edges crossing the core boundary. After the first GCN layer |
| computes base features, a gating network mixes them with features |
| aggregated only among core neighbors. |
| |
| Parameters |
| ---------- |
| in_dim : int |
| Dimensionality of input node features. |
| hid_dim : int |
| Dimensionality of hidden layer. |
| out_dim : int |
| Number of output classes. |
| core_mask : Tensor[bool] |
| Boolean mask indicating which nodes belong to the L‑RMC core. |
| gamma : float, optional |
| Damping factor for edges that connect core and non‑core nodes. |
| Values <1.0 reduce the influence of boundary edges. Default is 1.0 |
| (no damping). |
| dropout : float, optional |
| Dropout probability applied after the first layer. |
| """ |
|
|
| def __init__(self, |
| in_dim: int, |
| hid_dim: int, |
| out_dim: int, |
| core_mask: Tensor, |
| gamma: float = 1.0, |
| dropout: float = 0.5): |
| super().__init__() |
| self.core_mask = core_mask.clone().detach() |
| self.gamma = float(gamma) |
| self.dropout = dropout |
|
|
| |
| |
| |
| self.base1 = GCNConv(in_dim, hid_dim, add_self_loops=True) |
| self.core1 = GCNConv(in_dim, hid_dim, add_self_loops=False) |
|
|
| |
| self.conv2 = GCNConv(hid_dim, out_dim) |
|
|
| |
| self.gate = nn.Sequential( |
| nn.Linear(3, 16), |
| nn.ReLU(), |
| nn.Linear(16, 1), |
| nn.Sigmoid(), |
| ) |
|
|
| def _compute_edge_weights(self, edge_index: Tensor) -> Tensor: |
| """ |
| Given an edge index (two‑row tensor), return a weight tensor of ones |
| multiplied by ``gamma`` for edges with exactly one endpoint in the core. |
| Self loops (if present) are untouched. Edge weights are 1 for base |
| edges and <1 for cross‑boundary edges. |
| """ |
| if self.gamma >= 1.0: |
| return torch.ones(edge_index.size(1), dtype=torch.float32, device=edge_index.device) |
| src, dst = edge_index[0], edge_index[1] |
| in_core_src = self.core_mask[src] |
| in_core_dst = self.core_mask[dst] |
| cross = in_core_src ^ in_core_dst |
| w = torch.ones(edge_index.size(1), dtype=torch.float32, device=edge_index.device) |
| w[cross] *= self.gamma |
| return w |
|
|
| def _compute_structural_features(self, edge_index: Tensor) -> Tuple[Tensor, Tensor, Tensor]: |
| """ |
| Compute structural features used by the gating network: |
| |
| * `in_core` – 1 if node in core, else 0 |
| * `frac_core_nbrs` – fraction of neighbors that are in the core |
| * `is_boundary` – 1 if node has both core and non‑core neighbors |
| |
| The features are returned as a tuple of three tensors of shape [N,1]. |
| Nodes with zero degree get frac_core_nbrs=0 and is_boundary=0. |
| """ |
| N = self.core_mask.size(0) |
| device = edge_index.device |
| |
| src = edge_index[0] |
| dst = edge_index[1] |
| deg = torch.zeros(N, dtype=torch.float32, device=device) |
| core_deg = torch.zeros(N, dtype=torch.float32, device=device) |
| |
| |
| deg.index_add_(0, src, torch.ones_like(src, dtype=torch.float32)) |
| |
| core_flags = self.core_mask[dst].float() |
| core_deg.index_add_(0, src, core_flags) |
| |
| frac_core = torch.zeros(N, dtype=torch.float32, device=device) |
| nonzero = deg > 0 |
| frac_core[nonzero] = core_deg[nonzero] / deg[nonzero] |
| |
| has_core = core_deg > 0 |
| has_non_core = (deg - core_deg) > 0 |
| is_boundary = (has_core & has_non_core).float() |
| in_core = self.core_mask.float() |
| return in_core.view(-1, 1), frac_core.view(-1, 1), is_boundary.view(-1, 1) |
|
|
| def forward(self, x: Tensor, edge_index: Tensor) -> Tensor: |
| |
| w = self._compute_edge_weights(edge_index) |
| |
| h_base = self.base1(x, edge_index, w) |
| h_base = F.relu(h_base) |
|
|
| |
| |
| src, dst = edge_index |
| mask_core_edges = self.core_mask[src] & self.core_mask[dst] |
| ei_core = edge_index[:, mask_core_edges] |
| |
| if ei_core.numel() == 0: |
| h_core = torch.zeros_like(h_base) |
| else: |
| h_core = self.core1(x, ei_core) |
| h_core = F.relu(h_core) |
|
|
| |
| in_core, frac_core, is_boundary = self._compute_structural_features(edge_index) |
| feats = torch.cat([in_core, frac_core, is_boundary], dim=1) |
| alpha = self.gate(feats).view(-1) |
| |
| |
| no_core_neighbors = (frac_core.view(-1) == 0) |
| alpha = torch.where(no_core_neighbors, torch.zeros_like(alpha), alpha) |
|
|
| |
| |
| h1 = h_base + alpha.unsqueeze(1) * (h_core - h_base) |
|
|
| h1 = F.dropout(h1, p=self.dropout, training=self.training) |
| |
| out = self.conv2(h1, edge_index, w) |
| return out |
|
|
|
|
| |
| def deg_for_division(edge_index: Tensor, num_nodes: int) -> Tensor: |
| src = edge_index[0] |
| deg = torch.zeros(num_nodes, dtype=torch.float32, device=edge_index.device) |
| deg.index_add_(0, src, torch.ones_like(src, dtype=torch.float32)) |
| return deg |
|
|
|
|
| |
| |
| |
|
|
| @torch.no_grad() |
| def accuracy(logits: Tensor, y: Tensor, mask: Tensor) -> float: |
| """Compute accuracy of the predictions over the mask.""" |
| pred = logits[mask].argmax(dim=1) |
| return (pred == y[mask]).float().mean().item() |
|
|
|
|
| def train_model(model: nn.Module, |
| data, |
| epochs: int = 200, |
| lr: float = 0.01, |
| weight_decay: float = 5e-4) -> None: |
| """Standard training loop for either baseline or anchor models.""" |
| opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay) |
| best_val = 0.0 |
| best_state = None |
| for ep in range(1, epochs + 1): |
| model.train() |
| opt.zero_grad(set_to_none=True) |
| logits = model(data.x, data.edge_index) |
| loss = F.cross_entropy(logits[data.train_mask], data.y[data.train_mask]) |
| loss.backward() |
| opt.step() |
| |
| model.eval() |
| logits_val = model(data.x, data.edge_index) |
| val_acc = accuracy(logits_val, data.y, data.val_mask) |
| if val_acc > best_val: |
| best_val = val_acc |
| best_state = {k: v.detach().clone() for k, v in model.state_dict().items()} |
| if best_state is not None: |
| model.load_state_dict(best_state) |
| model.eval() |
|
|
|
|
| def evaluate_model(model: nn.Module, data) -> dict: |
| """Evaluate a trained model on train, val, and test masks.""" |
| model.eval() |
| logits = model(data.x, data.edge_index) |
| return { |
| "train": accuracy(logits, data.y, data.train_mask), |
| "val": accuracy(logits, data.y, data.val_mask), |
| "test": accuracy(logits, data.y, data.test_mask), |
| } |
|
|
|
|
| |
| |
| |
|
|
| def undirected_edge_set(edge_index: Tensor) -> Set[Tuple[int, int]]: |
| """ |
| Convert a directed edge index into a set of undirected edges represented |
| as (u,v) tuples with u < v. Self loops are ignored. |
| """ |
| edges = set() |
| src = edge_index[0].tolist() |
| dst = edge_index[1].tolist() |
| for u, v in zip(src, dst): |
| if u == v: |
| continue |
| a, b = (u, v) if u < v else (v, u) |
| edges.add((a, b)) |
| return edges |
|
|
|
|
| def edge_set_to_index(edges: Set[Tuple[int, int]], num_nodes: int) -> Tensor: |
| """ |
| Convert an undirected edge set into a directed edge_index tensor of shape |
| [2, 2*|edges|] by adding both (u,v) and (v,u) for each undirected edge. |
| Self loops are omitted; GCNConv adds them automatically. |
| """ |
| if not edges: |
| return torch.empty(2, 0, dtype=torch.long) |
| src_list = [] |
| dst_list = [] |
| for u, v in edges: |
| src_list.extend([u, v]) |
| dst_list.extend([v, u]) |
| edge_index = torch.tensor([src_list, dst_list], dtype=torch.long) |
| return edge_index |
|
|
|
|
| def random_rewire(edges: Set[Tuple[int, int]], num_nodes: int, n_changes: int, rng: random.Random) -> Set[Tuple[int, int]]: |
| """ |
| Perform n_changes edge removals and n_changes edge additions on the given |
| undirected edge set. For each change we remove a random existing edge and |
| add a random new edge (u,v) not already present. Self loops are never |
| added. Duplicate additions are skipped. |
| """ |
| edges = set(edges) |
| |
| n_changes = min(n_changes, len(edges)) |
| |
| to_remove = rng.sample(list(edges), n_changes) |
| for e in to_remove: |
| edges.remove(e) |
| |
| added = 0 |
| attempts = 0 |
| while added < n_changes and attempts < n_changes * 10: |
| u = rng.randrange(num_nodes) |
| v = rng.randrange(num_nodes) |
| if u == v: |
| attempts += 1 |
| continue |
| a, b = (u, v) if u < v else (v, u) |
| if (a, b) not in edges: |
| edges.add((a, b)) |
| added += 1 |
| attempts += 1 |
| return edges |
|
|
|
|
| def auc_over_time(acc_list: List[float]) -> float: |
| """ |
| Compute the area under an accuracy–time curve using the trapezoidal rule. |
| ``acc_list`` should contain the accuracies at t=0,1,...,T. The AUC is |
| normalized by T so that a perfect score of 1.0 yields AUC=1.0. |
| """ |
| if not acc_list: |
| return 0.0 |
| area = 0.0 |
| for i in range(1, len(acc_list)): |
| area += (acc_list[i] + acc_list[i-1]) / 2.0 |
| return area / (len(acc_list) - 1) |
|
|
|
|
| def evaluate_dynamic_auc(model: nn.Module, |
| data, |
| core_mask: Tensor, |
| steps: int = 30, |
| flip_fraction: float = 0.05, |
| rng_seed: int = 1234) -> List[float]: |
| """ |
| Evaluate a model's test accuracy over a sequence of random edge rewiring steps. |
| |
| Parameters |
| ---------- |
| model : nn.Module |
| A trained model that accepts (x, edge_index) and returns logits. |
| data : Data |
| PyG data object with attributes x, y, test_mask. ``data.edge_index`` |
| provides the initial adjacency. |
| core_mask : Tensor[bool] |
| Boolean mask indicating core nodes (used for gating during evaluation). |
| The baseline model ignores it. |
| steps : int, optional |
| Number of rewiring steps to perform. The accuracy at t=0 is computed |
| before any rewiring. Default: 30. |
| flip_fraction : float, optional |
| Fraction of edges to remove/add at each step. For example, 0.05 |
| rewires 5% of existing edges per step. Default: 0.05. |
| rng_seed : int, optional |
| Random seed for reproducibility. Default: 1234. |
| |
| Returns |
| ------- |
| List[float] |
| A list of length ``steps+1`` containing the test accuracy at each |
| iteration (including t=0). |
| """ |
| |
| base_edges = undirected_edge_set(data.edge_index) |
| num_edges = len(base_edges) |
| |
| n_changes = max(1, int(flip_fraction * num_edges)) |
| |
| model.eval() |
| |
| rng = random.Random(rng_seed) |
| |
| cur_edges = set(base_edges) |
| accuracies = [] |
| |
| ei = edge_set_to_index(cur_edges, data.num_nodes) |
| |
| ei = ei.to(data.x.device) |
| logits = model(data.x, ei) |
| accuracies.append(accuracy(logits, data.y, data.test_mask)) |
| |
| for t in range(1, steps + 1): |
| cur_edges = random_rewire(cur_edges, data.num_nodes, n_changes, rng) |
| ei = edge_set_to_index(cur_edges, data.num_nodes).to(data.x.device) |
| logits = model(data.x, ei) |
| acc = accuracy(logits, data.y, data.test_mask) |
| accuracies.append(acc) |
| return accuracies |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="L‑RMC anchored GCN vs. baseline with dynamic evaluation.") |
| parser.add_argument("--dataset", required=True, choices=["Cora", "Citeseer", "Pubmed"], |
| help="Planetoid dataset to load.") |
| parser.add_argument("--seeds", required=True, help="Path to LRMC seeds JSON (for core extraction).") |
| parser.add_argument("--variant", choices=["baseline", "anchor"], default="anchor", help="Which variant to run.") |
| parser.add_argument("--hidden", type=int, default=64, help="Hidden dimension.") |
| parser.add_argument("--epochs", type=int, default=200, help="Number of training epochs.") |
| parser.add_argument("--lr", type=float, default=0.01, help="Learning rate.") |
| parser.add_argument("--wd", type=float, default=5e-4, help="Weight decay (L2).") |
| parser.add_argument("--dropout", type=float, default=0.5, help="Dropout probability.") |
| parser.add_argument("--gamma", type=float, default=1.0, help="Damping factor γ for cross‑boundary edges (anchor only).") |
| parser.add_argument("--dynamic_steps", type=int, default=30, help="Number of dynamic rewiring steps for AUC evaluation.") |
| parser.add_argument("--flip_fraction", type=float, default=0.05, help="Fraction of edges rewired at each step.") |
| parser.add_argument("--seed", type=int, default=42, help="Random seed for PyTorch.") |
| args = parser.parse_args() |
|
|
| |
| torch.manual_seed(args.seed) |
| random.seed(args.seed) |
|
|
| |
| dataset = Planetoid(root=f"./data/{args.dataset}", name=args.dataset) |
| data = dataset[0] |
| in_dim = dataset.num_node_features |
| out_dim = dataset.num_classes |
| num_nodes = data.num_nodes |
|
|
| |
| core_mask, core_nodes = load_top1_assignment(args.seeds, num_nodes) |
| print(f"Loaded core of size {core_nodes.numel()} from {args.seeds}.") |
|
|
| if args.variant == "baseline": |
| |
| baseline = GCN2(in_dim, args.hidden, out_dim, dropout=args.dropout) |
| train_model(baseline, data, epochs=args.epochs, lr=args.lr, weight_decay=args.wd) |
| res = evaluate_model(baseline, data) |
| print(f"Baseline GCN: train={res['train']:.4f} val={res['val']:.4f} test={res['test']:.4f}") |
| |
| accs = evaluate_dynamic_auc(baseline, data, core_mask, steps=args.dynamic_steps, |
| flip_fraction=args.flip_fraction, rng_seed=args.seed) |
| auc = auc_over_time(accs) |
| print(f"Baseline dynamic AUC‑AT (steps={args.dynamic_steps}, flip={args.flip_fraction}): {auc:.4f}") |
| return |
|
|
| |
| |
| baseline = GCN2(in_dim, args.hidden, out_dim, dropout=args.dropout) |
| train_model(baseline, data, epochs=args.epochs, lr=args.lr, weight_decay=args.wd) |
| res_base = evaluate_model(baseline, data) |
| print(f"Baseline GCN: train={res_base['train']:.4f} val={res_base['val']:.4f} test={res_base['test']:.4f}") |
| |
| anchor = AnchorGCN(in_dim, args.hidden, out_dim, |
| core_mask=core_mask, |
| gamma=args.gamma, |
| dropout=args.dropout) |
| train_model(anchor, data, epochs=args.epochs, lr=args.lr, weight_decay=args.wd) |
| res_anchor = evaluate_model(anchor, data) |
| print(f"Anchor‑GCN: train={res_anchor['train']:.4f} val={res_anchor['val']:.4f} test={res_anchor['test']:.4f}") |
| |
| accs_base = evaluate_dynamic_auc(baseline, data, core_mask, steps=args.dynamic_steps, |
| flip_fraction=args.flip_fraction, rng_seed=args.seed) |
| accs_anchor = evaluate_dynamic_auc(anchor, data, core_mask, steps=args.dynamic_steps, |
| flip_fraction=args.flip_fraction, rng_seed=args.seed) |
| auc_base = auc_over_time(accs_base) |
| auc_anchor = auc_over_time(accs_anchor) |
| print(f"Dynamic AUC‑AT (steps={args.dynamic_steps}, flip={args.flip_fraction}):") |
| print(f" Baseline : {auc_base:.4f}\n Anchor : {auc_anchor:.4f}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|