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 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
+ ]
@@ -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
+ )