| """Data loading utilities for temporal COSTE/SSS graphs.""" |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| from torch_geometric.data import Data |
|
|
| GROUP_TO_STAGE = {"HD": "T1", "LA": "T2", "MA": "T3"} |
| STAGE_ORDER = ("T1", "T2", "T3") |
| STAGE_LABELS = {"T1": "healthy", "T2": "less_affected", "T3": "more_affected"} |
| REQUIRED_COLUMNS = ("row", "column", "value", "sample", "group") |
|
|
|
|
| @dataclass |
| class TemporalGraphBundle: |
| """Container holding stage graphs and related metadata.""" |
|
|
| node_names: list[str] |
| sample_stage_map: dict[str, str] |
| sample_matrices: dict[str, np.ndarray] |
| stage_matrices: dict[str, np.ndarray] |
| stage_graphs: list[Data] |
| sample_counts: dict[str, int] |
|
|
|
|
| def load_relationship_table(csv_path: str | Path) -> pd.DataFrame: |
| """Load and validate the flattened COSTE/SSS relationship table.""" |
|
|
| csv_path = Path(csv_path) |
| df = pd.read_csv(csv_path) |
| missing_columns = [column for column in REQUIRED_COLUMNS if column not in df.columns] |
| if missing_columns: |
| raise ValueError(f"Missing required columns: {missing_columns}") |
|
|
| unknown_groups = sorted(set(df["group"]) - set(GROUP_TO_STAGE)) |
| if unknown_groups: |
| raise ValueError(f"Unexpected group labels: {unknown_groups}") |
|
|
| return df.copy() |
|
|
|
|
| def build_sample_matrices( |
| df: pd.DataFrame, |
| node_names: list[str] | None = None, |
| ) -> tuple[list[str], dict[str, str], dict[str, np.ndarray]]: |
| """Build full per-sample directed SSS matrices. |
| |
| Missing off-diagonal pairs are filled with 1.0, matching the manuscript's |
| treatment of absent spatial associations. Diagonal values are set to 0.0. |
| """ |
|
|
| if node_names is None: |
| node_names = sorted(set(df["row"]).union(set(df["column"]))) |
|
|
| node_to_idx = {name: idx for idx, name in enumerate(node_names)} |
| num_nodes = len(node_names) |
|
|
| sample_stage_map: dict[str, str] = {} |
| sample_matrices: dict[str, np.ndarray] = {} |
|
|
| for sample, sample_df in df.groupby("sample", sort=True): |
| stage = GROUP_TO_STAGE[str(sample_df["group"].iloc[0])] |
| matrix = np.ones((num_nodes, num_nodes), dtype=np.float32) |
| np.fill_diagonal(matrix, 0.0) |
|
|
| for row in sample_df.itertuples(index=False): |
| source = node_to_idx[str(row.row)] |
| target = node_to_idx[str(row.column)] |
| matrix[source, target] = float(row.value) |
|
|
| sample_stage_map[str(sample)] = stage |
| sample_matrices[str(sample)] = matrix |
|
|
| return node_names, sample_stage_map, sample_matrices |
|
|
|
|
| def aggregate_stage_matrices( |
| sample_stage_map: dict[str, str], |
| sample_matrices: dict[str, np.ndarray], |
| ) -> tuple[dict[str, np.ndarray], dict[str, int]]: |
| """Average per-sample matrices within each temporal stage.""" |
|
|
| grouped: dict[str, list[np.ndarray]] = {stage: [] for stage in STAGE_ORDER} |
| for sample, matrix in sample_matrices.items(): |
| grouped[sample_stage_map[sample]].append(matrix) |
|
|
| stage_matrices: dict[str, np.ndarray] = {} |
| sample_counts: dict[str, int] = {} |
| for stage in STAGE_ORDER: |
| matrices = grouped[stage] |
| if not matrices: |
| raise ValueError(f"No sample matrices found for stage {stage}") |
| stage_matrices[stage] = np.mean(np.stack(matrices, axis=0), axis=0).astype(np.float32) |
| sample_counts[stage] = len(matrices) |
|
|
| return stage_matrices, sample_counts |
|
|
|
|
| def matrix_to_graph(stage: str, matrix: np.ndarray, node_names: list[str]) -> Data: |
| """Convert a directed SSS matrix to a PyG Data object. |
| |
| Message passing uses an affinity transform of ``1 - SSS`` so that closer |
| spatial associations produce larger edge weights. |
| """ |
|
|
| num_nodes = matrix.shape[0] |
| sss_matrix = np.clip(matrix, 0.0, 1.0).astype(np.float32) |
| affinity = 1.0 - sss_matrix |
| np.fill_diagonal(affinity, 0.0) |
|
|
| source_nodes, target_nodes = np.where(affinity > 0) |
| edge_index = torch.tensor( |
| np.vstack([source_nodes, target_nodes]), |
| dtype=torch.long, |
| ) |
| edge_weight = torch.tensor(affinity[source_nodes, target_nodes], dtype=torch.float32) |
| x = torch.eye(num_nodes, dtype=torch.float32) |
|
|
| graph = Data( |
| x=x, |
| edge_index=edge_index, |
| edge_weight=edge_weight, |
| y=torch.from_numpy(sss_matrix), |
| ) |
| graph.stage_name = stage |
| graph.stage_index = torch.tensor([STAGE_ORDER.index(stage)], dtype=torch.long) |
| graph.node_names = node_names |
| graph.sss_matrix = torch.from_numpy(sss_matrix) |
| graph.affinity_matrix = torch.from_numpy(affinity.astype(np.float32)) |
| return graph |
|
|
|
|
| def build_temporal_graphs(csv_path: str | Path) -> TemporalGraphBundle: |
| """Build stage graphs from the flattened COSTE/SSS CSV.""" |
|
|
| df = load_relationship_table(csv_path) |
| node_names, sample_stage_map, sample_matrices = build_sample_matrices(df) |
| stage_matrices, sample_counts = aggregate_stage_matrices(sample_stage_map, sample_matrices) |
| stage_graphs = [matrix_to_graph(stage, stage_matrices[stage], node_names) for stage in STAGE_ORDER] |
|
|
| return TemporalGraphBundle( |
| node_names=node_names, |
| sample_stage_map=sample_stage_map, |
| sample_matrices=sample_matrices, |
| stage_matrices=stage_matrices, |
| stage_graphs=stage_graphs, |
| sample_counts=sample_counts, |
| ) |
|
|