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/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
+ )