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.
- koopman_graph/__init__.py +48 -0
- koopman_graph/data.py +253 -0
- koopman_graph/datasets/__init__.py +31 -0
- koopman_graph/datasets/dynamics.py +309 -0
- koopman_graph/datasets/grid.py +417 -0
- koopman_graph/datasets/ieee118.py +507 -0
- koopman_graph/datasets/metr_la.py +528 -0
- koopman_graph/datasets/synthetic.py +216 -0
- koopman_graph/decoder.py +86 -0
- koopman_graph/encoder.py +467 -0
- koopman_graph/losses.py +193 -0
- koopman_graph/model.py +406 -0
- koopman_graph/operator.py +120 -0
- koopman_graph/training.py +575 -0
- koopman_graph-0.1.0.dist-info/METADATA +260 -0
- koopman_graph-0.1.0.dist-info/RECORD +19 -0
- koopman_graph-0.1.0.dist-info/WHEEL +5 -0
- koopman_graph-0.1.0.dist-info/licenses/LICENSE +201 -0
- koopman_graph-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|