structured-data-models-client 1.0.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.
- sdm_client/__init__.py +70 -0
- sdm_client/_version.py +5 -0
- sdm_client/adapters/__init__.py +9 -0
- sdm_client/adapters/relational.py +870 -0
- sdm_client/adapters/tabular.py +580 -0
- sdm_client/base.py +300 -0
- sdm_client/client.py +1416 -0
- sdm_client/core/__init__.py +5 -0
- sdm_client/core/databricks_serving.py +611 -0
- sdm_client/core/databricks_user_agent.py +76 -0
- sdm_client/core/dates.py +278 -0
- sdm_client/core/transport.py +898 -0
- sdm_client/core/warnings.py +51 -0
- sdm_client/decompose.py +421 -0
- sdm_client/errors.py +177 -0
- sdm_client/materializers/__init__.py +10 -0
- sdm_client/materializers/relational.py +1786 -0
- sdm_client/materializers/sources.py +331 -0
- sdm_client/materializers/tabular.py +339 -0
- sdm_client/models.py +251 -0
- sdm_client/py.typed +0 -0
- sdm_client/query.py +647 -0
- sdm_client/request.py +1127 -0
- sdm_client/result.py +328 -0
- sdm_client/schema.py +3187 -0
- sdm_client/wire/__init__.py +46 -0
- sdm_client/wire/answers.py +80 -0
- sdm_client/wire/dtypes.py +701 -0
- sdm_client/wire/keys.py +363 -0
- sdm_client/wire/tables.py +50 -0
- structured_data_models_client-1.0.0.dist-info/METADATA +99 -0
- structured_data_models_client-1.0.0.dist-info/RECORD +36 -0
- structured_data_models_client-1.0.0.dist-info/WHEEL +5 -0
- structured_data_models_client-1.0.0.dist-info/licenses/LICENSE +204 -0
- structured_data_models_client-1.0.0.dist-info/licenses/NOTICE +10 -0
- structured_data_models_client-1.0.0.dist-info/top_level.txt +1 -0
sdm_client/result.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""What a prediction gives back.
|
|
6
|
+
|
|
7
|
+
One type, always. The older design returned a DataFrame, or an explanation
|
|
8
|
+
object if you had asked to explain, which made the return an undocumented
|
|
9
|
+
union. Here an explanation is an output field like any other:
|
|
10
|
+
|
|
11
|
+
>>> result = client.predict(request, model=KumoRelational(),
|
|
12
|
+
... outputs=['prediction', 'explanation'])
|
|
13
|
+
>>> result.to_pandas() # always a frame
|
|
14
|
+
>>> result.explanation # None unless you asked for it
|
|
15
|
+
|
|
16
|
+
The frame's shape depends on the **task**, not the model. Classification and
|
|
17
|
+
multiclass both give one row per entity. Multiclass carries a probability per
|
|
18
|
+
class as columns rather than a row per class, while forecasting gives one row
|
|
19
|
+
per step. Ranking is the exception to the row-per-answer rule: the ``k`` ranked
|
|
20
|
+
candidates arrive as a **list in one row**, under ``rankings``, because
|
|
21
|
+
expanding them would make the frame's key no longer identify a row. A
|
|
22
|
+
candidate a ranking could not score is in no row, and is named on
|
|
23
|
+
``unscored_candidates`` instead.
|
|
24
|
+
|
|
25
|
+
Every row carries ``row_index``. It also carries ``id`` when the model has an
|
|
26
|
+
entity key, though a single-table request may have none. **Join on the key when
|
|
27
|
+
it is there, otherwise on** ``row_index``, **and never on row order.**
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Iterator, Sequence
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import pandas as pd
|
|
36
|
+
from sdm_api.generated.tfm_api import PredictionItem
|
|
37
|
+
|
|
38
|
+
from sdm_client.errors import StructuredDataError
|
|
39
|
+
|
|
40
|
+
# Emitted as columns when present on an item, in this order. ``row_index``
|
|
41
|
+
# leads because it is the one field the contract always requires.
|
|
42
|
+
_SCALAR_FIELDS = ('row_index', 'id', 'forecast_step', 'prediction')
|
|
43
|
+
# Spread into one column each, under this prefix. Named explicitly rather than
|
|
44
|
+
# singularised by stripping an 's', which turns 'probabilities' into
|
|
45
|
+
# 'probabilitie'.
|
|
46
|
+
_MAPPING_FIELDS = {'probabilities': 'probability', 'quantiles': 'quantile'}
|
|
47
|
+
_SEQUENCE_FIELDS = ('scores', 'embeddings')
|
|
48
|
+
|
|
49
|
+
_ITEM_FIELDS = (
|
|
50
|
+
'prediction',
|
|
51
|
+
'probabilities',
|
|
52
|
+
'quantiles',
|
|
53
|
+
'scores',
|
|
54
|
+
'rankings',
|
|
55
|
+
'embeddings',
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PredictionResult:
|
|
60
|
+
r"""The answer to one request.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
model: The model id that answered.
|
|
64
|
+
request_id: The endpoint's id for the call, useful when reading its
|
|
65
|
+
logs.
|
|
66
|
+
items: The response items, in the order the endpoint returned them.
|
|
67
|
+
Each item's ``id`` is the key that was asked about, in the type
|
|
68
|
+
the schema gave it, not the string the wire carried. The wire
|
|
69
|
+
spells every id as text, and a relational request spells it as an
|
|
70
|
+
opaque transport reference, so an item handed back unchanged would
|
|
71
|
+
answer a question about ``uid 3`` with something that is neither
|
|
72
|
+
``3`` nor recognisably about it.
|
|
73
|
+
metadata: Whatever the endpoint reported alongside the predictions.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
model: str,
|
|
80
|
+
request_id: str,
|
|
81
|
+
items: Sequence[PredictionItem],
|
|
82
|
+
metadata: dict[str, Any] | None = None,
|
|
83
|
+
explanation: dict[str, Any] | None = None,
|
|
84
|
+
outputs: Sequence[str] = (),
|
|
85
|
+
unscored_candidates: Sequence[Any] = (),
|
|
86
|
+
) -> None:
|
|
87
|
+
self.model = model
|
|
88
|
+
self.request_id = request_id
|
|
89
|
+
self.items = tuple(items)
|
|
90
|
+
self.outputs = tuple(outputs)
|
|
91
|
+
# Whether answers to this request name the row each one is about.
|
|
92
|
+
# Read only when there are no rows to read it from; `_empty_answer`
|
|
93
|
+
# sets it, because only the request knows.
|
|
94
|
+
self._names_its_rows = True
|
|
95
|
+
# Whether answers to this request say which forecast step they are
|
|
96
|
+
# about. Set where a forecast is assembled, for the same reason.
|
|
97
|
+
self._names_its_step = False
|
|
98
|
+
self.metadata = dict(metadata or {})
|
|
99
|
+
self._explanation = explanation
|
|
100
|
+
self._unscored_candidates = tuple(unscored_candidates)
|
|
101
|
+
|
|
102
|
+
def absent(self, requested: Sequence[str]) -> tuple[str, ...]:
|
|
103
|
+
r"""Which of ``requested`` the answer does not carry.
|
|
104
|
+
|
|
105
|
+
A field a model produces for one task kind and not another is accepted
|
|
106
|
+
by the endpoint, answered `200`, and simply left out. A caller who
|
|
107
|
+
names it reads a frame that has no such column and nothing anywhere
|
|
108
|
+
said why. Rather than deciding in advance which model produces what for
|
|
109
|
+
which kind, which is a moving target across deployments, this reports
|
|
110
|
+
what the answer turned out not to contain.
|
|
111
|
+
|
|
112
|
+
An empty batch is a supported request with a real empty answer, so
|
|
113
|
+
nothing can be looked for and nothing is reported. Calling every field
|
|
114
|
+
missing there would make the one case that is always legitimate the
|
|
115
|
+
noisiest.
|
|
116
|
+
"""
|
|
117
|
+
if not self.items:
|
|
118
|
+
return ()
|
|
119
|
+
return tuple(
|
|
120
|
+
name for name in dict.fromkeys(requested) if not self._carries(name)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _carries(self, name: str) -> bool:
|
|
124
|
+
r"""Whether the answer holds anything under ``name``.
|
|
125
|
+
|
|
126
|
+
A name this type does not know how to look for is treated as carried:
|
|
127
|
+
reporting it absent would be a guess, and a field no model produces
|
|
128
|
+
was refused before the request was sent.
|
|
129
|
+
"""
|
|
130
|
+
if name == 'explanation':
|
|
131
|
+
return self._explanation is not None
|
|
132
|
+
if name not in _ITEM_FIELDS:
|
|
133
|
+
return True
|
|
134
|
+
return any(
|
|
135
|
+
getattr(item, name, None) not in (None, {}, [], ())
|
|
136
|
+
for item in self.items
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def explanation(self) -> dict[str, Any] | None:
|
|
141
|
+
r"""The explanation, when asked for and the model produced one.
|
|
142
|
+
|
|
143
|
+
``None`` otherwise, rather than a different return type.
|
|
144
|
+
"""
|
|
145
|
+
return self._explanation
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def unscored_candidates(self) -> tuple[Any, ...]:
|
|
149
|
+
r"""The candidates a ranking left out, in the order they were named.
|
|
150
|
+
|
|
151
|
+
A candidate is left out when the context sampled for it holds one
|
|
152
|
+
answer only, which nearly always means no entity in it linked to the
|
|
153
|
+
candidate in the window. The endpoint refuses that question, so no
|
|
154
|
+
call is made for it and no score is invented for it: a row asked about
|
|
155
|
+
in that ranking does not hold it in ``prediction`` or ``rankings``,
|
|
156
|
+
and may therefore rank fewer than ``k`` candidates.
|
|
157
|
+
|
|
158
|
+
On a merge of rankings asked separately, a candidate is named here when
|
|
159
|
+
it was left out of at least one part's rows, and may be scored in the
|
|
160
|
+
rows of another part.
|
|
161
|
+
|
|
162
|
+
Empty when every candidate was scored, and on any result that is not
|
|
163
|
+
a ranking.
|
|
164
|
+
"""
|
|
165
|
+
return self._unscored_candidates
|
|
166
|
+
|
|
167
|
+
def to_pandas(self) -> pd.DataFrame:
|
|
168
|
+
r"""The predictions as a frame.
|
|
169
|
+
|
|
170
|
+
A column appears when at least one item carries that field, and
|
|
171
|
+
not otherwise: this reads the answer, not the task or the fields
|
|
172
|
+
that were asked for, so nothing is invented to fill a shape and
|
|
173
|
+
nothing carried is dropped. A probability or
|
|
174
|
+
quantile mapping is spread into one column per key, prefixed, so the
|
|
175
|
+
frame stays flat and joinable.
|
|
176
|
+
|
|
177
|
+
Rows come back ordered by ``row_index``. The endpoint is not required
|
|
178
|
+
to preserve request order, and a caller who reads the first few rows
|
|
179
|
+
would otherwise be reading whichever rows finished first.
|
|
180
|
+
|
|
181
|
+
A result with no rows still has columns. Scoring an empty frame is
|
|
182
|
+
legal because the contract puts no minimum on ``rows``. It is what a
|
|
183
|
+
filter that matched nothing produces, so it happens inside ordinary
|
|
184
|
+
loops. Returning a frame with no columns at all made that case fail
|
|
185
|
+
differently from every other: ``frame['prediction']`` became a
|
|
186
|
+
``KeyError`` and a join on ``row_index`` could not be written.
|
|
187
|
+
|
|
188
|
+
The per-class columns cannot be known with no rows to read them
|
|
189
|
+
from, so an empty classification result carries the fields that
|
|
190
|
+
were asked for but never a ``probability_*``.
|
|
191
|
+
"""
|
|
192
|
+
rows: list[dict[str, Any]] = []
|
|
193
|
+
for item in self.items:
|
|
194
|
+
row: dict[str, Any] = {}
|
|
195
|
+
for name in _SCALAR_FIELDS:
|
|
196
|
+
value = getattr(item, name, None)
|
|
197
|
+
if value is not None:
|
|
198
|
+
row[name] = value
|
|
199
|
+
for name, prefix in _MAPPING_FIELDS.items():
|
|
200
|
+
mapping = getattr(item, name, None)
|
|
201
|
+
if mapping:
|
|
202
|
+
for key, value in mapping.items():
|
|
203
|
+
row[f'{prefix}_{key}'] = value
|
|
204
|
+
for name in _SEQUENCE_FIELDS:
|
|
205
|
+
values = getattr(item, name, None)
|
|
206
|
+
if values is not None:
|
|
207
|
+
row[name] = list(values)
|
|
208
|
+
if item.rankings:
|
|
209
|
+
row['rankings'] = [dict(entry) for entry in item.rankings]
|
|
210
|
+
rows.append(row)
|
|
211
|
+
|
|
212
|
+
frame = pd.DataFrame(rows) if rows else self._empty_frame()
|
|
213
|
+
if 'row_index' in frame.columns:
|
|
214
|
+
frame = frame.sort_values('row_index', kind='stable').reset_index(
|
|
215
|
+
drop=True
|
|
216
|
+
)
|
|
217
|
+
return frame
|
|
218
|
+
|
|
219
|
+
def _empty_frame(self) -> pd.DataFrame:
|
|
220
|
+
r"""The columns this request would have produced, with no rows.
|
|
221
|
+
|
|
222
|
+
``row_index`` and ``id`` say which row an answer belongs to rather
|
|
223
|
+
than what the answer is, so they are here whatever was asked for.
|
|
224
|
+
The join a caller writes must not depend on the batch being empty.
|
|
225
|
+
``id`` only when this request names its rows at all, which is the
|
|
226
|
+
same condition a non-empty answer carries it under.
|
|
227
|
+
"""
|
|
228
|
+
columns = ['row_index']
|
|
229
|
+
if self._names_its_rows:
|
|
230
|
+
columns.append('id')
|
|
231
|
+
if self._names_its_step:
|
|
232
|
+
columns.append('forecast_step')
|
|
233
|
+
columns += [
|
|
234
|
+
name
|
|
235
|
+
for name in (*_SCALAR_FIELDS, *_SEQUENCE_FIELDS, 'rankings')
|
|
236
|
+
if name not in ('row_index', 'id', 'forecast_step')
|
|
237
|
+
and name in self.outputs
|
|
238
|
+
]
|
|
239
|
+
return pd.DataFrame({name: pd.Series(dtype=object) for name in columns})
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def merge(cls, parts: Sequence[PredictionResult]) -> PredictionResult:
|
|
243
|
+
r"""Join the results of one request that was sent in several calls.
|
|
244
|
+
|
|
245
|
+
A request larger than the endpoint's per-call cap is split by the
|
|
246
|
+
adapter, and each call answers about its own rows. Keeping only the
|
|
247
|
+
last answer would silently drop every earlier batch, so the parts are
|
|
248
|
+
concatenated here.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
StructuredDataError: If ``parts`` is empty, which would mean a
|
|
252
|
+
request was sent with nothing to score, or if the parts do
|
|
253
|
+
not describe one request.
|
|
254
|
+
"""
|
|
255
|
+
if not parts:
|
|
256
|
+
raise StructuredDataError(
|
|
257
|
+
'no parts to merge', code='INTERNAL_ERROR'
|
|
258
|
+
)
|
|
259
|
+
if len(parts) == 1:
|
|
260
|
+
return parts[0]
|
|
261
|
+
first = parts[0]
|
|
262
|
+
models = {part.model for part in parts}
|
|
263
|
+
if len(models) > 1:
|
|
264
|
+
raise StructuredDataError(
|
|
265
|
+
f'cannot merge results from different models: {sorted(models)}',
|
|
266
|
+
code='INTERNAL_ERROR',
|
|
267
|
+
)
|
|
268
|
+
items = [item for part in parts for item in part.items]
|
|
269
|
+
metadata = dict(first.metadata)
|
|
270
|
+
metadata['batches'] = len(parts)
|
|
271
|
+
# Each call has its own id in the endpoint's logs, and only the first
|
|
272
|
+
# would otherwise survive. That is the one you do not have when a
|
|
273
|
+
# later batch is the one that misbehaved.
|
|
274
|
+
metadata['batch_request_ids'] = [part.request_id for part in parts]
|
|
275
|
+
explanation = next(
|
|
276
|
+
(part.explanation for part in parts if part.explanation), None
|
|
277
|
+
)
|
|
278
|
+
# Every part answers one slice of the same request, so they were asked
|
|
279
|
+
# for the same fields. If they were not, the merged result cannot
|
|
280
|
+
# honestly claim either set, and an empty one would take its shape from
|
|
281
|
+
# whichever part happened to be first.
|
|
282
|
+
shapes = {part.outputs for part in parts}
|
|
283
|
+
if len(shapes) > 1:
|
|
284
|
+
raise StructuredDataError(
|
|
285
|
+
f'these parts were asked for different outputs '
|
|
286
|
+
f'({sorted(shapes)}), so they are not slices of one request',
|
|
287
|
+
code='INTERNAL_ERROR',
|
|
288
|
+
)
|
|
289
|
+
# Named when any part left it out. Which rows ranked without it is
|
|
290
|
+
# not recorded, so the union is the most that can be said.
|
|
291
|
+
unscored: list[Any] = []
|
|
292
|
+
for part in parts:
|
|
293
|
+
unscored += [
|
|
294
|
+
candidate
|
|
295
|
+
for candidate in part.unscored_candidates
|
|
296
|
+
if candidate not in unscored
|
|
297
|
+
]
|
|
298
|
+
merged = cls(
|
|
299
|
+
model=first.model,
|
|
300
|
+
request_id=first.request_id,
|
|
301
|
+
items=items,
|
|
302
|
+
metadata=metadata,
|
|
303
|
+
explanation=explanation,
|
|
304
|
+
outputs=first.outputs,
|
|
305
|
+
unscored_candidates=unscored,
|
|
306
|
+
)
|
|
307
|
+
merged._names_its_rows = all(part._names_its_rows for part in parts)
|
|
308
|
+
merged._names_its_step = any(part._names_its_step for part in parts)
|
|
309
|
+
return merged
|
|
310
|
+
|
|
311
|
+
def __len__(self) -> int:
|
|
312
|
+
return len(self.items)
|
|
313
|
+
|
|
314
|
+
def __iter__(self) -> Iterator[PredictionItem]:
|
|
315
|
+
return iter(self.items)
|
|
316
|
+
|
|
317
|
+
def __repr__(self) -> str:
|
|
318
|
+
explained = ', explained' if self._explanation is not None else ''
|
|
319
|
+
left_out = len(self._unscored_candidates)
|
|
320
|
+
unscored = (
|
|
321
|
+
f', {left_out} candidate{"s" if left_out > 1 else ""} unscored'
|
|
322
|
+
if left_out
|
|
323
|
+
else ''
|
|
324
|
+
)
|
|
325
|
+
return (
|
|
326
|
+
f'PredictionResult({self.model!r}, {len(self.items)} rows'
|
|
327
|
+
f'{explained}{unscored})'
|
|
328
|
+
)
|