cyclebane 24.5.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.
cyclebane/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ # ruff: noqa: E402, F401
4
+
5
+ import importlib.metadata
6
+
7
+ try:
8
+ __version__ = importlib.metadata.version(__package__ or __name__)
9
+ except importlib.metadata.PackageNotFoundError:
10
+ __version__ = "0.0.0"
11
+
12
+ del importlib
13
+
14
+ from .graph import Graph
15
+
16
+ __all__ = ["Graph"]
cyclebane/graph.py ADDED
@@ -0,0 +1,461 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Generator, Hashable, Iterable
7
+ from uuid import uuid4
8
+
9
+ import networkx as nx
10
+
11
+ from .node_values import IndexName, IndexValue, NodeValues
12
+
13
+
14
+ def _get_unique_sink(graph: nx.DiGraph) -> Hashable:
15
+ sink_nodes = [node for node in graph.nodes if graph.out_degree(node) == 0]
16
+ if len(sink_nodes) != 1:
17
+ raise ValueError('Graph must have exactly one sink node')
18
+ return sink_nodes[0]
19
+
20
+
21
+ def _get_new_node_name(graph: nx.DiGraph) -> str:
22
+ while True:
23
+ name = str(uuid4())
24
+ if name not in graph:
25
+ return name
26
+
27
+
28
+ def _remove_ancestors(graph: nx.DiGraph, node: Hashable) -> nx.DiGraph:
29
+ graph = graph.copy()
30
+ ancestors = nx.ancestors(graph, node)
31
+ ancestors_successors = {
32
+ ancestor: graph.successors(ancestor) for ancestor in ancestors
33
+ }
34
+ to_remove = []
35
+ for ancestor, successors in ancestors_successors.items():
36
+ # If any successor does not have node as descendant we must keep the node
37
+ if all(nx.has_path(graph, successor, node) for successor in successors):
38
+ to_remove.append(ancestor)
39
+ graph.remove_nodes_from(to_remove)
40
+ graph.remove_edges_from(list(graph.in_edges(node)))
41
+ return graph
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class IndexValues:
46
+ """
47
+ Index values used as part of :py:class:`NodeName`.
48
+
49
+ Conceptually, this is a mapping from index names to index values.
50
+ """
51
+
52
+ axes: tuple[IndexName, ...]
53
+ values: tuple[IndexValue, ...]
54
+
55
+ @staticmethod
56
+ def from_tuple(t: tuple[tuple[IndexName, IndexValue], ...]) -> IndexValues:
57
+ names = tuple(name for name, _ in t)
58
+ values = tuple(value for _, value in t)
59
+ return IndexValues(axes=names, values=values)
60
+
61
+ def to_tuple(self) -> tuple[tuple[IndexName, IndexValue], ...]:
62
+ return tuple(zip(self.axes, self.values))
63
+
64
+ def merge_index(self, other: IndexValues) -> IndexValues:
65
+ return IndexValues(
66
+ axes=other.axes + self.axes, values=other.values + self.values
67
+ )
68
+
69
+ def __str__(self) -> str:
70
+ return ', '.join(
71
+ f'{name}={value}' for name, value in zip(self.axes, self.values)
72
+ )
73
+
74
+ def __len__(self) -> int:
75
+ return len(self.axes)
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class NodeName:
80
+ """Node name with indices used for mapped nodes when converting to NetworkX."""
81
+
82
+ name: Hashable
83
+ index: IndexValues
84
+
85
+ def merge_index(self, other: IndexValues) -> NodeName:
86
+ return NodeName(name=self.name, index=self.index.merge_index(other))
87
+
88
+ def __str__(self) -> str:
89
+ return f'{self.name}({self.index})'
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class MappedNode:
94
+ """
95
+ Key for a node in :py:class:`Graph` representing a collection of "mapped" nodes.
96
+ """
97
+
98
+ name: Hashable
99
+ indices: tuple[IndexName, ...]
100
+
101
+
102
+ def _node_with_indices(node: Hashable, indices: tuple[IndexName, ...]) -> MappedNode:
103
+ if isinstance(node, MappedNode):
104
+ return MappedNode(name=node.name, indices=indices + node.indices)
105
+ return MappedNode(name=node, indices=indices)
106
+
107
+
108
+ def _node_indices(node: Hashable) -> tuple[IndexName, ...]:
109
+ if isinstance(node, MappedNode):
110
+ return node.indices
111
+ return ()
112
+
113
+
114
+ def _find_successors(
115
+ graph: nx.DiGraph, *, root_nodes: tuple[Hashable]
116
+ ) -> set[Hashable]:
117
+ successors = set()
118
+ for root in root_nodes:
119
+ if graph.in_degree(root) > 0:
120
+ raise ValueError(f"Mapped node '{root}' is not a source node")
121
+ successors.update(nx.descendants(graph, source=root) | {root})
122
+ return successors
123
+
124
+
125
+ def _rename_successors(
126
+ graph: nx.DiGraph, *, successors: Iterable[Hashable], index: IndexValues
127
+ ) -> nx.DiGraph:
128
+ """Replace 'node' and all its successors with (node, suffix), and update all edges
129
+ accordingly."""
130
+ renamed_nodes = {
131
+ node: (
132
+ node.merge_index(index)
133
+ if isinstance(node, NodeName)
134
+ else NodeName(name=node, index=index)
135
+ )
136
+ for node in successors
137
+ }
138
+ return nx.relabel_nodes(graph, renamed_nodes, copy=True)
139
+
140
+
141
+ def _yield_index(
142
+ indices: list[tuple[IndexName, Iterable[IndexValue]]]
143
+ ) -> Generator[tuple[tuple[IndexName, IndexValue], ...], None, None]:
144
+ """Given a multi-dimensional index, yield all possible combinations."""
145
+ name, index = indices[0]
146
+ for index_value in index:
147
+ if len(indices) == 1:
148
+ yield ((name, index_value),)
149
+ else:
150
+ for rest in _yield_index(indices[1:]):
151
+ yield ((name, index_value),) + rest
152
+
153
+
154
+ class PositionalIndexer:
155
+ """
156
+ Helper class to allow slicing a named dim of a graph using positional indexing.
157
+ """
158
+
159
+ def __init__(self, graph: Graph, index_name: IndexName):
160
+ self.graph = graph
161
+ self.index_name = index_name
162
+
163
+ def __getitem__(self, key: int | slice) -> Graph:
164
+ # Supporting single indices may be conceptually ill-defined if the index
165
+ # `reduce` was applied to the graph, so we might never support this.
166
+ if isinstance(key, int):
167
+ raise NotImplementedError('Only slices are supported')
168
+ node_values = NodeValues(
169
+ {
170
+ name: (
171
+ col.loc({self.index_name: key})
172
+ if self.index_name in col.index_names
173
+ else col
174
+ )
175
+ for name, col in self.graph._node_values.items()
176
+ }
177
+ )
178
+ return Graph(self.graph.graph, node_values=node_values)
179
+
180
+
181
+ MappingToArrayLike = Any # dict[str, Numpy|DataArray], DataFrame, etc.
182
+
183
+
184
+ class Graph:
185
+ """
186
+ A Cyclebane graph is a directed acyclic graph with additional array-like structure.
187
+
188
+ The array-like structure selectively affects nodes in the graph by associating
189
+ source nodes with an array-like object. These source node and all their descendants
190
+ thus gain an additional index (or "dimension").
191
+
192
+ Nomenclature:
193
+
194
+ - Index: As in Pandas, and index is a sequence of values that label an axis.
195
+ - Index-value: A single value in an index.
196
+ - Index-name: The name of an index.
197
+
198
+
199
+ Notes
200
+ -----
201
+ The current implementation is not complete, there is a number of things to
202
+ improve:
203
+ - Overall, I would like to reduce the array-handling code and transparently forward
204
+ to the slicing code of the underlying array-like object (Pandas, NumPy, Xarray,
205
+ Scipp). Basically, we would like to use the slicing methods of the underlying
206
+ object. This may not be trivial, since we might mix different types of array-like
207
+ objects at nodes with multiple predecessors.
208
+ """
209
+
210
+ def __init__(self, graph: nx.DiGraph, *, node_values: NodeValues | None = None):
211
+ """
212
+ Initialize a graph from a directed NetworkX graph.
213
+
214
+ Parameters
215
+ ----------
216
+ graph:
217
+ The directed graph representing the data flow.
218
+ node_values:
219
+ A mapping from source node names to array-like objects. The implementation
220
+ assumes that the graph has been setup correctly. Do not use this argument
221
+ unless you know what you are doing.
222
+ """
223
+ self.graph = graph
224
+ self._node_values = node_values or NodeValues({})
225
+
226
+ def copy(self) -> Graph:
227
+ return Graph(self.graph.copy(), node_values=self._node_values)
228
+
229
+ @property
230
+ def index_names(self) -> tuple[IndexName, ...]:
231
+ """Names of the indices (dimensions) of the graph."""
232
+ return tuple(self.indices)
233
+
234
+ @property
235
+ def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
236
+ """Names and values of the indices of the graph."""
237
+ return self._node_values.indices
238
+
239
+ def map(self, node_values: MappingToArrayLike) -> Graph:
240
+ """
241
+ Map the graph over the given values by associating source nodes with values.
242
+
243
+ All successors of the mapped source nodes are replaced with new nodes, one for
244
+ each index value. The value is set as an attribute on the new source nodes
245
+ (but not their successors).
246
+
247
+ Parameters
248
+ ----------
249
+ node_values:
250
+ A mapping from source node names to array-like objects. The source nodes
251
+ are the roots of the branches to be mapped. The array-like objects must
252
+ support slicing, e.g., NumPy arrays, Xarray DataArrays, Pandas DataFrames,
253
+ etc.
254
+ """
255
+ new_values = NodeValues.from_mapping(
256
+ node_values, axis_zero=len(self.index_names)
257
+ )
258
+
259
+ # Make sure root nodes exist in graph, add them if not. This choice allows for
260
+ # mapping, e.g., with multiple columns from a DataFrame, representing labels
261
+ # used later for groupby operations.
262
+ graph = self.graph.copy()
263
+ graph.add_nodes_from(new_values)
264
+
265
+ successors = _find_successors(graph, root_nodes=new_values)
266
+ name_mapping: dict[Hashable, MappedNode] = {}
267
+ for node in successors:
268
+ name_mapping[node] = _node_with_indices(node, tuple(new_values.indices))
269
+
270
+ return Graph(
271
+ nx.relabel_nodes(graph, name_mapping),
272
+ node_values=self._node_values.merge(new_values),
273
+ )
274
+
275
+ def reduce(
276
+ self,
277
+ key: None | Hashable = None,
278
+ *,
279
+ index: None | Hashable = None,
280
+ axis: None | int = None,
281
+ name: None | Hashable = None,
282
+ attrs: None | dict[str, Any] = None,
283
+ ) -> Graph:
284
+ """
285
+ Reduce over the given index or axis previously created with :py:meth:`map`.
286
+
287
+ If neither index nor axis is given, all axes are reduced.
288
+
289
+ Parameters
290
+ ----------
291
+ key:
292
+ The name of the source node to reduce. This is the original name prior to
293
+ mapping. If not given, tries to find a unique sink node.
294
+ index:
295
+ The name of the index to reduce over. Only one of index and axis can be
296
+ given.
297
+ axis:
298
+ Integer axis index to reduce over. Only one of index and axis can be given.
299
+ name:
300
+ The name of the new node. If not given, a unique name is generated.
301
+ attrs:
302
+ Attributes to set on the new node(s).
303
+ """
304
+ key = key or _get_unique_sink(self.graph)
305
+ name = name or _get_new_node_name(self.graph)
306
+
307
+ attrs = attrs or {}
308
+ if index is not None and axis is not None:
309
+ raise ValueError('Only one of index and axis can be given')
310
+ key = self._from_orig_key(key)
311
+ indices = _node_indices(key)
312
+ if index is not None and index not in indices:
313
+ raise ValueError(f"Node '{key}' does not have index '{index}'.")
314
+ # TODO We can support indexing from the back in the future.
315
+ if axis is not None and (axis < 0 or axis >= len(indices)):
316
+ raise ValueError(f"Node '{key}' does not have axis '{axis}'.")
317
+ if index is not None:
318
+ new_index = tuple(value for value in indices if value != index)
319
+ elif axis is not None:
320
+ # TODO Should axis refer to axes of graph, or the node?
321
+ new_index = tuple(value for i, value in enumerate(indices) if i != axis)
322
+ else:
323
+ new_index = None
324
+ if name in self.graph:
325
+ raise ValueError(f"Node '{name}' already exists in the graph.")
326
+
327
+ graph = self.graph.copy()
328
+ name = MappedNode(name=name, indices=new_index) if new_index else name
329
+ graph.add_node(name, **attrs)
330
+ graph.add_edge(key, name)
331
+
332
+ return Graph(graph, node_values=self._node_values)
333
+
334
+ def _from_orig_key(self, key: Hashable) -> Hashable:
335
+ # Graph.map relabels nodes to include index names, which can be inconvenient
336
+ # for the user. Is this convenience of finding the node by its original name
337
+ # worth the complexity and a good idea?
338
+ if key not in self.graph:
339
+ matches = [
340
+ node
341
+ for node in self.graph.nodes
342
+ if isinstance(node, MappedNode) and node.name == key
343
+ ]
344
+ if len(matches) == 0:
345
+ raise KeyError(f"Node '{key}' does not exist in the graph.")
346
+ if len(matches) > 1:
347
+ raise KeyError(f"Node '{key}' is ambiguous. Found {matches}.")
348
+ return matches[0]
349
+ return key
350
+
351
+ def by_position(self, index_name: IndexName) -> PositionalIndexer:
352
+ return PositionalIndexer(self, index_name)
353
+
354
+ def to_networkx(self, value_attr: str = 'value') -> nx.DiGraph:
355
+ """
356
+ Convert to a NetworkX graph, spelling out the internal array structures as
357
+ explicit nodes.
358
+
359
+ Parameters
360
+ ----------
361
+ value_attr:
362
+ The name of the attribute on nodes that holds the array-like object.
363
+ """
364
+ graph = self.graph
365
+ for index_name, index in reversed(self.indices.items()):
366
+ # Find all nodes with this index
367
+ nodes = []
368
+ for node in graph.nodes():
369
+ if index_name in _node_indices(
370
+ node.name if isinstance(node, NodeName) else node
371
+ ):
372
+ nodes.append(node)
373
+ # Make a copy for each index value
374
+ graphs = [
375
+ _rename_successors(
376
+ graph, successors=nodes, index=IndexValues.from_tuple(i)
377
+ )
378
+ for i in _yield_index([(index_name, index)])
379
+ ]
380
+ graph = nx.compose_all(graphs)
381
+ # Replace all MappingNodes with their name
382
+ new_names = {
383
+ node: NodeName(node.name.name, node.index)
384
+ for node in graph
385
+ if isinstance(node, NodeName) and isinstance(node.name, MappedNode)
386
+ }
387
+ graph = nx.relabel_nodes(graph, new_names)
388
+
389
+ # Get values using previously stored index values
390
+ for node in graph.nodes:
391
+ if (
392
+ isinstance(node, NodeName)
393
+ and (node_values := self._node_values.get(node.name)) is not None
394
+ ):
395
+ graph.nodes[node][value_attr] = node_values.sel(node.index.to_tuple())
396
+
397
+ return graph
398
+
399
+ def __getitem__(self, key: Hashable | slice) -> Graph:
400
+ """
401
+ Get the branch of the graph rooted at the given node.
402
+
403
+ The branch is a subgraph containing the given node and all its ancestors.
404
+ Think of this like a Git branch, where the given node is the head of the branch.
405
+ """
406
+ if isinstance(key, slice):
407
+ raise NotImplementedError('Only single nodes are supported ')
408
+ key = self._from_orig_key(key)
409
+ ancestors = nx.ancestors(self.graph, key)
410
+ ancestors.add(key)
411
+ # Drop all node values that are not in the branch
412
+ mapped = set(a.name for a in ancestors if isinstance(a, MappedNode))
413
+ keep_values = [key for key in self._node_values.keys() if key in mapped]
414
+ return Graph(
415
+ self.graph.subgraph(ancestors),
416
+ node_values=self._node_values.get_columns(keep_values),
417
+ )
418
+
419
+ def __setitem__(self, branch: Hashable | slice, other: Graph) -> None:
420
+ """
421
+ Set a new branch in place of the given branch.
422
+
423
+ The new branch must have a unique sink node. The branch at `branch` is replaced
424
+ with the new branch. The indices and node values are updated accordingly. The
425
+ edges to successors of the old branch are connected to the sink of the new
426
+ branch.
427
+ """
428
+ if isinstance(branch, slice):
429
+ raise NotImplementedError('Setting slice not supported yet.')
430
+ if not isinstance(other, Graph):
431
+ raise TypeError(f'Expected {Graph}, got {type(other)}')
432
+ new_branch = other.graph
433
+ sink = _get_unique_sink(new_branch)
434
+ # In the future, we could support this if BOTH sink and branch are MappedNodes
435
+ # with identical indices.
436
+ if isinstance(sink, MappedNode) or isinstance(branch, MappedNode):
437
+ raise NotImplementedError('Mapped nodes not supported yet in __setitem__')
438
+ new_branch = nx.relabel_nodes(new_branch, {sink: branch})
439
+ if branch in self.graph:
440
+ graph = _remove_ancestors(self.graph, branch)
441
+ graph.nodes[branch].clear()
442
+ else:
443
+ graph = self.graph
444
+
445
+ intersection_nodes = set(graph.nodes) & set(new_branch.nodes) - {branch}
446
+
447
+ for node in intersection_nodes:
448
+ if graph.pred[node] != new_branch.pred[node]:
449
+ raise ValueError(
450
+ f"Node inputs differ for node '{node}':\n"
451
+ f" {graph.pred[node]}\n"
452
+ f" {new_branch.pred[node]}\n"
453
+ )
454
+ if graph.nodes[node] != new_branch.nodes[node]:
455
+ raise ValueError(f"Node data differs for node '{node}'")
456
+
457
+ graph = nx.compose(graph, new_branch)
458
+
459
+ # Delay setting graph until we know no step fails
460
+ self._node_values = self._node_values.merge(other._node_values)
461
+ self.graph = graph
@@ -0,0 +1,400 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections import abc
7
+ from typing import TYPE_CHECKING, Any, Hashable, Iterable, Mapping, Sequence
8
+
9
+ if TYPE_CHECKING:
10
+ import numpy
11
+ import pandas
12
+ import scipp
13
+ import xarray
14
+
15
+ IndexName = Hashable
16
+ IndexValue = Hashable
17
+
18
+
19
+ class ValueArray(ABC):
20
+ """
21
+ Abstract base class for a series of values with an index that can be sliced.
22
+
23
+ Used by :py:class:`NodeValues` to store the values of a given node in a graph. The
24
+ abstraction allows for the use of different data structures to store the values of
25
+ nodes in a graph, such as pandas.DataFrame, xarray.DataArray, numpy.ndarray, or
26
+ simple Python iterables.
27
+ """
28
+
29
+ _registry = []
30
+
31
+ def __init_subclass__(cls) -> None:
32
+ super().__init_subclass__()
33
+ ValueArray._registry.append(cls)
34
+
35
+ @staticmethod
36
+ def from_array_like(values: Any, *, axis_zero: int = 0) -> ValueArray:
37
+ # Reversed to ensure SequenceAdapter is tried last, as it is the most general
38
+ # SequenceAdapter is defined right after this class so it is registered first
39
+ for subclass in reversed(ValueArray._registry):
40
+ if (a := subclass.try_from(values, axis_zero=axis_zero)) is not None:
41
+ return a
42
+ raise ValueError(f'Cannot create ValueArray from {values}')
43
+
44
+ @staticmethod
45
+ @abstractmethod
46
+ def try_from(obj: Any, *, axis_zero: int = 0) -> ValueArray | None:
47
+ ...
48
+
49
+ @abstractmethod
50
+ def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
51
+ """Return data by selecting from index with given name and index value."""
52
+
53
+ def loc(self, key: dict[IndexName, slice]) -> ValueArray:
54
+ if not all(isinstance(i, slice) for i in key.values()):
55
+ raise ValueError('ValueArray.loc only accepts slices, not integers')
56
+ if not set(key).issubset(set(self.index_names)):
57
+ raise ValueError(
58
+ f'ValueArray.loc got {key.keys()}, not a subset of {self.index_names}'
59
+ )
60
+ return self[key]
61
+
62
+ @abstractmethod
63
+ def __getitem__(self, key: dict[IndexName, slice]) -> ValueArray:
64
+ pass
65
+
66
+ @property
67
+ @abstractmethod
68
+ def shape(self) -> tuple[int, ...]:
69
+ pass
70
+
71
+ @property
72
+ @abstractmethod
73
+ def index_names(self) -> tuple[IndexName, ...]:
74
+ pass
75
+
76
+ @property
77
+ @abstractmethod
78
+ def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
79
+ pass
80
+
81
+
82
+ class SequenceAdapter(ValueArray):
83
+ def __init__(
84
+ self,
85
+ values: Sequence[Any],
86
+ *,
87
+ index: Iterable[IndexValue] | None = None,
88
+ axis_zero: int = 0,
89
+ ):
90
+ self._values = values
91
+ self._index = index or range(len(values))
92
+ self._axis_zero = axis_zero
93
+
94
+ @staticmethod
95
+ def try_from(obj: Any, *, axis_zero: int = 0) -> SequenceAdapter | None:
96
+ return SequenceAdapter(obj, axis_zero=axis_zero)
97
+
98
+ def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
99
+ if len(key) != 1:
100
+ raise ValueError('SequenceAdapter only supports single index')
101
+ _, i = key[0]
102
+ return self._values[self._index.index(i)]
103
+
104
+ def __getitem__(self, key: dict[IndexName, slice]) -> SequenceAdapter:
105
+ _, i = next(iter(key.items()))
106
+ return SequenceAdapter(
107
+ self._values[i], index=self._index[i], axis_zero=self._axis_zero
108
+ )
109
+
110
+ @property
111
+ def shape(self) -> tuple[int, ...]:
112
+ return (len(self._values),)
113
+
114
+ @property
115
+ def index_names(self) -> tuple[IndexName, ...]:
116
+ return (f'dim_{self._axis_zero}',)
117
+
118
+ @property
119
+ def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
120
+ return {f'dim_{self._axis_zero}': self._index}
121
+
122
+
123
+ class PandasSeriesAdapter(ValueArray):
124
+ def __init__(self, series: 'pandas.Series', *, axis_zero: int = 0):
125
+ self._series = series
126
+ self._axis_zero = axis_zero
127
+
128
+ @staticmethod
129
+ def try_from(obj: Any, *, axis_zero: int = 0) -> PandasSeriesAdapter | None:
130
+ try:
131
+ import pandas
132
+ except ModuleNotFoundError:
133
+ return None
134
+ if isinstance(obj, pandas.Series):
135
+ return PandasSeriesAdapter(obj, axis_zero=axis_zero)
136
+
137
+ def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
138
+ if len(key) != 1:
139
+ raise ValueError('PandasSeriesAdapter only supports single index')
140
+ index_name, i = key[0]
141
+ if index_name != self.index_names[0]:
142
+ raise ValueError(
143
+ f'Unexpected index name {index_name} for PandasSeriesAdapter with '
144
+ f'index names {self.index_names}'
145
+ )
146
+ return self._series.loc[i]
147
+
148
+ def __getitem__(self, key: dict[IndexName, slice]) -> PandasSeriesAdapter:
149
+ _, i = next(iter(key.items()))
150
+ return PandasSeriesAdapter(self._series[i], axis_zero=self._axis_zero)
151
+
152
+ @property
153
+ def shape(self) -> tuple[int, ...]:
154
+ return (len(self._series),)
155
+
156
+ @property
157
+ def index_names(self) -> tuple[IndexName, ...]:
158
+ index_name = (
159
+ self._series.index.name
160
+ if self._series.index.name is not None
161
+ else f'dim_{self._axis_zero}'
162
+ )
163
+ return (index_name,)
164
+
165
+ @property
166
+ def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
167
+ return {self.index_names[0]: self._series.index}
168
+
169
+
170
+ class XarrayDataArrayAdapter(ValueArray):
171
+ def __init__(
172
+ self,
173
+ data_array: 'xarray.DataArray',
174
+ ):
175
+ default_indices = {
176
+ dim: range(size)
177
+ for dim, size in data_array.sizes.items()
178
+ if dim not in data_array.coords
179
+ }
180
+ self._data_array = data_array.assign_coords(default_indices)
181
+
182
+ @staticmethod
183
+ def try_from(obj: Any, *, axis_zero: int = 0) -> XarrayDataArrayAdapter | None:
184
+ try:
185
+ import xarray
186
+
187
+ if isinstance(obj, xarray.DataArray):
188
+ return XarrayDataArrayAdapter(obj)
189
+ except ModuleNotFoundError:
190
+ pass
191
+
192
+ def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
193
+ return self._data_array.sel(dict(key))
194
+
195
+ def __getitem__(self, key: dict[IndexName, slice]) -> XarrayDataArrayAdapter:
196
+ return XarrayDataArrayAdapter(self._data_array.isel(key))
197
+
198
+ @property
199
+ def shape(self) -> tuple[int, ...]:
200
+ return self._data_array.shape
201
+
202
+ @property
203
+ def index_names(self) -> tuple[IndexName, ...]:
204
+ return tuple(self._data_array.dims)
205
+
206
+ @property
207
+ def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
208
+ return {
209
+ dim: self._data_array.coords[dim].values for dim in self._data_array.dims
210
+ }
211
+
212
+
213
+ class ScippDataArrayAdapter(ValueArray):
214
+ def __init__(self, data_array: 'scipp.DataArray'):
215
+ import scipp
216
+
217
+ default_indices = {
218
+ dim: scipp.arange(dim, size, unit=None)
219
+ for dim, size in data_array.sizes.items()
220
+ if dim not in data_array.coords
221
+ }
222
+ self._data_array = data_array.assign_coords(default_indices)
223
+
224
+ @staticmethod
225
+ def try_from(obj: Any, *, axis_zero: int = 0) -> ScippDataArrayAdapter | None:
226
+ try:
227
+ import scipp
228
+
229
+ if isinstance(obj, scipp.Variable):
230
+ return ScippDataArrayAdapter(scipp.DataArray(obj))
231
+ if isinstance(obj, scipp.DataArray):
232
+ return ScippDataArrayAdapter(obj)
233
+ except ModuleNotFoundError:
234
+ pass
235
+
236
+ def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
237
+ import scipp
238
+
239
+ values = self._data_array
240
+ for dim, value in key:
241
+ # Reconstruct label, to use label-based indexing instead of positional
242
+ if isinstance(value, tuple):
243
+ value, unit = value
244
+ else:
245
+ unit = None
246
+ label = scipp.scalar(value, unit=unit)
247
+ # Scipp indexing uses a comma to separate dimension label from the index,
248
+ # unlike Numpy and other libraries where it separates the indices for
249
+ # different axes.
250
+ values = values[dim, label]
251
+ return values
252
+
253
+ def __getitem__(self, key: dict[IndexName, slice]) -> ScippDataArrayAdapter:
254
+ values = self._data_array
255
+ for dim, i in key:
256
+ values = values[dim, i]
257
+ return ScippDataArrayAdapter(values)
258
+
259
+ @property
260
+ def shape(self) -> tuple[int, ...]:
261
+ return self._data_array.shape
262
+
263
+ @property
264
+ def index_names(self) -> tuple[IndexName, ...]:
265
+ return tuple(self._data_array.dims)
266
+
267
+ def _index_for_dim(self, dim: str) -> list[tuple[Any, 'scipp.Unit']]:
268
+ # Work around some NetworkX errors. Probably scipp.Variable lacks functionality.
269
+ # For now we return a list of tuples, where the first element is the value and
270
+ # the second is the unit.
271
+ coord = self._data_array.coords[dim]
272
+ unit = coord.unit
273
+ if unit is None:
274
+ return coord.values
275
+ unit = str(unit)
276
+ return [(value, unit) for value in coord.values]
277
+
278
+ @property
279
+ def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
280
+ return {dim: self._index_for_dim(dim) for dim in self._data_array.dims}
281
+
282
+
283
+ class NumpyArrayAdapter(ValueArray):
284
+ def __init__(
285
+ self,
286
+ array: 'numpy.ndarray',
287
+ *,
288
+ indices: dict[IndexName, Iterable[IndexValue]] | None = None,
289
+ axis_zero: int = 0,
290
+ ):
291
+ import numpy as np
292
+
293
+ self._array = np.asarray(array)
294
+ if indices is None:
295
+ indices = {
296
+ f'dim_{i+axis_zero}': range(size)
297
+ for i, size in enumerate(self._array.shape)
298
+ }
299
+ self._indices = indices
300
+ self._axis_zero = axis_zero
301
+
302
+ @staticmethod
303
+ def try_from(obj: Any, *, axis_zero: int = 0) -> NumpyArrayAdapter | None:
304
+ try:
305
+ import numpy
306
+ except ModuleNotFoundError:
307
+ return None
308
+ if isinstance(obj, numpy.ndarray):
309
+ return NumpyArrayAdapter(obj, axis_zero=axis_zero)
310
+
311
+ def sel(self, key: tuple[tuple[IndexName, IndexValue], ...]) -> Any:
312
+ index_tuple = tuple(self._indices[k].index(i) for k, i in key)
313
+ return self._array[index_tuple]
314
+
315
+ def __getitem__(self, key: dict[IndexName, slice]) -> NumpyArrayAdapter:
316
+ return NumpyArrayAdapter(
317
+ self._array[tuple(key.get(k, slice(None)) for k in self._indices)],
318
+ indices={
319
+ index_name: (index_values[key.get(index_name, slice(None))])
320
+ for index_name, index_values in self._indices.items()
321
+ },
322
+ axis_zero=self._axis_zero,
323
+ )
324
+
325
+ @property
326
+ def shape(self) -> tuple[int, ...]:
327
+ return self._array.shape
328
+
329
+ @property
330
+ def index_names(self) -> tuple[IndexName, ...]:
331
+ return tuple(self._indices)
332
+
333
+ @property
334
+ def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
335
+ return self._indices
336
+
337
+
338
+ class NodeValues(abc.Mapping[Hashable, ValueArray]):
339
+ """
340
+ A collection of pandas.DataFrame-like objects with distinct indices.
341
+
342
+ This is used by :py:class:`Graph` to store the values of nodes in a graph.
343
+ """
344
+
345
+ def __init__(self, values: Mapping[Hashable, ValueArray]):
346
+ self._values = values
347
+
348
+ def __len__(self) -> int:
349
+ """Return the number of columns."""
350
+ return len(self._values)
351
+
352
+ def __iter__(self) -> Iterable[Hashable]:
353
+ """Iterate over the column names."""
354
+ return iter(self._values)
355
+
356
+ def __getitem__(self, key: Hashable) -> ValueArray:
357
+ """Return the column with the given name."""
358
+ return self._values[key]
359
+
360
+ @staticmethod
361
+ def from_mapping(
362
+ values: Mapping[Hashable, Sequence[Any]], axis_zero: int
363
+ ) -> NodeValues:
364
+ """Construct from a mapping of node names to value sequences."""
365
+ value_arrays = {
366
+ key: ValueArray.from_array_like(value, axis_zero=axis_zero)
367
+ for key, value in values.items()
368
+ }
369
+ shapes = {array.shape for array in value_arrays.values()}
370
+ if len(shapes) > 1:
371
+ raise ValueError(
372
+ 'All value sequences in a map operation must have the same shape. '
373
+ 'Use multiple map operations if necessary.'
374
+ )
375
+ return NodeValues(value_arrays)
376
+
377
+ def merge(self, value_arrays: Mapping[Hashable, ValueArray]) -> NodeValues:
378
+ if value_arrays:
379
+ named = next(iter(value_arrays.values())).index_names
380
+ if any([name in self.indices for name in named]):
381
+ raise ValueError(
382
+ f'Conflicting new index names {named} with existing '
383
+ f'{tuple(self.indices)}'
384
+ )
385
+ for node in value_arrays:
386
+ if node in self:
387
+ raise ValueError(f"Node '{node}' has already been mapped")
388
+ return NodeValues({**self._values, **value_arrays})
389
+
390
+ def get_columns(self, keys: list[Hashable]) -> NodeValues:
391
+ """Select a subset of columns."""
392
+ return NodeValues({key: self._values[key] for key in keys})
393
+
394
+ @property
395
+ def indices(self) -> dict[IndexName, Iterable[IndexValue]]:
396
+ """Return the indices of the NodeValues object."""
397
+ value_indices = [value.indices for value in self._values.values()]
398
+ return {
399
+ name: index for indices in value_indices for name, index in indices.items()
400
+ }
cyclebane/py.typed ADDED
File without changes
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, Scipp contributors (https://github.com/scipp)
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.1
2
+ Name: cyclebane
3
+ Version: 24.5.0
4
+ Summary: Transform directed acyclic graphs using map-reduce and groupby operations
5
+ Author: Scipp contributors
6
+ License: BSD 3-Clause License
7
+
8
+ Copyright (c) 2024, Scipp contributors (https://github.com/scipp)
9
+ All rights reserved.
10
+
11
+ Redistribution and use in source and binary forms, with or without
12
+ modification, are permitted provided that the following conditions are met:
13
+
14
+ 1. Redistributions of source code must retain the above copyright notice, this
15
+ list of conditions and the following disclaimer.
16
+
17
+ 2. Redistributions in binary form must reproduce the above copyright notice,
18
+ this list of conditions and the following disclaimer in the documentation
19
+ and/or other materials provided with the distribution.
20
+
21
+ 3. Neither the name of the copyright holder nor the names of its
22
+ contributors may be used to endorse or promote products derived from
23
+ this software without specific prior written permission.
24
+
25
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
26
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
27
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
28
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
29
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
30
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
31
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
32
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
33
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
34
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
35
+
36
+ Project-URL: Bug Tracker, https://github.com/scipp/cyclebane/issues
37
+ Project-URL: Documentation, https://scipp.github.io/cyclebane
38
+ Project-URL: Source, https://github.com/scipp/cyclebane
39
+ Classifier: Intended Audience :: Science/Research
40
+ Classifier: License :: OSI Approved :: BSD License
41
+ Classifier: Natural Language :: English
42
+ Classifier: Operating System :: OS Independent
43
+ Classifier: Programming Language :: Python :: 3
44
+ Classifier: Programming Language :: Python :: 3 :: Only
45
+ Classifier: Programming Language :: Python :: 3.10
46
+ Classifier: Programming Language :: Python :: 3.11
47
+ Classifier: Programming Language :: Python :: 3.12
48
+ Classifier: Topic :: Scientific/Engineering
49
+ Classifier: Typing :: Typed
50
+ Requires-Python: >=3.10
51
+ Description-Content-Type: text/markdown
52
+ License-File: LICENSE
53
+ Requires-Dist: networkx
54
+
55
+ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)
56
+ [![PyPI badge](http://img.shields.io/pypi/v/cyclebane.svg)](https://pypi.python.org/pypi/cyclebane)
57
+ [![Anaconda-Server Badge](https://anaconda.org/scipp/cyclebane/badges/version.svg)](https://anaconda.org/scipp/cyclebane)
58
+ [![License: BSD 3-Clause](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](LICENSE)
59
+
60
+ # Cyclebane
61
+
62
+ ## About
63
+
64
+ Transform directed acyclic graphs using map-reduce and groupby operations
65
+
66
+ This library is an attempt to merge the concepts of directed acyclic graphs (DAG) with array-like objects such as NumPy arrays, Pandas DataFrames, or Xarray/Scipp DataArrays.
67
+ This could be useful for describing tasks graphs, e.g., when a series of tasks is applied to chunks of an array.
68
+ These tasks also have an array structure.
69
+ After an reduction operation of chunks, the graph loses this structure, i.e., only a subset of the graph's nodes has array structure.
70
+ What if we could work with this structure, even though only parts of the graph follows it?
71
+ And what if we could use the power of array slicing with named dimensions, or select by label?
72
+ This is what Cyclebane tries to do.
73
+
74
+ Our initial goal is to support:
75
+
76
+ - `map` operations of a DAG's source nodes over an array-like (https://docs.dask.org/en/latest/high-level-graphs.html).
77
+ Cyclebane will effectively copy all descendants of those nodes, once for each array element.
78
+ Cyclebane will support joint mappings of multiple source nodes by mapping over, e.g., a DataFrame with multiple columns, as well as chaining independent map operations at different source nodes.
79
+ In the latter case this will effectively broadcast at descendant nodes that depend on multiple such source nodes.
80
+ - `reduce` operations at descendants of mapped nodes.
81
+ This will add a new node with edges to all copies of the mapped node being reduced.
82
+ Cyclebane will support reducing only individual axes or all axes, similar to Numpy.
83
+ - `groupby` operations similar to Pandas and Xarray (albeit more limited).
84
+ - Positional and label-based indexing.
85
+ Cyclebane will support selecting branches that were creating during `map` (or `groupby`) operations based on their indices.
86
+ The graph structure will be left untouched, i.e., nodes after a `reduce` operation will be preserved, but fewer edges will lead to the reduce node.
87
+
88
+ See also Dask's [High Level Graphs](https://docs.dask.org/en/latest/high-level-graphs.html) for a related concept (without the direct support for any such operations).
89
+
90
+ ## Installation
91
+
92
+ ```sh
93
+ python -m pip install cyclebane
94
+ ```
@@ -0,0 +1,9 @@
1
+ cyclebane/__init__.py,sha256=YGeabZhJkCjRwK-c5kfL4G7viTRXljZRK4_a78fJMoY,371
2
+ cyclebane/graph.py,sha256=M3bX2BTgKhfNM9QnTa1RZiF0KiXvRhFFU_4w5_JCiQU,17165
3
+ cyclebane/node_values.py,sha256=ju6sfSZsLuIdL9Pxcc9cXsNHNJPzx0YCkDsKlfk-3XY,13588
4
+ cyclebane/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ cyclebane-24.5.0.dist-info/LICENSE,sha256=nVEiume4Qj6jMYfSRjHTM2jtJ4FGu0g-5Sdh7osfEYw,1553
6
+ cyclebane-24.5.0.dist-info/METADATA,sha256=0nEYg9Ssl5SpZUxf8_mLBy2CdMbicvabPpGCMywA-l0,5400
7
+ cyclebane-24.5.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
8
+ cyclebane-24.5.0.dist-info/top_level.txt,sha256=KVv2uZtZFY3P-omQCsFyxjazWD32M_ym0Kpt-rUxn8Q,10
9
+ cyclebane-24.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ cyclebane