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
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""The engine's graph over tables that stay in their source.
|
|
6
|
+
|
|
7
|
+
One place builds it, because two need it: `Schema.from_source` reads the
|
|
8
|
+
source's metadata through it, and the relational materializer samples through
|
|
9
|
+
it. Both must agree on which columns a table has and where it lives.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import warnings
|
|
15
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from sdm_client.core.warnings import SDMWarning, caller_stacklevel
|
|
20
|
+
from sdm_client.errors import MissingExtraError, StructuredDataError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from sdm_relational_engine.rfm import Graph
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EngineTables:
|
|
27
|
+
r"""Engine tables read once per source.
|
|
28
|
+
|
|
29
|
+
Each gather gets copies, because it writes keys, time columns and stypes
|
|
30
|
+
onto them. Time ranges are not kept, so a schema sees new rows.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._tables: dict[str, Any] = {}
|
|
35
|
+
|
|
36
|
+
def keep(self, tables: Mapping[str, Any]) -> None:
|
|
37
|
+
r"""Keyed by the schema's source name; Databricks and Postgres qualify
|
|
38
|
+
the engine's.
|
|
39
|
+
"""
|
|
40
|
+
for key, table in tables.items():
|
|
41
|
+
self._tables.setdefault(key, table)
|
|
42
|
+
|
|
43
|
+
def graph(
|
|
44
|
+
self, source: Any, tables: Mapping[str, tuple[str, Sequence[str]]]
|
|
45
|
+
) -> Graph:
|
|
46
|
+
r"""A fresh graph over copies of ``tables`` in ``source``, without
|
|
47
|
+
edges: the schema decides those.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
source: A ``Source``, or a ``SemanticModel`` kept in one.
|
|
51
|
+
tables: Schema name to (source name, columns kept). Only tables
|
|
52
|
+
not read yet are read.
|
|
53
|
+
"""
|
|
54
|
+
from sdm_relational_engine.rfm import Graph
|
|
55
|
+
|
|
56
|
+
missing = [
|
|
57
|
+
source_name
|
|
58
|
+
for source_name, _ in tables.values()
|
|
59
|
+
if source_name not in self._tables
|
|
60
|
+
]
|
|
61
|
+
if missing:
|
|
62
|
+
if is_semantic_model(source):
|
|
63
|
+
model = semantic_model_graph(source)
|
|
64
|
+
self.keep({name: model[name] for name in model.tables})
|
|
65
|
+
else:
|
|
66
|
+
read = engine_graph(
|
|
67
|
+
source,
|
|
68
|
+
{name: name for name in missing},
|
|
69
|
+
infer_metadata=False,
|
|
70
|
+
)
|
|
71
|
+
self.keep({name: read[name] for name in missing})
|
|
72
|
+
return Graph(
|
|
73
|
+
tables=[
|
|
74
|
+
_copy_for_one_gather(self._tables[source_name], name, columns)
|
|
75
|
+
for name, (source_name, columns) in tables.items()
|
|
76
|
+
],
|
|
77
|
+
edges=[],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _copy_for_one_gather(table: Any, name: str, columns: Sequence[str]) -> Any:
|
|
82
|
+
copied = table.copy(name)
|
|
83
|
+
copied.end_time_column = None
|
|
84
|
+
copied.time_column = None
|
|
85
|
+
copied.primary_key = None
|
|
86
|
+
for column in [c.name for c in copied.columns if c.name not in columns]:
|
|
87
|
+
copied.remove_column(column)
|
|
88
|
+
return copied
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# The engine's dtypes a scalar wire column can carry, as the wire names them.
|
|
92
|
+
# Lists, binary and durations have no scalar wire form.
|
|
93
|
+
_WIRE_DTYPE = {
|
|
94
|
+
'bool': 'bool',
|
|
95
|
+
'int': 'int64',
|
|
96
|
+
'byte': 'int32',
|
|
97
|
+
'int16': 'int32',
|
|
98
|
+
'int32': 'int32',
|
|
99
|
+
'int64': 'int64',
|
|
100
|
+
'float': 'float64',
|
|
101
|
+
'float32': 'float32',
|
|
102
|
+
'float64': 'float64',
|
|
103
|
+
'string': 'string',
|
|
104
|
+
'date': 'timestamp[us]',
|
|
105
|
+
'time': 'timestamp[us]',
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def wire_dtype(engine_dtype: Any) -> str | None:
|
|
110
|
+
r"""The wire dtype for an engine column, or ``None`` if it has none."""
|
|
111
|
+
return _WIRE_DTYPE.get(str(engine_dtype))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@contextmanager
|
|
115
|
+
def _schema_says_time_columns() -> Iterator[None]:
|
|
116
|
+
r"""Keep the engine from warning about a time column it kept unchecked:
|
|
117
|
+
the schema says that, with its own override, in one notice.
|
|
118
|
+
"""
|
|
119
|
+
from sdm_relational_engine.rfm import TimeColumnWarning
|
|
120
|
+
|
|
121
|
+
with warnings.catch_warnings():
|
|
122
|
+
warnings.simplefilter('ignore', TimeColumnWarning)
|
|
123
|
+
yield
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def engine_graph(
|
|
127
|
+
source: Any, tables: Mapping[str, str] | None, *, infer_metadata: bool
|
|
128
|
+
) -> Graph:
|
|
129
|
+
r"""The engine's graph over ``tables`` in ``source``, without edges: the
|
|
130
|
+
schema decides those.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
source: A ``Source``.
|
|
134
|
+
tables: Schema name to source name; ``None`` reads every table.
|
|
135
|
+
infer_metadata: Let the engine infer keys, time columns and stypes.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
from sdm_relational_engine import (
|
|
139
|
+
MetricViewAsTableError,
|
|
140
|
+
NoTablesFoundError,
|
|
141
|
+
TableNotFoundError,
|
|
142
|
+
)
|
|
143
|
+
except ImportError as error: # pragma: no cover - optional extra
|
|
144
|
+
raise MissingExtraError(
|
|
145
|
+
'relational', 'structured-data-models-relational-engine'
|
|
146
|
+
) from error
|
|
147
|
+
|
|
148
|
+
specs = (
|
|
149
|
+
None
|
|
150
|
+
if tables is None
|
|
151
|
+
else [
|
|
152
|
+
{'name': name, 'source_name': source_name}
|
|
153
|
+
for name, source_name in tables.items()
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
try:
|
|
157
|
+
with _schema_says_time_columns():
|
|
158
|
+
return _read(source, specs, infer_metadata)
|
|
159
|
+
except NoTablesFoundError as error:
|
|
160
|
+
# The engine's own advice names its API, which cannot fill a namespace.
|
|
161
|
+
raise StructuredDataError(
|
|
162
|
+
f'{source!r} holds no table to describe', code='INVALID_REQUEST'
|
|
163
|
+
) from error
|
|
164
|
+
except TableNotFoundError as error:
|
|
165
|
+
raise StructuredDataError(str(error), code='INVALID_REQUEST') from error
|
|
166
|
+
except MetricViewAsTableError as error:
|
|
167
|
+
# The engine's advice names its own API; here a source reads the view.
|
|
168
|
+
raise StructuredDataError(
|
|
169
|
+
f'{error.name!r} is a metric view, not a table: its measures are '
|
|
170
|
+
f'aggregations with no value per row. Read it as a semantic model '
|
|
171
|
+
f'instead, with source.semantic_model({error.name!r}).',
|
|
172
|
+
code='INVALID_REQUEST',
|
|
173
|
+
details={'metric_view': error.name},
|
|
174
|
+
) from error
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _read(
|
|
178
|
+
source: Any, specs: list[dict[str, Any]] | None, infer_metadata: bool
|
|
179
|
+
) -> Graph:
|
|
180
|
+
from sdm_relational_engine.rfm import Graph
|
|
181
|
+
|
|
182
|
+
backend = source.backend
|
|
183
|
+
namespace = source.namespace
|
|
184
|
+
connection = source.connection
|
|
185
|
+
if backend == 'databricks':
|
|
186
|
+
return Graph.from_databricks(
|
|
187
|
+
connection,
|
|
188
|
+
tables=specs,
|
|
189
|
+
catalog=namespace.get('catalog'),
|
|
190
|
+
schema=namespace.get('schema'),
|
|
191
|
+
edges=[],
|
|
192
|
+
infer_metadata=infer_metadata,
|
|
193
|
+
verbose=False,
|
|
194
|
+
)
|
|
195
|
+
if backend == 'snowflake':
|
|
196
|
+
return Graph.from_snowflake(
|
|
197
|
+
connection,
|
|
198
|
+
tables=specs,
|
|
199
|
+
database=namespace.get('database'),
|
|
200
|
+
schema=namespace.get('schema'),
|
|
201
|
+
edges=[],
|
|
202
|
+
infer_metadata=infer_metadata,
|
|
203
|
+
verbose=False,
|
|
204
|
+
)
|
|
205
|
+
if backend == 'postgres':
|
|
206
|
+
return Graph.from_postgres(
|
|
207
|
+
connection,
|
|
208
|
+
tables=specs,
|
|
209
|
+
schema=namespace.get('schema'),
|
|
210
|
+
edges=[],
|
|
211
|
+
infer_metadata=infer_metadata,
|
|
212
|
+
verbose=False,
|
|
213
|
+
)
|
|
214
|
+
if backend == 'duckdb':
|
|
215
|
+
return Graph.from_duckdb(
|
|
216
|
+
connection,
|
|
217
|
+
tables=specs,
|
|
218
|
+
edges=[],
|
|
219
|
+
infer_metadata=infer_metadata,
|
|
220
|
+
verbose=False,
|
|
221
|
+
)
|
|
222
|
+
if backend == 'sqlite':
|
|
223
|
+
return Graph.from_sqlite(
|
|
224
|
+
connection,
|
|
225
|
+
tables=specs,
|
|
226
|
+
edges=[],
|
|
227
|
+
infer_metadata=infer_metadata,
|
|
228
|
+
verbose=False,
|
|
229
|
+
)
|
|
230
|
+
raise StructuredDataError( # pragma: no cover - Source refuses others
|
|
231
|
+
f'no engine backend samples a {backend!r} source',
|
|
232
|
+
code='INVALID_REQUEST',
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def is_semantic_model(source: Any) -> bool:
|
|
237
|
+
r"""Whether ``source`` is a semantic model rather than a namespace."""
|
|
238
|
+
try:
|
|
239
|
+
from sdm_connectors import SemanticModel
|
|
240
|
+
except ImportError: # pragma: no cover - nothing could have built one
|
|
241
|
+
return False
|
|
242
|
+
return isinstance(source, SemanticModel)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def semantic_model_graph(model: Any) -> Graph:
|
|
246
|
+
r"""The engine's graph of a semantic model: a Databricks metric view or a
|
|
247
|
+
Snowflake semantic view.
|
|
248
|
+
|
|
249
|
+
Named after the model's own tables, with its links, keys and time columns
|
|
250
|
+
as the model declares them. Discovery and gathering both read it here, so
|
|
251
|
+
they keep the same columns.
|
|
252
|
+
|
|
253
|
+
What the model has that a graph cannot hold is said in one
|
|
254
|
+
:class:`SDMWarning`, and nothing is said for a model that loses nothing:
|
|
255
|
+
how it was read is in the graph's ``conversion_notes``, which the schema
|
|
256
|
+
renders as text. A model the
|
|
257
|
+
engine refuses, for a filter on a joined table say, is refused with a
|
|
258
|
+
:class:`StructuredDataError` that carries the engine's reason.
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
from sdm_relational_engine import (
|
|
262
|
+
TableNotFoundError,
|
|
263
|
+
ViewConversionError,
|
|
264
|
+
)
|
|
265
|
+
from sdm_relational_engine.rfm import Graph
|
|
266
|
+
except ImportError as error: # pragma: no cover - optional extra
|
|
267
|
+
raise MissingExtraError(
|
|
268
|
+
'relational', 'structured-data-models-relational-engine'
|
|
269
|
+
) from error
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
# The engine is told not to warn, rather than its warnings filtered,
|
|
273
|
+
# since a filter holds for every thread: what it would say is said
|
|
274
|
+
# below, and by the schema, to the caller.
|
|
275
|
+
graph = _model_graph(Graph, model)
|
|
276
|
+
except (ViewConversionError, TableNotFoundError) as error:
|
|
277
|
+
# The engine's refusals of the model, each saying what to change in
|
|
278
|
+
# it, and a table it reads that is not there, as for a source's own
|
|
279
|
+
# tables. Any other failure is not the model's, and is not one.
|
|
280
|
+
raise StructuredDataError(
|
|
281
|
+
str(error),
|
|
282
|
+
code='INVALID_REQUEST',
|
|
283
|
+
details={'semantic_model': model.name},
|
|
284
|
+
) from error
|
|
285
|
+
if graph.conversion_messages:
|
|
286
|
+
warnings.warn(
|
|
287
|
+
f'{model!r} has what a graph cannot hold, so it is read in '
|
|
288
|
+
f'part:\n'
|
|
289
|
+
+ '\n'.join(
|
|
290
|
+
f'- {message}' for message in graph.conversion_messages
|
|
291
|
+
),
|
|
292
|
+
category=SDMWarning,
|
|
293
|
+
stacklevel=caller_stacklevel(),
|
|
294
|
+
)
|
|
295
|
+
return graph
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _model_graph(graph_type: type[Graph], model: Any) -> Graph:
|
|
299
|
+
namespace = model.namespace
|
|
300
|
+
if model.backend == 'databricks':
|
|
301
|
+
return graph_type.from_databricks_metric_view(
|
|
302
|
+
model.name,
|
|
303
|
+
model.connection,
|
|
304
|
+
catalog=namespace.get('catalog'),
|
|
305
|
+
schema=namespace.get('schema'),
|
|
306
|
+
verbose=False,
|
|
307
|
+
trust_expressions=model.trust_expressions,
|
|
308
|
+
warn=False,
|
|
309
|
+
)
|
|
310
|
+
if model.backend == 'snowflake':
|
|
311
|
+
# The engine resolves an unqualified name against the connection's
|
|
312
|
+
# current database and schema, so the source's own are written in.
|
|
313
|
+
parts = [
|
|
314
|
+
namespace[part]
|
|
315
|
+
for part in ('database', 'schema')
|
|
316
|
+
if part in namespace
|
|
317
|
+
]
|
|
318
|
+
name = (
|
|
319
|
+
model.name if '.' in model.name else '.'.join([*parts, model.name])
|
|
320
|
+
)
|
|
321
|
+
return graph_type.from_snowflake_semantic_view(
|
|
322
|
+
name,
|
|
323
|
+
model.connection,
|
|
324
|
+
verbose=False,
|
|
325
|
+
trust_expressions=model.trust_expressions,
|
|
326
|
+
warn=False,
|
|
327
|
+
)
|
|
328
|
+
raise StructuredDataError( # pragma: no cover - SemanticModel refuses
|
|
329
|
+
f'no semantic model is kept in a {model.backend!r} source',
|
|
330
|
+
code='INVALID_REQUEST',
|
|
331
|
+
)
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""Build single-table requests from rows supplied by the caller."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numbers
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Hashable, Sequence
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from predictive_query import Stype
|
|
17
|
+
from predictive_query.validator.utils import col_name, table_name
|
|
18
|
+
|
|
19
|
+
from sdm_client.core.warnings import SDMWarning, caller_stacklevel
|
|
20
|
+
from sdm_client.errors import StructuredDataError
|
|
21
|
+
from sdm_client.models import TABULAR_LIMITS
|
|
22
|
+
from sdm_client.request import Request
|
|
23
|
+
from sdm_client.schema import Schema, SourceTable, Table
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from predictive_query import ValidatedPredictiveQuery
|
|
27
|
+
|
|
28
|
+
_CONTEXT_SEED = 42
|
|
29
|
+
_CONTEXT_ROWS_MAX = TABULAR_LIMITS['context_rows_max']
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TABULAR_TASK_KINDS = frozenset({'classification', 'regression'})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_whole(value: object) -> bool:
|
|
36
|
+
r"""Whether ``value`` is a number with nothing after the decimal point."""
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
return True
|
|
39
|
+
if isinstance(value, numbers.Integral):
|
|
40
|
+
return True
|
|
41
|
+
if isinstance(value, Decimal):
|
|
42
|
+
return value.is_finite() and value == value.to_integral_value()
|
|
43
|
+
if isinstance(value, numbers.Real):
|
|
44
|
+
try:
|
|
45
|
+
return float(value).is_integer()
|
|
46
|
+
except (OverflowError, TypeError, ValueError):
|
|
47
|
+
return False
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _derive_task_kind(
|
|
52
|
+
query: ValidatedPredictiveQuery, target: pd.Series
|
|
53
|
+
) -> str:
|
|
54
|
+
r"""The task the target implies, when the caller named none."""
|
|
55
|
+
if query.target_stype == Stype.categorical:
|
|
56
|
+
return 'classification'
|
|
57
|
+
if _holds_two_whole_numbers(target):
|
|
58
|
+
warnings.warn(
|
|
59
|
+
f'{query.target_column} holds two whole numbers, which is how '
|
|
60
|
+
f'a binary label is usually written, so this is being asked as '
|
|
61
|
+
f'a classification. If they are quantities rather than labels, '
|
|
62
|
+
f'pass task="regression" to ask for one; pass '
|
|
63
|
+
f'task="classification" to say this was meant and silence '
|
|
64
|
+
f'this.',
|
|
65
|
+
category=SDMWarning,
|
|
66
|
+
stacklevel=caller_stacklevel(),
|
|
67
|
+
)
|
|
68
|
+
return 'classification'
|
|
69
|
+
return 'regression'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _holds_two_whole_numbers(target: pd.Series) -> bool:
|
|
73
|
+
r"""Whether ``target`` is two distinct whole numbers and nothing else.
|
|
74
|
+
|
|
75
|
+
The values decide, not their storage: a 0/1 column with a null arrives as
|
|
76
|
+
``float64`` and is still a label. Anything wider or fractional is left to
|
|
77
|
+
``task=``.
|
|
78
|
+
"""
|
|
79
|
+
values = target.dropna().unique()
|
|
80
|
+
if len(values) != 2:
|
|
81
|
+
return False
|
|
82
|
+
return all(_is_whole(value) for value in values)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _asking(request: Request, query: Any) -> Request:
|
|
86
|
+
"""Record the question a request was built from.
|
|
87
|
+
|
|
88
|
+
A session pins one question and only the rows travel on a scoring call, so
|
|
89
|
+
the question is what tells one request from another. The relational route
|
|
90
|
+
records it when it gathers; this route builds the request directly and did
|
|
91
|
+
not, which left a tabular session unable to tell that it had been handed
|
|
92
|
+
something else.
|
|
93
|
+
"""
|
|
94
|
+
request.query = query.to_string()
|
|
95
|
+
return request
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TabularMaterializer:
|
|
99
|
+
r"""Materialize resolved PQL against a single-table schema.
|
|
100
|
+
|
|
101
|
+
The ``TABULAR`` profile has already refused anything this cannot honour
|
|
102
|
+
(a filtered entity, an ``ASSUMING`` clause, a windowed or derived
|
|
103
|
+
target),
|
|
104
|
+
so the target here is a real column of the one table.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, schema: Schema) -> None:
|
|
108
|
+
self.schema = schema
|
|
109
|
+
|
|
110
|
+
def table(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
query: ValidatedPredictiveQuery,
|
|
114
|
+
predict: pd.DataFrame,
|
|
115
|
+
task: str | None = None,
|
|
116
|
+
) -> Request:
|
|
117
|
+
r"""Build a request from rows supplied separately by the caller.
|
|
118
|
+
|
|
119
|
+
The context is the table minus the rows being scored and minus the
|
|
120
|
+
rows with nothing to teach. The other route already left out both;
|
|
121
|
+
this one left out neither, so the same table asked the same question
|
|
122
|
+
two ways built two different contexts.
|
|
123
|
+
"""
|
|
124
|
+
entity_table_name = table_name(query.entity_column)
|
|
125
|
+
entity_col = col_name(query.entity_column)
|
|
126
|
+
target_col = col_name(query.target_column)
|
|
127
|
+
|
|
128
|
+
context = _context_within_limit(
|
|
129
|
+
_rows_that_carry_an_answer(
|
|
130
|
+
_without_the_rows_being_scored(
|
|
131
|
+
_rows(self.schema[entity_table_name]), predict, entity_col
|
|
132
|
+
),
|
|
133
|
+
target_col,
|
|
134
|
+
),
|
|
135
|
+
None,
|
|
136
|
+
entity_table_name,
|
|
137
|
+
'This route takes no num_context_samples, so choose the '
|
|
138
|
+
'context by building the schema from the rows you want.',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return _asking(
|
|
142
|
+
Request(
|
|
143
|
+
context=context,
|
|
144
|
+
predict=predict,
|
|
145
|
+
target=target_col,
|
|
146
|
+
task=self._task_kind(query, context[target_col], task),
|
|
147
|
+
schema=self.schema.snapshot(),
|
|
148
|
+
entity_table=entity_table_name,
|
|
149
|
+
),
|
|
150
|
+
query,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def indices(
|
|
154
|
+
self,
|
|
155
|
+
query: ValidatedPredictiveQuery,
|
|
156
|
+
indices: Sequence[Any],
|
|
157
|
+
num_context_samples: int | None,
|
|
158
|
+
task: str | None = None,
|
|
159
|
+
) -> Request:
|
|
160
|
+
r"""Build a single-table request for rows in the table."""
|
|
161
|
+
entity_table_name = table_name(query.entity_column)
|
|
162
|
+
entity_table = _rows(self.schema[entity_table_name])
|
|
163
|
+
entity_col = col_name(query.entity_column)
|
|
164
|
+
target_col = col_name(query.target_column)
|
|
165
|
+
|
|
166
|
+
indices = list(indices)
|
|
167
|
+
positions: dict[Hashable, list[int]] = {}
|
|
168
|
+
for position, value in enumerate(entity_table[entity_col]):
|
|
169
|
+
positions.setdefault(value, []).append(position)
|
|
170
|
+
|
|
171
|
+
missing = [value for value in indices if value not in positions]
|
|
172
|
+
if missing:
|
|
173
|
+
shown = missing[:10]
|
|
174
|
+
len_missing = len(missing)
|
|
175
|
+
tail = '' if len_missing == len(shown) else ', ...'
|
|
176
|
+
raise StructuredDataError(
|
|
177
|
+
f'{len_missing} of the {len(indices)} indices are not in '
|
|
178
|
+
f'{query.entity_column}: {shown}{tail}. Pass predict=<frame> '
|
|
179
|
+
f'to score rows not present in the table',
|
|
180
|
+
code='INVALID_REQUEST',
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
scored = [
|
|
184
|
+
position for value in indices for position in positions[value]
|
|
185
|
+
]
|
|
186
|
+
predict = entity_table.iloc[scored].drop(columns=[target_col])
|
|
187
|
+
context_mask = entity_table[target_col].notna()
|
|
188
|
+
context_mask.iloc[scored] = False
|
|
189
|
+
candidates = entity_table[context_mask]
|
|
190
|
+
|
|
191
|
+
context = _context_within_limit(
|
|
192
|
+
candidates,
|
|
193
|
+
num_context_samples,
|
|
194
|
+
entity_table_name,
|
|
195
|
+
'Pass num_context_samples to choose how many, or select the rows '
|
|
196
|
+
'yourself.',
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return _asking(
|
|
200
|
+
Request(
|
|
201
|
+
context=context,
|
|
202
|
+
predict=predict,
|
|
203
|
+
target=target_col,
|
|
204
|
+
task=self._task_kind(query, context[target_col], task),
|
|
205
|
+
schema=self.schema.snapshot(),
|
|
206
|
+
entity_table=entity_table_name,
|
|
207
|
+
),
|
|
208
|
+
query,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _task_kind(
|
|
213
|
+
query: ValidatedPredictiveQuery,
|
|
214
|
+
target: pd.Series,
|
|
215
|
+
stated: str | None,
|
|
216
|
+
) -> str:
|
|
217
|
+
r"""What is being asked of the target, and who decided it.
|
|
218
|
+
|
|
219
|
+
``target`` is the context that will be sent, not the column it came
|
|
220
|
+
from; the two differ, and the narrower one is what the model learns
|
|
221
|
+
from.
|
|
222
|
+
"""
|
|
223
|
+
if query.target_stype not in {Stype.categorical, Stype.numerical}:
|
|
224
|
+
raise StructuredDataError(
|
|
225
|
+
f'tabular materialization requires a categorical or '
|
|
226
|
+
f'numerical target; {query.target_column!r} has semantic '
|
|
227
|
+
f'type {query.target_stype.value!r}',
|
|
228
|
+
code='UNSUPPORTED_REQUEST',
|
|
229
|
+
)
|
|
230
|
+
if stated is not None and (
|
|
231
|
+
not isinstance(stated, str) or stated not in TABULAR_TASK_KINDS
|
|
232
|
+
):
|
|
233
|
+
raise StructuredDataError(
|
|
234
|
+
f'the tabular model does not serve the task kind '
|
|
235
|
+
f'{stated!r}; it serves {sorted(TABULAR_TASK_KINDS)}',
|
|
236
|
+
code='UNSUPPORTED_REQUEST',
|
|
237
|
+
details={
|
|
238
|
+
'task': stated,
|
|
239
|
+
'supported': sorted(TABULAR_TASK_KINDS),
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
stated if stated is not None else _derive_task_kind(query, target)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _context_within_limit(
|
|
249
|
+
candidates: pd.DataFrame,
|
|
250
|
+
num_context_samples: int | None,
|
|
251
|
+
table: str,
|
|
252
|
+
remedy: str,
|
|
253
|
+
) -> pd.DataFrame:
|
|
254
|
+
r"""The context rows, never more than the endpoint will take.
|
|
255
|
+
|
|
256
|
+
The endpoint refuses a context above its limit, and refuses it *after* the
|
|
257
|
+
whole thing has been encoded into JSON and transmitted. A table of
|
|
258
|
+
hundreds of thousands of rows sent in full to be told the limit is ten
|
|
259
|
+
thousand. The relational side has declared its limits and refused locally
|
|
260
|
+
all along; this is the same thing on the other model.
|
|
261
|
+
|
|
262
|
+
A number the caller named above the limit is an error, because they asked
|
|
263
|
+
for something that cannot happen and quietly giving them less would answer
|
|
264
|
+
a different question. Saying nothing is a choice about how much data the
|
|
265
|
+
model sees, so it is made here and said out loud.
|
|
266
|
+
"""
|
|
267
|
+
if num_context_samples is not None:
|
|
268
|
+
if num_context_samples > _CONTEXT_ROWS_MAX:
|
|
269
|
+
raise StructuredDataError(
|
|
270
|
+
f'num_context_samples is {num_context_samples:,}, and the '
|
|
271
|
+
f'endpoint accepts at most {_CONTEXT_ROWS_MAX:,} context '
|
|
272
|
+
f'rows',
|
|
273
|
+
code='UNSUPPORTED_REQUEST',
|
|
274
|
+
)
|
|
275
|
+
wanted = num_context_samples
|
|
276
|
+
else:
|
|
277
|
+
wanted = _CONTEXT_ROWS_MAX
|
|
278
|
+
if len(candidates) <= wanted:
|
|
279
|
+
return candidates
|
|
280
|
+
if num_context_samples is None:
|
|
281
|
+
warnings.warn(
|
|
282
|
+
f'{table} has {len(candidates):,} rows that could be context and '
|
|
283
|
+
f'the endpoint accepts {_CONTEXT_ROWS_MAX:,}, so {wanted:,} were '
|
|
284
|
+
f'sampled. {remedy}',
|
|
285
|
+
category=SDMWarning,
|
|
286
|
+
stacklevel=caller_stacklevel(),
|
|
287
|
+
)
|
|
288
|
+
return candidates.sample(n=wanted, random_state=_CONTEXT_SEED).sort_index()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _rows_that_carry_an_answer(
|
|
292
|
+
frame: pd.DataFrame, target: str
|
|
293
|
+
) -> pd.DataFrame:
|
|
294
|
+
r"""The rows that can teach the model something.
|
|
295
|
+
|
|
296
|
+
A row whose target is null is not an example of anything. It takes up
|
|
297
|
+
room in a context the endpoint caps, and says nothing in exchange.
|
|
298
|
+
"""
|
|
299
|
+
if target not in frame:
|
|
300
|
+
return frame
|
|
301
|
+
return frame[frame[target].notna()]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _without_the_rows_being_scored(
|
|
305
|
+
table: pd.DataFrame, predict: pd.DataFrame, entity_col: str
|
|
306
|
+
) -> pd.DataFrame:
|
|
307
|
+
r"""The table, minus any row the caller is about to ask about.
|
|
308
|
+
|
|
309
|
+
The `indices=` route does this and this one did not, so the two
|
|
310
|
+
single-table routes disagreed about whether a row may be both scored and
|
|
311
|
+
shown. Rows supplied separately are often rows the table already holds,
|
|
312
|
+
which is how anyone checks a model against answers they have. Leaving
|
|
313
|
+
them in the context hands the model those answers, labelled, and returns
|
|
314
|
+
what reads like accuracy.
|
|
315
|
+
|
|
316
|
+
Rows the table does not hold are what the route is for, and they simply
|
|
317
|
+
match nothing here.
|
|
318
|
+
"""
|
|
319
|
+
if entity_col not in table.columns or entity_col not in predict.columns:
|
|
320
|
+
return table
|
|
321
|
+
scored = set(predict[entity_col])
|
|
322
|
+
if not scored:
|
|
323
|
+
return table
|
|
324
|
+
return table[~table[entity_col].isin(scored)]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _rows(table: Table | SourceTable) -> pd.DataFrame:
|
|
328
|
+
r"""The rows of a table the tabular model reads, which it holds in memory.
|
|
329
|
+
|
|
330
|
+
`PredictiveQuery.materialize` sends a schema over tables in their source to
|
|
331
|
+
the relational model, so only a hand-built route reaches here with one.
|
|
332
|
+
"""
|
|
333
|
+
if isinstance(table, SourceTable):
|
|
334
|
+
raise StructuredDataError(
|
|
335
|
+
f'{table.name!r} stays in its source, and the tabular model reads '
|
|
336
|
+
f'rows in memory; build the schema with Schema.from_data',
|
|
337
|
+
code='INVALID_REQUEST',
|
|
338
|
+
)
|
|
339
|
+
return table.data
|