kpnn2 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.
- kpnn2/__init__.py +45 -0
- kpnn2/_adjacency_spec.py +387 -0
- kpnn2/_align.py +215 -0
- kpnn2/_attributions.py +377 -0
- kpnn2/_errors.py +29 -0
- kpnn2/_gather.py +158 -0
- kpnn2/_layout.py +315 -0
- kpnn2/_mask_tensor.py +23 -0
- kpnn2/_masked_linear.py +457 -0
- kpnn2/_packed_linear.py +472 -0
- kpnn2/_parse.py +640 -0
- kpnn2/_parse_adjacency.py +187 -0
- kpnn2/_serialize.py +359 -0
- kpnn2/_spec.py +473 -0
- kpnn2-0.1.0.dist-info/METADATA +281 -0
- kpnn2-0.1.0.dist-info/RECORD +19 -0
- kpnn2-0.1.0.dist-info/WHEEL +5 -0
- kpnn2-0.1.0.dist-info/licenses/LICENSE +22 -0
- kpnn2-0.1.0.dist-info/top_level.txt +1 -0
kpnn2/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Turn a named edgelist into sparsely connected PyTorch layers
|
|
3
|
+
you assemble yourself.
|
|
4
|
+
|
|
5
|
+
``kpnn2`` turns a source/target edgelist into a spec so you write
|
|
6
|
+
ordinary PyTorch. Pick the layout yourself: ``parse_layered``
|
|
7
|
+
ranks a DAG into a ``LayeredSpec``, one ``Hop`` per layer whose
|
|
8
|
+
mask carries every edge entering it, skips included, while
|
|
9
|
+
``parse_adjacency`` puts every node in one state vector with
|
|
10
|
+
packed edge indices (``AdjacencySpec``) and allows cycles. Use
|
|
11
|
+
``PackedLinear`` on those packed indices, or
|
|
12
|
+
``MaskedLinear(spec.to_mask())`` for the dense path.
|
|
13
|
+
|
|
14
|
+
It is not a graph compiler: there is no ready-made model object
|
|
15
|
+
and no training loop.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._adjacency_spec import AdjacencySpec
|
|
19
|
+
from ._align import align_inputs
|
|
20
|
+
from ._attributions import map_node_attributions
|
|
21
|
+
from ._errors import Kpnn2Error
|
|
22
|
+
from ._gather import gather_hop_inputs
|
|
23
|
+
from ._masked_linear import MaskedLinear
|
|
24
|
+
from ._packed_linear import PackedLinear
|
|
25
|
+
from ._parse import parse_layered
|
|
26
|
+
from ._parse_adjacency import parse_adjacency
|
|
27
|
+
from ._spec import Hop, LayeredSpec, Skip
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"parse_layered",
|
|
33
|
+
"parse_adjacency",
|
|
34
|
+
"LayeredSpec",
|
|
35
|
+
"Hop",
|
|
36
|
+
"Skip",
|
|
37
|
+
"AdjacencySpec",
|
|
38
|
+
"MaskedLinear",
|
|
39
|
+
"PackedLinear",
|
|
40
|
+
"gather_hop_inputs",
|
|
41
|
+
"align_inputs",
|
|
42
|
+
"map_node_attributions",
|
|
43
|
+
"Kpnn2Error",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
kpnn2/_adjacency_spec.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structural blueprint for an edgelist-defined node network.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import torch
|
|
9
|
+
from torch import Tensor
|
|
10
|
+
|
|
11
|
+
from ._layout import build_layout
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AdjacencySpec:
|
|
16
|
+
"""
|
|
17
|
+
Frozen blueprint from ``parse_adjacency``.
|
|
18
|
+
|
|
19
|
+
Structure only: not an ``nn.Module`` and no parameters. Every
|
|
20
|
+
node lives in one state vector and connectivity is packed as
|
|
21
|
+
source/target index tuples, so the graph may contain cycles
|
|
22
|
+
and self-loops. There is no stored square mask.
|
|
23
|
+
|
|
24
|
+
There are no depths here: no ``layer_nodes``, no ``hops``
|
|
25
|
+
tuple, and no ``skips``. This is not a one-layer
|
|
26
|
+
``LayeredSpec``. Use ``PackedLinear`` on ``source_index`` /
|
|
27
|
+
``target_index`` for a state update that never allocates
|
|
28
|
+
``(n, n)``, or ``MaskedLinear(spec.to_mask())`` for the
|
|
29
|
+
dense path, and write the recurrence in ``forward()``.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
nodes : tuple[str, ...]
|
|
34
|
+
Every node name, alphabetical. This is the unit order of
|
|
35
|
+
the state vector and the row and column order of
|
|
36
|
+
``to_mask()``.
|
|
37
|
+
input_nodes : tuple[str, ...]
|
|
38
|
+
In-degree 0 names, alphabetical. This is the column order
|
|
39
|
+
of tensors returned by ``align_inputs``.
|
|
40
|
+
output_nodes : tuple[str, ...]
|
|
41
|
+
Out-degree 0 names, alphabetical.
|
|
42
|
+
hidden_nodes : tuple[str, ...]
|
|
43
|
+
Names that are neither input nor output, alphabetical.
|
|
44
|
+
source_index : tuple[int, ...]
|
|
45
|
+
For each original edge, the column in ``nodes`` (the
|
|
46
|
+
source). Same length as ``target_index`` and as the
|
|
47
|
+
edge count. Order is canonical: lexicographic by
|
|
48
|
+
``(source name, target name)``, identical to
|
|
49
|
+
``to_edgelist()`` row order. Cycles and self-loops are
|
|
50
|
+
included.
|
|
51
|
+
target_index : tuple[int, ...]
|
|
52
|
+
For each original edge, the row in ``nodes`` (the
|
|
53
|
+
target). A dense square would have ``1.0`` at
|
|
54
|
+
``[target_index[i], source_index[i]]``.
|
|
55
|
+
input_index : tuple[int, ...]
|
|
56
|
+
Position of each ``input_nodes`` name in ``nodes``.
|
|
57
|
+
output_index : tuple[int, ...]
|
|
58
|
+
Position of each ``output_nodes`` name in ``nodes``.
|
|
59
|
+
|
|
60
|
+
Notes
|
|
61
|
+
-----
|
|
62
|
+
Fields cannot be reassigned and sequences are tuples, so the
|
|
63
|
+
structure itself is fixed. There is no ``mask`` field and no
|
|
64
|
+
densifying ``mask`` property. ``to_mask()`` allocates a
|
|
65
|
+
fresh dense square on every call; mutating that tensor does
|
|
66
|
+
not change this spec. ``MaskedLinear(spec.to_mask())``
|
|
67
|
+
clones the square into a non-persistent buffer, so a layer
|
|
68
|
+
built earlier keeps its own connectivity.
|
|
69
|
+
|
|
70
|
+
``align_inputs`` returns ``len(input_nodes)`` columns, which
|
|
71
|
+
is not the state width. Scatter that tensor into the
|
|
72
|
+
``n``-wide state vector with ``input_index``. Input rows of
|
|
73
|
+
``to_mask()`` are all zeros, so under the degree-aware init
|
|
74
|
+
of ``MaskedLinear`` they stay zero: writing the inputs in
|
|
75
|
+
is required, not cosmetic.
|
|
76
|
+
|
|
77
|
+
``to_edgelist()`` returns the original edges as a
|
|
78
|
+
two-column ``source`` / ``target`` DataFrame, rows sorted
|
|
79
|
+
lexicographically, including cycle edges and self-loops.
|
|
80
|
+
``parse_adjacency`` on that table reconstructs this spec's
|
|
81
|
+
node lists, packed indices, and input/output indices.
|
|
82
|
+
|
|
83
|
+
``to_dict()`` returns a JSON-safe tagged dict
|
|
84
|
+
(``kpnn2_spec``, ``layout``, ``edges``). ``from_dict``
|
|
85
|
+
rebuilds this spec by calling ``parse_adjacency``.
|
|
86
|
+
``fingerprint`` is the SHA-256 of that canonical JSON.
|
|
87
|
+
Pickle / ``torch.save`` of the dataclass is not the
|
|
88
|
+
supported interchange.
|
|
89
|
+
|
|
90
|
+
Examples
|
|
91
|
+
--------
|
|
92
|
+
An input feeding a two-node feedback core plus one output:
|
|
93
|
+
|
|
94
|
+
>>> import pandas as pd
|
|
95
|
+
>>> import kpnn2
|
|
96
|
+
>>> edgelist = pd.DataFrame(
|
|
97
|
+
... {
|
|
98
|
+
... "source": ["x", "a", "b", "a"],
|
|
99
|
+
... "target": ["a", "b", "a", "y"],
|
|
100
|
+
... }
|
|
101
|
+
... )
|
|
102
|
+
>>> spec = kpnn2.parse_adjacency(edgelist)
|
|
103
|
+
>>> spec.nodes
|
|
104
|
+
('a', 'b', 'x', 'y')
|
|
105
|
+
>>> spec.input_nodes, spec.output_nodes
|
|
106
|
+
(('x',), ('y',))
|
|
107
|
+
>>> spec.hidden_nodes
|
|
108
|
+
('a', 'b')
|
|
109
|
+
>>> spec.input_index, spec.output_index
|
|
110
|
+
((2,), (3,))
|
|
111
|
+
>>> spec.source_index
|
|
112
|
+
(0, 0, 1, 2)
|
|
113
|
+
>>> spec.target_index
|
|
114
|
+
(1, 3, 0, 0)
|
|
115
|
+
>>> tuple(spec.to_mask().shape)
|
|
116
|
+
(4, 4)
|
|
117
|
+
>>> spec.to_mask()[0].tolist()
|
|
118
|
+
[0.0, 1.0, 1.0, 0.0]
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
nodes: tuple[str, ...]
|
|
122
|
+
input_nodes: tuple[str, ...]
|
|
123
|
+
output_nodes: tuple[str, ...]
|
|
124
|
+
hidden_nodes: tuple[str, ...]
|
|
125
|
+
source_index: tuple[int, ...]
|
|
126
|
+
target_index: tuple[int, ...]
|
|
127
|
+
input_index: tuple[int, ...]
|
|
128
|
+
output_index: tuple[int, ...]
|
|
129
|
+
|
|
130
|
+
def __post_init__(self) -> None:
|
|
131
|
+
object.__setattr__(
|
|
132
|
+
self,
|
|
133
|
+
"nodes",
|
|
134
|
+
tuple(self.nodes),
|
|
135
|
+
)
|
|
136
|
+
object.__setattr__(
|
|
137
|
+
self,
|
|
138
|
+
"input_nodes",
|
|
139
|
+
tuple(self.input_nodes),
|
|
140
|
+
)
|
|
141
|
+
object.__setattr__(
|
|
142
|
+
self,
|
|
143
|
+
"output_nodes",
|
|
144
|
+
tuple(self.output_nodes),
|
|
145
|
+
)
|
|
146
|
+
object.__setattr__(
|
|
147
|
+
self,
|
|
148
|
+
"hidden_nodes",
|
|
149
|
+
tuple(self.hidden_nodes),
|
|
150
|
+
)
|
|
151
|
+
object.__setattr__(
|
|
152
|
+
self,
|
|
153
|
+
"source_index",
|
|
154
|
+
tuple(self.source_index),
|
|
155
|
+
)
|
|
156
|
+
object.__setattr__(
|
|
157
|
+
self,
|
|
158
|
+
"target_index",
|
|
159
|
+
tuple(self.target_index),
|
|
160
|
+
)
|
|
161
|
+
object.__setattr__(
|
|
162
|
+
self,
|
|
163
|
+
"input_index",
|
|
164
|
+
tuple(self.input_index),
|
|
165
|
+
)
|
|
166
|
+
object.__setattr__(
|
|
167
|
+
self,
|
|
168
|
+
"output_index",
|
|
169
|
+
tuple(self.output_index),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def to_mask(self) -> Tensor:
|
|
173
|
+
"""
|
|
174
|
+
Allocate a dense float32 square from the packed edges.
|
|
175
|
+
|
|
176
|
+
Shape is ``(n, n)`` with ``n`` from the node layout
|
|
177
|
+
(``len(nodes)`` at width 1). The result starts at zeros;
|
|
178
|
+
each live edge sets ``1.0`` at
|
|
179
|
+
``[target_index[i], source_index[i]]``. Every call
|
|
180
|
+
returns a fresh tensor. Mutating it does not change this
|
|
181
|
+
spec or the next ``to_mask()`` call.
|
|
182
|
+
|
|
183
|
+
Returns
|
|
184
|
+
-------
|
|
185
|
+
torch.Tensor
|
|
186
|
+
New dense connectivity square. This allocates.
|
|
187
|
+
|
|
188
|
+
Examples
|
|
189
|
+
--------
|
|
190
|
+
>>> import pandas as pd
|
|
191
|
+
>>> import kpnn2
|
|
192
|
+
>>> edgelist = pd.DataFrame(
|
|
193
|
+
... {
|
|
194
|
+
... "source": ["x", "a", "b", "a"],
|
|
195
|
+
... "target": ["a", "b", "a", "y"],
|
|
196
|
+
... }
|
|
197
|
+
... )
|
|
198
|
+
>>> spec = kpnn2.parse_adjacency(edgelist)
|
|
199
|
+
>>> mask = spec.to_mask()
|
|
200
|
+
>>> tuple(mask.shape)
|
|
201
|
+
(4, 4)
|
|
202
|
+
>>> mask[0, 1].item(), mask[1, 0].item()
|
|
203
|
+
(1.0, 1.0)
|
|
204
|
+
"""
|
|
205
|
+
layout = build_layout(self.nodes)
|
|
206
|
+
n_units = layout.n_units
|
|
207
|
+
mask = torch.zeros(
|
|
208
|
+
(
|
|
209
|
+
n_units,
|
|
210
|
+
n_units,
|
|
211
|
+
),
|
|
212
|
+
dtype=torch.float32,
|
|
213
|
+
)
|
|
214
|
+
for source, target in zip(
|
|
215
|
+
self.source_index,
|
|
216
|
+
self.target_index,
|
|
217
|
+
):
|
|
218
|
+
mask[target, source] = 1.0
|
|
219
|
+
return mask
|
|
220
|
+
|
|
221
|
+
def to_edgelist(self) -> pd.DataFrame:
|
|
222
|
+
"""
|
|
223
|
+
Return this spec's edges as a two-column table.
|
|
224
|
+
|
|
225
|
+
Columns are exactly ``source`` then ``target``. Rows
|
|
226
|
+
follow the packed indices in canonical order: sorted
|
|
227
|
+
lexicographically by ``(source, target)``, one row per
|
|
228
|
+
original edge, names as strings, including cycle edges
|
|
229
|
+
and self-loops. Extra columns from the DataFrame that
|
|
230
|
+
was parsed are not reproduced.
|
|
231
|
+
|
|
232
|
+
``parse_adjacency`` on this table reconstructs the same
|
|
233
|
+
node lists, packed indices, and input/output indices.
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
pandas.DataFrame
|
|
238
|
+
One row per original edge.
|
|
239
|
+
|
|
240
|
+
Examples
|
|
241
|
+
--------
|
|
242
|
+
A cycle comes back as sorted pairs:
|
|
243
|
+
|
|
244
|
+
>>> import pandas as pd
|
|
245
|
+
>>> import kpnn2
|
|
246
|
+
>>> edgelist = pd.DataFrame(
|
|
247
|
+
... {
|
|
248
|
+
... "source": ["x", "a", "b", "a"],
|
|
249
|
+
... "target": ["a", "b", "a", "y"],
|
|
250
|
+
... }
|
|
251
|
+
... )
|
|
252
|
+
>>> spec = kpnn2.parse_adjacency(edgelist)
|
|
253
|
+
>>> table = spec.to_edgelist()
|
|
254
|
+
>>> list(table.columns)
|
|
255
|
+
['source', 'target']
|
|
256
|
+
>>> table["source"].tolist()
|
|
257
|
+
['a', 'a', 'b', 'x']
|
|
258
|
+
>>> table["target"].tolist()
|
|
259
|
+
['b', 'y', 'a', 'a']
|
|
260
|
+
"""
|
|
261
|
+
from ._serialize import spec_to_edgelist
|
|
262
|
+
|
|
263
|
+
return spec_to_edgelist(self)
|
|
264
|
+
|
|
265
|
+
def to_dict(self) -> dict:
|
|
266
|
+
"""
|
|
267
|
+
Return this spec as a JSON-safe tagged dict.
|
|
268
|
+
|
|
269
|
+
Keys are ``kpnn2_spec`` (integer ``1``), ``layout``
|
|
270
|
+
(``"adjacency"``), and ``edges`` (list of
|
|
271
|
+
``[source, target]`` lists in the same order as
|
|
272
|
+
``to_edgelist()`` rows, including cycle edges and
|
|
273
|
+
self-loops). The returned dict is new on every call.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
dict
|
|
278
|
+
Tagged edge list plus layout.
|
|
279
|
+
|
|
280
|
+
Examples
|
|
281
|
+
--------
|
|
282
|
+
>>> import pandas as pd
|
|
283
|
+
>>> import kpnn2
|
|
284
|
+
>>> edgelist = pd.DataFrame(
|
|
285
|
+
... {
|
|
286
|
+
... "source": ["x", "a", "b", "a"],
|
|
287
|
+
... "target": ["a", "b", "a", "y"],
|
|
288
|
+
... }
|
|
289
|
+
... )
|
|
290
|
+
>>> spec = kpnn2.parse_adjacency(edgelist)
|
|
291
|
+
>>> payload = spec.to_dict()
|
|
292
|
+
>>> payload["kpnn2_spec"]
|
|
293
|
+
1
|
|
294
|
+
>>> payload["layout"]
|
|
295
|
+
'adjacency'
|
|
296
|
+
>>> payload["edges"]
|
|
297
|
+
[['a', 'b'], ['a', 'y'], ['b', 'a'], ['x', 'a']]
|
|
298
|
+
"""
|
|
299
|
+
from ._serialize import spec_to_dict
|
|
300
|
+
|
|
301
|
+
return spec_to_dict(self)
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def from_dict(cls, payload: dict) -> "AdjacencySpec":
|
|
305
|
+
"""
|
|
306
|
+
Rebuild an ``AdjacencySpec`` from ``to_dict()`` output.
|
|
307
|
+
|
|
308
|
+
Calls ``parse_adjacency`` on a DataFrame built from
|
|
309
|
+
``payload["edges"]``. Packed indices are not assembled
|
|
310
|
+
by hand. Extra unknown keys are ignored.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
payload : dict
|
|
315
|
+
A dict with ``kpnn2_spec``, ``layout``, and
|
|
316
|
+
``edges``. ``layout`` must be ``"adjacency"``.
|
|
317
|
+
|
|
318
|
+
Returns
|
|
319
|
+
-------
|
|
320
|
+
AdjacencySpec
|
|
321
|
+
The parsed spec.
|
|
322
|
+
|
|
323
|
+
Raises
|
|
324
|
+
------
|
|
325
|
+
Kpnn2Error
|
|
326
|
+
If ``payload`` is not a dict; ``kpnn2_spec`` is
|
|
327
|
+
missing or not ``1``; ``layout`` is missing, not a
|
|
328
|
+
known layout, or is ``"layered"``; or ``edges`` is
|
|
329
|
+
missing or not a sequence of two nonempty names.
|
|
330
|
+
|
|
331
|
+
Examples
|
|
332
|
+
--------
|
|
333
|
+
>>> import pandas as pd
|
|
334
|
+
>>> import kpnn2
|
|
335
|
+
>>> edgelist = pd.DataFrame(
|
|
336
|
+
... {
|
|
337
|
+
... "source": ["x", "a", "b", "a"],
|
|
338
|
+
... "target": ["a", "b", "a", "y"],
|
|
339
|
+
... }
|
|
340
|
+
... )
|
|
341
|
+
>>> spec = kpnn2.parse_adjacency(edgelist)
|
|
342
|
+
>>> roundtrip = kpnn2.AdjacencySpec.from_dict(spec.to_dict())
|
|
343
|
+
>>> roundtrip.nodes == spec.nodes
|
|
344
|
+
True
|
|
345
|
+
"""
|
|
346
|
+
from ._serialize import adjacency_spec_from_dict
|
|
347
|
+
|
|
348
|
+
return adjacency_spec_from_dict(payload)
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def fingerprint(self) -> str:
|
|
352
|
+
"""
|
|
353
|
+
SHA-256 hex digest of the canonical ``to_dict()`` JSON.
|
|
354
|
+
|
|
355
|
+
The payload is ``json.dumps(self.to_dict(),
|
|
356
|
+
sort_keys=True, separators=(",", ":"),
|
|
357
|
+
ensure_ascii=False)`` encoded as UTF-8. The result is
|
|
358
|
+
64 lowercase hex characters. It is not Python
|
|
359
|
+
``hash()``.
|
|
360
|
+
|
|
361
|
+
Returns
|
|
362
|
+
-------
|
|
363
|
+
str
|
|
364
|
+
Hex digest of the tagged spec dict.
|
|
365
|
+
|
|
366
|
+
Examples
|
|
367
|
+
--------
|
|
368
|
+
>>> import pandas as pd
|
|
369
|
+
>>> import kpnn2
|
|
370
|
+
>>> edgelist = pd.DataFrame(
|
|
371
|
+
... {
|
|
372
|
+
... "source": ["x", "a", "b", "a"],
|
|
373
|
+
... "target": ["a", "b", "a", "y"],
|
|
374
|
+
... }
|
|
375
|
+
... )
|
|
376
|
+
>>> spec = kpnn2.parse_adjacency(edgelist)
|
|
377
|
+
>>> len(spec.fingerprint)
|
|
378
|
+
64
|
|
379
|
+
>>> (
|
|
380
|
+
... spec.fingerprint
|
|
381
|
+
... == kpnn2.parse_adjacency(spec.to_edgelist()).fingerprint
|
|
382
|
+
... )
|
|
383
|
+
True
|
|
384
|
+
"""
|
|
385
|
+
from ._serialize import spec_fingerprint
|
|
386
|
+
|
|
387
|
+
return spec_fingerprint(self)
|
kpnn2/_align.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Align named DataFrame columns to ``spec.input_nodes``.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import torch
|
|
7
|
+
|
|
8
|
+
from ._adjacency_spec import AdjacencySpec
|
|
9
|
+
from ._errors import Kpnn2Error
|
|
10
|
+
from ._layout import build_layout, expand_columns
|
|
11
|
+
from ._spec import LayeredSpec
|
|
12
|
+
|
|
13
|
+
_TENSOR_NOT_ACCEPTED_MSG = (
|
|
14
|
+
"'data' is a tensor; a pandas DataFrame is required. "
|
|
15
|
+
"Pass a DataFrame so columns can be matched to "
|
|
16
|
+
"spec.input_nodes. Pre-ordered tensors go straight to "
|
|
17
|
+
"the model."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def align_inputs(
|
|
22
|
+
data: pd.DataFrame,
|
|
23
|
+
spec: LayeredSpec | AdjacencySpec,
|
|
24
|
+
) -> torch.Tensor:
|
|
25
|
+
"""
|
|
26
|
+
Return a float32 tensor whose columns follow ``spec.input_nodes``.
|
|
27
|
+
|
|
28
|
+
**DataFrame.** Required columns are ``spec.input_nodes``. Labels
|
|
29
|
+
are matched after ``str(...)``, the same conversion used for
|
|
30
|
+
edgelist node names, so an integer column ``1`` matches node
|
|
31
|
+
``"1"``. Extra columns are ignored. Columns are reordered to
|
|
32
|
+
``spec.input_nodes``. Missing, duplicate, or non-numeric required
|
|
33
|
+
columns raise an error.
|
|
34
|
+
|
|
35
|
+
**Tensor.** Not accepted. Raise ``Kpnn2Error``. Pre-ordered
|
|
36
|
+
tensors go straight to the model. Users who need alignment pass
|
|
37
|
+
a DataFrame.
|
|
38
|
+
|
|
39
|
+
AnnData is not supported.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
data : DataFrame
|
|
44
|
+
Feature table with named columns.
|
|
45
|
+
spec : LayeredSpec or AdjacencySpec
|
|
46
|
+
Graph structure whose ``input_nodes`` define column order.
|
|
47
|
+
Both spec types work the same way here; only
|
|
48
|
+
``input_nodes`` is read.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
Tensor
|
|
53
|
+
Float32 tensor of shape
|
|
54
|
+
``(n_samples, len(spec.input_nodes))``.
|
|
55
|
+
|
|
56
|
+
Raises
|
|
57
|
+
------
|
|
58
|
+
Kpnn2Error
|
|
59
|
+
If ``spec`` is neither a ``LayeredSpec`` nor an
|
|
60
|
+
``AdjacencySpec``; ``data`` is a tensor; ``data`` is not a
|
|
61
|
+
DataFrame; required DataFrame columns are missing or
|
|
62
|
+
duplicated (including after ``str`` conversion; the message
|
|
63
|
+
names the unique duplicated labels, sorted,
|
|
64
|
+
comma-separated); or required columns are non-numeric.
|
|
65
|
+
|
|
66
|
+
Notes
|
|
67
|
+
-----
|
|
68
|
+
PyTorch never sees feature names. Column ``i`` of the returned
|
|
69
|
+
tensor is ``spec.input_nodes[i]``. Use this function when the
|
|
70
|
+
table has named columns. A tensor whose columns already follow
|
|
71
|
+
``spec.input_nodes`` goes straight to the model. Passing
|
|
72
|
+
``DataFrame.to_numpy()`` (or any hand-stacked array) into the
|
|
73
|
+
model can silently wire the wrong features if the column order
|
|
74
|
+
differs.
|
|
75
|
+
|
|
76
|
+
The returned width is always ``len(spec.input_nodes)``. For a
|
|
77
|
+
``LayeredSpec`` that is the width of ``hops[0].mask``, whose
|
|
78
|
+
only source layer is layer 0, so the tensor feeds the first
|
|
79
|
+
hop directly and needs no gathering. For an ``AdjacencySpec``
|
|
80
|
+
it is **not** the state width: scatter the tensor into the
|
|
81
|
+
``len(spec.nodes)``-wide state vector with ``spec.input_index``
|
|
82
|
+
before calling ``MaskedLinear(spec.to_mask())``.
|
|
83
|
+
|
|
84
|
+
Examples
|
|
85
|
+
--------
|
|
86
|
+
Extra columns are dropped and remaining columns are reordered:
|
|
87
|
+
|
|
88
|
+
>>> import pandas as pd
|
|
89
|
+
>>> import torch
|
|
90
|
+
>>> import kpnn2
|
|
91
|
+
>>> edgelist = pd.DataFrame(
|
|
92
|
+
... {
|
|
93
|
+
... "source": ["A", "H"],
|
|
94
|
+
... "target": ["H", "C"],
|
|
95
|
+
... }
|
|
96
|
+
... )
|
|
97
|
+
>>> spec = kpnn2.parse_layered(edgelist)
|
|
98
|
+
>>> spec.input_nodes
|
|
99
|
+
('A',)
|
|
100
|
+
>>> df = pd.DataFrame(
|
|
101
|
+
... {
|
|
102
|
+
... "unused": [9.0, 8.0],
|
|
103
|
+
... "A": [0.5, 1.5],
|
|
104
|
+
... }
|
|
105
|
+
... )
|
|
106
|
+
>>> x = kpnn2.align_inputs(df, spec)
|
|
107
|
+
>>> x.dtype
|
|
108
|
+
torch.float32
|
|
109
|
+
>>> tuple(x.shape)
|
|
110
|
+
(2, 1)
|
|
111
|
+
>>> x.tolist()
|
|
112
|
+
[[0.5], [1.5]]
|
|
113
|
+
|
|
114
|
+
An ``AdjacencySpec`` works the same way, but the result is
|
|
115
|
+
``len(input_nodes)`` wide and must be scattered into the state
|
|
116
|
+
vector before it reaches ``MaskedLinear(spec.to_mask())``:
|
|
117
|
+
|
|
118
|
+
>>> cyclic = pd.DataFrame(
|
|
119
|
+
... {
|
|
120
|
+
... "source": ["x", "a", "b", "a"],
|
|
121
|
+
... "target": ["a", "b", "a", "y"],
|
|
122
|
+
... }
|
|
123
|
+
... )
|
|
124
|
+
>>> state_spec = kpnn2.parse_adjacency(cyclic)
|
|
125
|
+
>>> inputs = pd.DataFrame({"x": [0.5, 1.5]})
|
|
126
|
+
>>> x = kpnn2.align_inputs(inputs, state_spec)
|
|
127
|
+
>>> tuple(x.shape), tuple(state_spec.to_mask().shape)
|
|
128
|
+
((2, 1), (4, 4))
|
|
129
|
+
>>> state = torch.zeros(
|
|
130
|
+
... 2,
|
|
131
|
+
... len(state_spec.nodes),
|
|
132
|
+
... )
|
|
133
|
+
>>> state[:, state_spec.input_index] = x
|
|
134
|
+
>>> state.tolist()
|
|
135
|
+
[[0.0, 0.0, 0.5, 0.0], [0.0, 0.0, 1.5, 0.0]]
|
|
136
|
+
|
|
137
|
+
A tensor is not accepted; pass a DataFrame instead:
|
|
138
|
+
|
|
139
|
+
>>> t = torch.tensor([[0.5], [1.5]])
|
|
140
|
+
>>> kpnn2.align_inputs(t, spec) # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
141
|
+
Traceback (most recent call last):
|
|
142
|
+
...
|
|
143
|
+
Kpnn2Error: 'data' is a tensor; a pandas DataFrame is required. ...
|
|
144
|
+
"""
|
|
145
|
+
if not isinstance(
|
|
146
|
+
spec,
|
|
147
|
+
(LayeredSpec, AdjacencySpec),
|
|
148
|
+
):
|
|
149
|
+
raise Kpnn2Error("'spec' must be a LayeredSpec or an AdjacencySpec.")
|
|
150
|
+
if isinstance(data, torch.Tensor):
|
|
151
|
+
raise Kpnn2Error(_TENSOR_NOT_ACCEPTED_MSG)
|
|
152
|
+
if isinstance(data, pd.DataFrame):
|
|
153
|
+
return _align_dataframe(
|
|
154
|
+
data,
|
|
155
|
+
spec,
|
|
156
|
+
)
|
|
157
|
+
raise Kpnn2Error(
|
|
158
|
+
"Unsupported input data type. Expected a pandas DataFrame."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _align_dataframe(
|
|
163
|
+
data: pd.DataFrame,
|
|
164
|
+
spec: LayeredSpec | AdjacencySpec,
|
|
165
|
+
) -> torch.Tensor:
|
|
166
|
+
"""
|
|
167
|
+
Reorder numeric DataFrame columns to ``spec.input_nodes``.
|
|
168
|
+
"""
|
|
169
|
+
str_columns = [str(name) for name in data.columns]
|
|
170
|
+
label_counts: dict[str, int] = {}
|
|
171
|
+
for label in str_columns:
|
|
172
|
+
label_counts[label] = label_counts.get(label, 0) + 1
|
|
173
|
+
duplicated_labels = sorted(
|
|
174
|
+
label for label, count in label_counts.items() if count > 1
|
|
175
|
+
)
|
|
176
|
+
if duplicated_labels:
|
|
177
|
+
labels_str = ", ".join(duplicated_labels)
|
|
178
|
+
raise Kpnn2Error(
|
|
179
|
+
"Input DataFrame must not contain duplicate column names "
|
|
180
|
+
"(including after converting labels to strings): "
|
|
181
|
+
f"{labels_str}."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
renamed = data.copy(deep=False)
|
|
185
|
+
renamed.columns = str_columns
|
|
186
|
+
|
|
187
|
+
missing = [name for name in spec.input_nodes if name not in renamed.columns]
|
|
188
|
+
if missing:
|
|
189
|
+
missing_str = ", ".join(sorted(missing))
|
|
190
|
+
raise Kpnn2Error(
|
|
191
|
+
f"Input data is missing required feature name(s): {missing_str}."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
ordered = renamed[list(spec.input_nodes)]
|
|
195
|
+
non_numeric = [
|
|
196
|
+
name
|
|
197
|
+
for name in spec.input_nodes
|
|
198
|
+
if not pd.api.types.is_numeric_dtype(ordered[name])
|
|
199
|
+
]
|
|
200
|
+
if non_numeric:
|
|
201
|
+
non_numeric_str = ", ".join(sorted(non_numeric))
|
|
202
|
+
raise Kpnn2Error(
|
|
203
|
+
"Input data contains non-numeric feature column(s): "
|
|
204
|
+
f"{non_numeric_str}."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
layout = build_layout(spec.input_nodes)
|
|
208
|
+
values = expand_columns(
|
|
209
|
+
ordered.to_numpy(copy=True),
|
|
210
|
+
layout,
|
|
211
|
+
)
|
|
212
|
+
return torch.tensor(
|
|
213
|
+
values,
|
|
214
|
+
dtype=torch.float32,
|
|
215
|
+
)
|