koopman-graph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ """KoopmanGraph: topology-aware Koopman dynamics on graphs.
2
+
3
+ Public API
4
+ ----------
5
+ ``GraphKoopmanModel``
6
+ End-to-end encode → Koopman advance → decode model.
7
+ ``GNNEncoder``, ``GATEncoder``
8
+ GNN encoders for latent lifting.
9
+ ``GNNDecoder``
10
+ GNN decoder for physical reconstruction.
11
+ ``KoopmanOperator``
12
+ Learnable finite-dimensional Koopman matrix.
13
+ ``GraphSnapshotSequence``
14
+ Container for time-ordered PyG graph snapshots.
15
+ ``ForwardConsistencyLoss``
16
+ Latent-space linear evolution consistency loss.
17
+ ``BackwardConsistencyLoss``
18
+ Latent-space inverse linear evolution consistency loss.
19
+ ``FitHistory``
20
+ Training history returned by :meth:`~koopman_graph.model.GraphKoopmanModel.fit`.
21
+ ``LossWeights``
22
+ Weights for reconstruction and consistency loss terms.
23
+ ``__version__``
24
+ Package version string.
25
+ """
26
+
27
+ from koopman_graph.data import GraphSnapshotSequence
28
+ from koopman_graph.decoder import GNNDecoder
29
+ from koopman_graph.encoder import GATEncoder, GNNEncoder
30
+ from koopman_graph.losses import BackwardConsistencyLoss, ForwardConsistencyLoss
31
+ from koopman_graph.model import GraphKoopmanModel
32
+ from koopman_graph.operator import KoopmanOperator
33
+ from koopman_graph.training import FitHistory, LossWeights
34
+
35
+ __all__ = [
36
+ "BackwardConsistencyLoss",
37
+ "FitHistory",
38
+ "ForwardConsistencyLoss",
39
+ "GATEncoder",
40
+ "GNNDecoder",
41
+ "GNNEncoder",
42
+ "GraphKoopmanModel",
43
+ "GraphSnapshotSequence",
44
+ "KoopmanOperator",
45
+ "LossWeights",
46
+ "__version__",
47
+ ]
48
+ __version__ = "0.1.0"
koopman_graph/data.py ADDED
@@ -0,0 +1,253 @@
1
+ """Utilities for spatiotemporal graph snapshot sequences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Sequence
6
+
7
+ import numpy as np
8
+ import torch
9
+ from torch import Tensor
10
+ from torch_geometric.data import Data
11
+
12
+ ArrayLike = Tensor | np.ndarray
13
+
14
+
15
+ def _as_tensor(value: ArrayLike, *, dtype: torch.dtype | None = None) -> Tensor:
16
+ """Convert an array-like value to a :class:`torch.Tensor`.
17
+
18
+ Parameters
19
+ ----------
20
+ value : Tensor or ndarray
21
+ Input array or tensor.
22
+ dtype : torch.dtype, optional
23
+ Target dtype. When ``value`` is already a tensor, conversion is applied
24
+ only if the dtypes differ.
25
+
26
+ Returns
27
+ -------
28
+ Tensor
29
+ Tensor representation of ``value``.
30
+ """
31
+ if isinstance(value, Tensor):
32
+ if dtype is not None and value.dtype != dtype:
33
+ return value.to(dtype=dtype)
34
+ return value
35
+ return torch.as_tensor(value, dtype=dtype)
36
+
37
+
38
+ def _validate_shared_topology(snapshots: Sequence[Data]) -> None:
39
+ """Verify that all snapshots share node count, features, and topology.
40
+
41
+ Parameters
42
+ ----------
43
+ snapshots : sequence of Data
44
+ Graph snapshots to validate.
45
+
46
+ Raises
47
+ ------
48
+ ValueError
49
+ If the sequence is empty or any snapshot differs in ``edge_index``,
50
+ node count, or feature dimension from the first snapshot.
51
+ """
52
+ if not snapshots:
53
+ msg = "GraphSnapshotSequence requires at least one snapshot"
54
+ raise ValueError(msg)
55
+
56
+ reference = snapshots[0]
57
+ ref_edge_index = reference.edge_index
58
+ ref_num_nodes = reference.num_nodes
59
+ ref_in_channels = reference.x.shape[1]
60
+
61
+ for idx, snapshot in enumerate(snapshots[1:], start=1):
62
+ if snapshot.num_nodes != ref_num_nodes:
63
+ msg = (
64
+ f"Snapshot {idx} has {snapshot.num_nodes} nodes, "
65
+ f"expected {ref_num_nodes}"
66
+ )
67
+ raise ValueError(msg)
68
+ if snapshot.x.shape[1] != ref_in_channels:
69
+ msg = (
70
+ f"Snapshot {idx} has feature dimension {snapshot.x.shape[1]}, "
71
+ f"expected {ref_in_channels}"
72
+ )
73
+ raise ValueError(msg)
74
+ if not torch.equal(snapshot.edge_index, ref_edge_index):
75
+ msg = f"Snapshot {idx} has a different edge_index than snapshot 0"
76
+ raise ValueError(msg)
77
+
78
+
79
+ class GraphSnapshotSequence:
80
+ """Container for a time-ordered sequence of PyG ``Data`` graph snapshots.
81
+
82
+ All snapshots must share the same ``edge_index``, node count, and feature
83
+ dimension. Downstream training APIs should require at least two snapshots;
84
+ construction here allows a single snapshot for inspection or prediction-only
85
+ workflows.
86
+
87
+ Attributes
88
+ ----------
89
+ snapshots : list of Data
90
+ Time-ordered PyG graph snapshots sharing the same topology.
91
+ edge_index : Tensor
92
+ Shared edge index, shape ``(2, num_edges)``.
93
+ num_nodes : int
94
+ Number of nodes in the graph topology.
95
+ num_timesteps : int
96
+ Length of the temporal sequence.
97
+ in_channels : int
98
+ Node feature dimension shared across snapshots.
99
+ """
100
+
101
+ def __init__(self, snapshots: Sequence[Data]) -> None:
102
+ """Initialize from a sequence of graph snapshots.
103
+
104
+ Parameters
105
+ ----------
106
+ snapshots : sequence of Data
107
+ Time-ordered graph snapshots. Validated for shared topology on
108
+ construction.
109
+ """
110
+ snapshot_list = list(snapshots)
111
+ _validate_shared_topology(snapshot_list)
112
+ self._snapshots = snapshot_list
113
+
114
+ @classmethod
115
+ def from_arrays(
116
+ cls,
117
+ node_features: ArrayLike,
118
+ edge_index: ArrayLike,
119
+ *,
120
+ dtype: torch.dtype = torch.float32,
121
+ ) -> GraphSnapshotSequence:
122
+ """Build a sequence from node feature arrays and a shared topology.
123
+
124
+ Parameters
125
+ ----------
126
+ node_features : array-like
127
+ Array with shape ``(num_timesteps, num_nodes, in_channels)``.
128
+ edge_index : array-like
129
+ Shared edge index with shape ``(2, num_edges)``.
130
+ dtype : torch.dtype, optional
131
+ Floating dtype used when converting numpy inputs to torch tensors.
132
+ Default is ``torch.float32``.
133
+
134
+ Returns
135
+ -------
136
+ :class:`~koopman_graph.data.GraphSnapshotSequence`
137
+ Validated snapshot sequence.
138
+
139
+ Raises
140
+ ------
141
+ ValueError
142
+ If ``node_features`` or ``edge_index`` have invalid shape.
143
+ """
144
+ features = _as_tensor(node_features, dtype=dtype)
145
+ edges = _as_tensor(edge_index, dtype=torch.long)
146
+
147
+ if features.ndim != 3:
148
+ msg = (
149
+ f"node_features must have shape "
150
+ f"(num_timesteps, num_nodes, in_channels), got {tuple(features.shape)}"
151
+ )
152
+ raise ValueError(msg)
153
+ if edges.ndim != 2 or edges.shape[0] != 2:
154
+ msg = f"edge_index must have shape (2, num_edges), got {tuple(edges.shape)}"
155
+ raise ValueError(msg)
156
+ if features.shape[0] < 1:
157
+ msg = "node_features must contain at least one timestep"
158
+ raise ValueError(msg)
159
+
160
+ snapshots = [
161
+ Data(x=features[t], edge_index=edges) for t in range(features.shape[0])
162
+ ]
163
+ return cls(snapshots)
164
+
165
+ @property
166
+ def snapshots(self) -> list[Data]:
167
+ """Return the underlying list of graph snapshots.
168
+
169
+ Returns
170
+ -------
171
+ list of Data
172
+ Time-ordered PyG graph snapshots.
173
+ """
174
+ return self._snapshots
175
+
176
+ @property
177
+ def edge_index(self) -> Tensor:
178
+ """Return the shared edge index for all snapshots.
179
+
180
+ Returns
181
+ -------
182
+ Tensor
183
+ Edge index with shape ``(2, num_edges)``.
184
+ """
185
+ return self._snapshots[0].edge_index
186
+
187
+ @property
188
+ def num_nodes(self) -> int:
189
+ """Return the number of nodes in the graph topology.
190
+
191
+ Returns
192
+ -------
193
+ int
194
+ Node count shared across all snapshots.
195
+ """
196
+ return int(self._snapshots[0].num_nodes)
197
+
198
+ @property
199
+ def num_timesteps(self) -> int:
200
+ """Return the number of timesteps in the sequence.
201
+
202
+ Returns
203
+ -------
204
+ int
205
+ Length of the temporal sequence.
206
+ """
207
+ return len(self._snapshots)
208
+
209
+ @property
210
+ def in_channels(self) -> int:
211
+ """Return the node feature dimension.
212
+
213
+ Returns
214
+ -------
215
+ int
216
+ Feature dimension shared across all snapshots.
217
+ """
218
+ return int(self._snapshots[0].x.shape[1])
219
+
220
+ def __len__(self) -> int:
221
+ """Return the number of timesteps in the sequence.
222
+
223
+ Returns
224
+ -------
225
+ int
226
+ Same value as :attr:`num_timesteps`.
227
+ """
228
+ return len(self._snapshots)
229
+
230
+ def __getitem__(self, index: int) -> Data:
231
+ """Return the graph snapshot at ``index``.
232
+
233
+ Parameters
234
+ ----------
235
+ index : int
236
+ Timestep index.
237
+
238
+ Returns
239
+ -------
240
+ Data
241
+ Graph snapshot at the requested timestep.
242
+ """
243
+ return self._snapshots[index]
244
+
245
+ def __iter__(self) -> Iterator[Data]:
246
+ """Iterate over graph snapshots in temporal order.
247
+
248
+ Yields
249
+ ------
250
+ Data
251
+ Graph snapshot at each timestep.
252
+ """
253
+ return iter(self._snapshots)
@@ -0,0 +1,31 @@
1
+ """Dataset utilities for KoopmanGraph benchmarks and examples.
2
+
3
+ Public benchmarks
4
+ -----------------
5
+ SyntheticDynamicGraphBenchmark
6
+ Reproducible Laplacian-diffusion dynamics on path/ring topologies.
7
+ GridDynamicGraphBenchmark
8
+ Laplacian diffusion on a 4-connected 2D lattice graph.
9
+ AnisotropicAdvectionGridBenchmark
10
+ Directional advection on a grid with asymmetric neighbor weights.
11
+ IEEE118DynamicBenchmark
12
+ IEEE 118-bus MATPOWER topology with simulated voltage/load dynamics.
13
+ MetrLaTrafficBenchmark
14
+ METR-LA traffic-speed sensor graph with cached speed snapshots.
15
+ """
16
+
17
+ from koopman_graph.datasets.grid import (
18
+ AnisotropicAdvectionGridBenchmark,
19
+ GridDynamicGraphBenchmark,
20
+ )
21
+ from koopman_graph.datasets.ieee118 import IEEE118DynamicBenchmark
22
+ from koopman_graph.datasets.metr_la import MetrLaTrafficBenchmark
23
+ from koopman_graph.datasets.synthetic import SyntheticDynamicGraphBenchmark
24
+
25
+ __all__ = [
26
+ "AnisotropicAdvectionGridBenchmark",
27
+ "GridDynamicGraphBenchmark",
28
+ "IEEE118DynamicBenchmark",
29
+ "MetrLaTrafficBenchmark",
30
+ "SyntheticDynamicGraphBenchmark",
31
+ ]
@@ -0,0 +1,309 @@
1
+ """Shared Laplacian diffusion dynamics for benchmark datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ import torch
8
+ from torch import Tensor
9
+
10
+ from koopman_graph.data import GraphSnapshotSequence
11
+
12
+ InitialStateName = Literal["random", "ones"]
13
+
14
+
15
+ def normalized_step_operator(
16
+ edge_index: Tensor,
17
+ num_nodes: int,
18
+ diffusion_rate: float,
19
+ *,
20
+ dtype: torch.dtype,
21
+ ) -> Tensor:
22
+ """Build one-step Laplacian diffusion operator ``I - alpha * L_sym``.
23
+
24
+ The symmetric normalized Laplacian is ``L_sym = I - D^{-1/2} A D^{-1/2}``.
25
+ The returned operator is ``(1 - alpha) * I + alpha * D^{-1/2} A D^{-1/2}``.
26
+
27
+ Parameters
28
+ ----------
29
+ edge_index : Tensor
30
+ Edge index with shape ``(2, num_edges)``.
31
+ num_nodes : int
32
+ Number of graph nodes.
33
+ diffusion_rate : float
34
+ Diffusion strength ``alpha`` in ``[0, 1]``.
35
+ dtype : torch.dtype
36
+ Floating dtype for the dense operator.
37
+
38
+ Returns
39
+ -------
40
+ Tensor
41
+ Step operator with shape ``(num_nodes, num_nodes)``.
42
+ """
43
+ row, col = edge_index
44
+ deg = torch.zeros(num_nodes, dtype=dtype)
45
+ deg.index_add_(0, row, torch.ones(row.size(0), dtype=dtype))
46
+ deg_inv_sqrt = deg.pow(-0.5)
47
+ deg_inv_sqrt[torch.isinf(deg_inv_sqrt)] = 0.0
48
+
49
+ norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]
50
+ adj = torch.zeros((num_nodes, num_nodes), dtype=dtype)
51
+ adj[row, col] = norm
52
+
53
+ eye = torch.eye(num_nodes, dtype=dtype)
54
+ return (1.0 - diffusion_rate) * eye + diffusion_rate * adj
55
+
56
+
57
+ def make_generator(seed: int | None) -> torch.Generator | None:
58
+ """Return a seeded torch generator when ``seed`` is provided.
59
+
60
+ Parameters
61
+ ----------
62
+ seed : int or None
63
+ Random seed. When ``None``, no generator is created.
64
+
65
+ Returns
66
+ -------
67
+ torch.Generator or None
68
+ Seeded generator, or ``None`` when ``seed`` is ``None``.
69
+ """
70
+ if seed is None:
71
+ return None
72
+ generator = torch.Generator()
73
+ generator.manual_seed(seed)
74
+ return generator
75
+
76
+
77
+ def validate_diffusion_generation_params(
78
+ *,
79
+ decay_rate: float,
80
+ noise_std: float,
81
+ diffusion_rate: float | None = None,
82
+ initial_state: InitialStateName | None = None,
83
+ ) -> None:
84
+ """Validate shared diffusion benchmark generation parameters.
85
+
86
+ Parameters
87
+ ----------
88
+ decay_rate : float
89
+ Global amplitude decay applied each diffusion step.
90
+ noise_std : float
91
+ Standard deviation of additive Gaussian noise.
92
+ diffusion_rate : float or None, optional
93
+ Laplacian diffusion strength in ``[0, 1]``. Validated when provided.
94
+ initial_state : {"random", "ones"} or None, optional
95
+ Initial node feature pattern. Validated when provided.
96
+
97
+ Raises
98
+ ------
99
+ ValueError
100
+ If any parameter is outside its allowed range.
101
+ """
102
+ if diffusion_rate is not None and not 0.0 <= diffusion_rate <= 1.0:
103
+ msg = f"diffusion_rate must be in [0, 1], got {diffusion_rate}"
104
+ raise ValueError(msg)
105
+ if decay_rate <= 0.0:
106
+ msg = f"decay_rate must be > 0, got {decay_rate}"
107
+ raise ValueError(msg)
108
+ if noise_std < 0.0:
109
+ msg = f"noise_std must be >= 0, got {noise_std}"
110
+ raise ValueError(msg)
111
+ if initial_state is not None and initial_state not in {"random", "ones"}:
112
+ msg = f"initial_state must be 'random' or 'ones', got {initial_state!r}"
113
+ raise ValueError(msg)
114
+
115
+
116
+ def initial_node_features(
117
+ num_nodes: int,
118
+ in_channels: int,
119
+ initial_state: InitialStateName,
120
+ *,
121
+ generator: torch.Generator | None,
122
+ dtype: torch.dtype,
123
+ ) -> Tensor:
124
+ """Create the initial node feature matrix for diffusion benchmarks.
125
+
126
+ Parameters
127
+ ----------
128
+ num_nodes : int
129
+ Number of graph nodes.
130
+ in_channels : int
131
+ Node feature dimension.
132
+ initial_state : {"random", "ones"}
133
+ Feature initialization pattern.
134
+ generator : torch.Generator or None
135
+ Optional RNG used when ``initial_state="random"``.
136
+ dtype : torch.dtype
137
+ Floating dtype for the returned tensor.
138
+
139
+ Returns
140
+ -------
141
+ Tensor
142
+ Initial node features with shape ``(num_nodes, in_channels)``.
143
+ """
144
+ if initial_state == "ones":
145
+ return torch.ones((num_nodes, in_channels), dtype=dtype)
146
+ return torch.randn(
147
+ (num_nodes, in_channels),
148
+ generator=generator,
149
+ dtype=dtype,
150
+ )
151
+
152
+
153
+ def apply_laplacian_diffusion_step(
154
+ state: Tensor,
155
+ step_operator: Tensor,
156
+ decay_rate: float,
157
+ ) -> Tensor:
158
+ """Advance node features by one Laplacian diffusion step.
159
+
160
+ Parameters
161
+ ----------
162
+ state : Tensor
163
+ Current node features with shape ``(num_nodes, in_channels)``.
164
+ step_operator : Tensor
165
+ One-step diffusion operator with shape ``(num_nodes, num_nodes)``.
166
+ decay_rate : float
167
+ Global amplitude decay applied to the diffused state.
168
+
169
+ Returns
170
+ -------
171
+ Tensor
172
+ Updated node features with the same shape as ``state``.
173
+ """
174
+ return decay_rate * (step_operator @ state)
175
+
176
+
177
+ def add_gaussian_noise(
178
+ state: Tensor,
179
+ noise_std: float,
180
+ *,
181
+ generator: torch.Generator | None,
182
+ dtype: torch.dtype,
183
+ ) -> Tensor:
184
+ """Add optional isotropic Gaussian noise to node features.
185
+
186
+ Parameters
187
+ ----------
188
+ state : Tensor
189
+ Node features to perturb.
190
+ noise_std : float
191
+ Standard deviation of additive Gaussian noise. No noise is added when
192
+ ``noise_std <= 0``.
193
+ generator : torch.Generator or None
194
+ Optional RNG for drawing noise samples.
195
+ dtype : torch.dtype
196
+ Floating dtype for generated noise.
197
+
198
+ Returns
199
+ -------
200
+ Tensor
201
+ Perturbed node features with the same shape as ``state``.
202
+ """
203
+ if noise_std <= 0.0:
204
+ return state
205
+ noise = torch.randn(state.shape, generator=generator, dtype=dtype)
206
+ return state + noise_std * noise
207
+
208
+
209
+ def laplacian_diffusion_rollout(
210
+ *,
211
+ edge_index: Tensor,
212
+ num_nodes: int,
213
+ num_timesteps: int,
214
+ in_channels: int,
215
+ diffusion_rate: float,
216
+ decay_rate: float,
217
+ noise_std: float,
218
+ initial_state: InitialStateName,
219
+ dtype: torch.dtype,
220
+ generator: torch.Generator | None,
221
+ initial_features: Tensor | None = None,
222
+ ) -> Tensor:
223
+ """Simulate Laplacian diffusion dynamics and return stacked features.
224
+
225
+ Parameters
226
+ ----------
227
+ edge_index : Tensor
228
+ Shared graph topology.
229
+ num_nodes : int
230
+ Number of nodes in the graph.
231
+ num_timesteps : int
232
+ Number of temporal snapshots to generate.
233
+ in_channels : int
234
+ Node feature dimension.
235
+ diffusion_rate : float
236
+ Laplacian diffusion strength in ``[0, 1]``.
237
+ decay_rate : float
238
+ Global amplitude decay applied each step.
239
+ noise_std : float
240
+ Standard deviation of additive Gaussian noise.
241
+ initial_state : {"random", "ones"}
242
+ Initial node feature pattern when ``initial_features`` is ``None``.
243
+ dtype : torch.dtype
244
+ Floating dtype for generated features.
245
+ generator : torch.Generator or None
246
+ Optional RNG for initial state and noise.
247
+ initial_features : Tensor or None, optional
248
+ Explicit initial node features with shape ``(num_nodes, in_channels)``.
249
+
250
+ Returns
251
+ -------
252
+ Tensor
253
+ Node features with shape ``(num_timesteps, num_nodes, in_channels)``.
254
+ """
255
+ step_operator = normalized_step_operator(
256
+ edge_index,
257
+ num_nodes,
258
+ diffusion_rate,
259
+ dtype=dtype,
260
+ )
261
+ state = (
262
+ initial_features.clone()
263
+ if initial_features is not None
264
+ else initial_node_features(
265
+ num_nodes,
266
+ in_channels,
267
+ initial_state,
268
+ generator=generator,
269
+ dtype=dtype,
270
+ )
271
+ )
272
+
273
+ snapshots = [state.clone()]
274
+ for _ in range(num_timesteps - 1):
275
+ state = apply_laplacian_diffusion_step(state, step_operator, decay_rate)
276
+ state = add_gaussian_noise(
277
+ state,
278
+ noise_std,
279
+ generator=generator,
280
+ dtype=dtype,
281
+ )
282
+ snapshots.append(state.clone())
283
+
284
+ return torch.stack(snapshots, dim=0)
285
+
286
+
287
+ def diffusion_sequence_from_features(
288
+ features: Tensor,
289
+ edge_index: Tensor,
290
+ *,
291
+ dtype: torch.dtype = torch.float32,
292
+ ) -> GraphSnapshotSequence:
293
+ """Wrap rollout features and topology in a validated snapshot sequence.
294
+
295
+ Parameters
296
+ ----------
297
+ features : Tensor
298
+ Node features with shape ``(num_timesteps, num_nodes, in_channels)``.
299
+ edge_index : Tensor
300
+ Shared edge index with shape ``(2, num_edges)``.
301
+ dtype : torch.dtype, optional
302
+ Floating dtype used when building snapshots. Default is ``torch.float32``.
303
+
304
+ Returns
305
+ -------
306
+ :class:`~koopman_graph.data.GraphSnapshotSequence`
307
+ Validated time-ordered snapshot sequence.
308
+ """
309
+ return GraphSnapshotSequence.from_arrays(features, edge_index, dtype=dtype)