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,1786 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
r"""Gather and encode relational requests with the relational engine."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import math
|
|
11
|
+
import warnings
|
|
12
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from numbers import Integral
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from predictive_query.ast import Filter
|
|
19
|
+
from predictive_query.validator.model_validator import (
|
|
20
|
+
MAX_TOP_K as _MAX_TOP_K,
|
|
21
|
+
)
|
|
22
|
+
from predictive_query.validator.utils import table_name
|
|
23
|
+
|
|
24
|
+
from sdm_client.core import dates
|
|
25
|
+
from sdm_client.core.warnings import SDMWarning, caller_stacklevel
|
|
26
|
+
from sdm_client.errors import MissingExtraError, StructuredDataError
|
|
27
|
+
from sdm_client.materializers.sources import EngineTables
|
|
28
|
+
from sdm_client.materializers.tabular import _holds_two_whole_numbers
|
|
29
|
+
from sdm_client.models import (
|
|
30
|
+
KUMO_RELATIONAL,
|
|
31
|
+
PREDICTION_STATISTICS,
|
|
32
|
+
RELATIONAL_LIMITS,
|
|
33
|
+
SUMMARISED_TASK_KINDS,
|
|
34
|
+
)
|
|
35
|
+
from sdm_client.request import Request
|
|
36
|
+
from sdm_client.schema import (
|
|
37
|
+
Relationship,
|
|
38
|
+
Schema,
|
|
39
|
+
SourceTable,
|
|
40
|
+
Table,
|
|
41
|
+
_and_list,
|
|
42
|
+
_as_key_tuple,
|
|
43
|
+
)
|
|
44
|
+
from sdm_client.wire.dtypes import arrow_decimal_type
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from predictive_query import ValidatedPredictiveQuery
|
|
48
|
+
from sdm_relational_engine.rfm import (
|
|
49
|
+
Graph,
|
|
50
|
+
KumoRelational,
|
|
51
|
+
MaterializedPredictionRequest,
|
|
52
|
+
TaskTable,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _warn_of_a_context_the_default_mode_capped(
|
|
57
|
+
task: TaskTable, num_context_samples: int | None
|
|
58
|
+
) -> None:
|
|
59
|
+
r"""Say when the default gathered as many examples as it may, not as many
|
|
60
|
+
as there are.
|
|
61
|
+
|
|
62
|
+
How much of the data the model sees is a choice, and the tabular route
|
|
63
|
+
already says so when it makes one. Here the choice is made by the run mode
|
|
64
|
+
a missing ``num_context_samples`` selects, which is quieter but no less a
|
|
65
|
+
choice.
|
|
66
|
+
"""
|
|
67
|
+
if num_context_samples is not None:
|
|
68
|
+
return
|
|
69
|
+
cap = RELATIONAL_LIMITS['context_rows_fast']
|
|
70
|
+
if getattr(task, 'num_context_examples', 0) < cap:
|
|
71
|
+
return
|
|
72
|
+
warnings.warn(
|
|
73
|
+
f'{cap:,} context examples were gathered, which is all this route '
|
|
74
|
+
f'takes by default; pass num_context_samples to ask for up to '
|
|
75
|
+
f'{RELATIONAL_LIMITS["context_rows_max"]:,}.',
|
|
76
|
+
category=SDMWarning,
|
|
77
|
+
stacklevel=caller_stacklevel(),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
#: More entities than this in one `materialize()` and the request is gathered
|
|
82
|
+
#: in pieces that no longer fit in memory together. Every batch is built
|
|
83
|
+
#: before any is sent, each carrying its own context.
|
|
84
|
+
MOST_ENTITIES = 100_000
|
|
85
|
+
|
|
86
|
+
#: And more calls than this, which is the other way to hold too much: a
|
|
87
|
+
#: context is gathered for every call, so a `rows_per_call` of 1 would hold
|
|
88
|
+
#: one per entity. Loose enough that lowering the batch size for a wide graph
|
|
89
|
+
#: is unaffected, and only a caller who set it very low meets it.
|
|
90
|
+
MOST_CALLS = 100
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _rows_per_call(
|
|
94
|
+
kind: object, limits: Mapping[str, int] = RELATIONAL_LIMITS
|
|
95
|
+
) -> int:
|
|
96
|
+
r"""How many entities the endpoint scores in one call of a task ``kind``."""
|
|
97
|
+
if str(kind) == 'temporal_link_prediction':
|
|
98
|
+
return limits['predict_rows_temporal_link_prediction']
|
|
99
|
+
return limits['predict_rows_max']
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def rows_one_call_scores(
|
|
103
|
+
asked: Any = None,
|
|
104
|
+
kind: object = None,
|
|
105
|
+
limits: Mapping[str, int] = RELATIONAL_LIMITS,
|
|
106
|
+
) -> int:
|
|
107
|
+
r"""How many entities one call will score, as the caller asked or as the
|
|
108
|
+
endpoint allows.
|
|
109
|
+
|
|
110
|
+
``rows_per_call`` only ever goes down. The endpoint refuses a call past
|
|
111
|
+
its own cap, so a larger number would buy a 422 rather than a bigger call;
|
|
112
|
+
a smaller one is how a graph too wide for a full batch, or a deployment
|
|
113
|
+
holding to less than this client knows of, is asked in pieces that fit.
|
|
114
|
+
"""
|
|
115
|
+
cap = _rows_per_call(kind, limits)
|
|
116
|
+
if asked is None:
|
|
117
|
+
return cap
|
|
118
|
+
return min(int(asked), cap)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def rows_per_call_asked_for(options: Mapping[str, Any]) -> int | None:
|
|
122
|
+
r"""The batch size the caller chose, refused if it is not a size.
|
|
123
|
+
|
|
124
|
+
``None`` is not choosing one. Every gathering option travels as a keyword
|
|
125
|
+
and a caller passing one through reads an unset value as absent, so a
|
|
126
|
+
question asked in parts, which drops them before passing them on, and one
|
|
127
|
+
asked whole have to agree about that.
|
|
128
|
+
"""
|
|
129
|
+
given = options.get('rows_per_call')
|
|
130
|
+
if given is None:
|
|
131
|
+
return None
|
|
132
|
+
if (
|
|
133
|
+
not isinstance(given, Integral)
|
|
134
|
+
or isinstance(given, bool)
|
|
135
|
+
or int(given) < 1
|
|
136
|
+
):
|
|
137
|
+
raise StructuredDataError(
|
|
138
|
+
f'rows_per_call is {given!r}, and a call scores a whole number of '
|
|
139
|
+
f'rows, at least one',
|
|
140
|
+
code='INVALID_REQUEST',
|
|
141
|
+
)
|
|
142
|
+
return int(given)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _the_largest_batch(task: TaskTable, per_call: int) -> int:
|
|
146
|
+
r"""How many rows the largest batch of this request actually carries.
|
|
147
|
+
|
|
148
|
+
Not the size asked for: a request for one entity is one call of one row
|
|
149
|
+
whatever the batch size allows, and telling its caller to ask for smaller
|
|
150
|
+
calls names a remedy that has nothing left to divide.
|
|
151
|
+
"""
|
|
152
|
+
asked = getattr(task, 'num_prediction_examples', None)
|
|
153
|
+
if not isinstance(asked, Integral) or int(asked) < 1:
|
|
154
|
+
return per_call
|
|
155
|
+
return min(per_call, int(asked))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def refuse_more_entities_than_fit_in_memory(asked: int, held: int = 1) -> None:
|
|
159
|
+
r"""Refuse a population too large to hold, from the count alone.
|
|
160
|
+
|
|
161
|
+
Checked before the engine is reached, because reaching it means building
|
|
162
|
+
the graph and computing a label for every index, which for a population
|
|
163
|
+
far past this ceiling is minutes of work and the memory that goes with it
|
|
164
|
+
before anything says no.
|
|
165
|
+
|
|
166
|
+
``held`` is how many times over the population is gathered: one for an
|
|
167
|
+
ordinary question, and one per part for a question asked in parts, each
|
|
168
|
+
of which gathers the whole population with a context of its own.
|
|
169
|
+
"""
|
|
170
|
+
total = asked * held
|
|
171
|
+
if total <= MOST_ENTITIES:
|
|
172
|
+
return
|
|
173
|
+
gathered = (
|
|
174
|
+
f'{asked:,} entities'
|
|
175
|
+
if held == 1
|
|
176
|
+
else f'{asked:,} entities gathered {held:,} times over, {total:,} in all,'
|
|
177
|
+
)
|
|
178
|
+
raise StructuredDataError(
|
|
179
|
+
f'{gathered} is past the {MOST_ENTITIES:,} one materialize() will '
|
|
180
|
+
f'hold at once, since every batch is built, with its own context, '
|
|
181
|
+
f'before any is sent; ask about fewer entities at a time',
|
|
182
|
+
code='UNSUPPORTED_REQUEST',
|
|
183
|
+
details={'entities': asked, 'gathered_times': held, 'total': total},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _refuse_more_entities_than_fit_in_memory(
|
|
188
|
+
task: TaskTable, per_call: int
|
|
189
|
+
) -> None:
|
|
190
|
+
r"""Refuse a population too large to hold as gathered batches.
|
|
191
|
+
|
|
192
|
+
The engine builds every batch before the first is sent, so the cost of a
|
|
193
|
+
population is paid in memory up front. This is the ceiling that the old
|
|
194
|
+
per-call entity check used to supply as a side effect of refusing to batch
|
|
195
|
+
at all.
|
|
196
|
+
"""
|
|
197
|
+
asked = getattr(task, 'num_prediction_examples', None)
|
|
198
|
+
if not isinstance(asked, Integral) or per_call <= 0:
|
|
199
|
+
return
|
|
200
|
+
asked = int(asked)
|
|
201
|
+
calls = math.ceil(asked / per_call)
|
|
202
|
+
met = [
|
|
203
|
+
said
|
|
204
|
+
for said, over in (
|
|
205
|
+
(f'{MOST_ENTITIES:,} entities', asked > MOST_ENTITIES),
|
|
206
|
+
(f'{MOST_CALLS:,} calls', calls > MOST_CALLS),
|
|
207
|
+
)
|
|
208
|
+
if over
|
|
209
|
+
]
|
|
210
|
+
if not met:
|
|
211
|
+
return
|
|
212
|
+
raise StructuredDataError(
|
|
213
|
+
f'{asked:,} entities are gathered as {calls:,} calls of '
|
|
214
|
+
f'{per_call:,}, and every one is built, with its own context, before '
|
|
215
|
+
f'any is sent. That is past the {" and ".join(met)} this will hold at '
|
|
216
|
+
f'once; ask about fewer entities at a time',
|
|
217
|
+
code='UNSUPPORTED_REQUEST',
|
|
218
|
+
details={
|
|
219
|
+
'entities': asked,
|
|
220
|
+
'calls': calls,
|
|
221
|
+
'rows_per_call': per_call,
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class RelationalMaterializer:
|
|
227
|
+
r"""Materialize resolved PQL against a relational schema."""
|
|
228
|
+
|
|
229
|
+
_MATERIALIZE_OPTIONS = frozenset(
|
|
230
|
+
{
|
|
231
|
+
'anchor_time',
|
|
232
|
+
'context_anchor_time',
|
|
233
|
+
'lag_timesteps',
|
|
234
|
+
'max_pq_iterations',
|
|
235
|
+
'num_hops',
|
|
236
|
+
'num_neighbors',
|
|
237
|
+
'random_seed',
|
|
238
|
+
'use_prediction_time',
|
|
239
|
+
'inference_config',
|
|
240
|
+
'exclude_cols_dict',
|
|
241
|
+
'return_embeddings',
|
|
242
|
+
'top_k',
|
|
243
|
+
'prediction_statistic',
|
|
244
|
+
'rows_per_call',
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def __init__(self, schema: Schema) -> None:
|
|
249
|
+
self.schema = schema
|
|
250
|
+
self._model: KumoRelational | None = None
|
|
251
|
+
|
|
252
|
+
_RESERVED = ('instance_id', '__kumo_anchor_time')
|
|
253
|
+
|
|
254
|
+
def materialize(
|
|
255
|
+
self,
|
|
256
|
+
*,
|
|
257
|
+
query: ValidatedPredictiveQuery,
|
|
258
|
+
indices: Sequence[Any],
|
|
259
|
+
num_context_samples: int | None,
|
|
260
|
+
options: dict[str, Any],
|
|
261
|
+
) -> Request:
|
|
262
|
+
r"""Gather the selected entities and return their encoded request."""
|
|
263
|
+
unknown = sorted(set(options) - self._MATERIALIZE_OPTIONS)
|
|
264
|
+
if unknown:
|
|
265
|
+
raise StructuredDataError(
|
|
266
|
+
f'unknown materialize option(s) {unknown}; accepted: '
|
|
267
|
+
f'{sorted(self._MATERIALIZE_OPTIONS)}',
|
|
268
|
+
code='INVALID_REQUEST',
|
|
269
|
+
)
|
|
270
|
+
if 'run_mode' in options: # pragma: no cover - caught by the above
|
|
271
|
+
raise StructuredDataError(
|
|
272
|
+
'run_mode is not a knob here; set num_context_samples and '
|
|
273
|
+
'the client derives what the endpoint needs',
|
|
274
|
+
code='INVALID_REQUEST',
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if len(indices) == 0:
|
|
278
|
+
raise StructuredDataError(
|
|
279
|
+
'indices names no entity, and gathering a relational request '
|
|
280
|
+
'means sampling a neighbourhood around each one, so there is '
|
|
281
|
+
'nothing here to gather. An empty batch is still answered: '
|
|
282
|
+
'build it as Request(predict=<no rows>) and predict() returns '
|
|
283
|
+
'the empty result without sending',
|
|
284
|
+
code='INVALID_REQUEST',
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
statistic = _statistic_asked_for(options)
|
|
288
|
+
rows_per_call_asked_for(options)
|
|
289
|
+
self._refuse_a_name_the_wire_reserves()
|
|
290
|
+
self._warn_two_whole_numbers_are_being_asked_as_quantities(query)
|
|
291
|
+
_warn_the_filter_chose_only_the_examples(query, indices)
|
|
292
|
+
_warn_of_relationships_that_join_nothing(self.schema)
|
|
293
|
+
payloads = self._gather(query, indices, num_context_samples, options)
|
|
294
|
+
if statistic is not None:
|
|
295
|
+
_record_the_statistic(payloads, statistic)
|
|
296
|
+
sent = _tables_sent(payloads, self.schema)
|
|
297
|
+
_warn_of_tables_left_out(
|
|
298
|
+
self.schema, sent, table_name(query.entity_column)
|
|
299
|
+
)
|
|
300
|
+
_warn_of_columns_the_model_does_not_read(
|
|
301
|
+
self.schema, _columns_to_leave_out(query, options), sent
|
|
302
|
+
)
|
|
303
|
+
windowed = (
|
|
304
|
+
getattr(query.target_ast, 'date_offset_range', None) is not None
|
|
305
|
+
)
|
|
306
|
+
_drop_context_that_answers_the_question(payloads, windowed=windowed)
|
|
307
|
+
return Request.materialized(
|
|
308
|
+
payloads=payloads,
|
|
309
|
+
windowed=windowed,
|
|
310
|
+
schema=self.schema.snapshot(),
|
|
311
|
+
query=query.to_string(),
|
|
312
|
+
indices=list(indices),
|
|
313
|
+
entity_table=table_name(query.entity_column),
|
|
314
|
+
# A copy: the payloads were gathered from the config as it is
|
|
315
|
+
# now, and a caller's later edit to it must not change what the
|
|
316
|
+
# adapter believes was asked for.
|
|
317
|
+
requested_inference={
|
|
318
|
+
'inference_config': copy.deepcopy(
|
|
319
|
+
options.get('inference_config') or {}
|
|
320
|
+
)
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _gather(
|
|
325
|
+
self,
|
|
326
|
+
query: ValidatedPredictiveQuery,
|
|
327
|
+
indices: Sequence[Any],
|
|
328
|
+
num_context_samples: int | None,
|
|
329
|
+
options: dict[str, Any],
|
|
330
|
+
) -> tuple[dict[str, Any], ...]:
|
|
331
|
+
r"""Sample and encode using the engine's own materializer.
|
|
332
|
+
|
|
333
|
+
More entities than one call scores are gathered as several payloads
|
|
334
|
+
of at most that many, which `predict()` sends one at a time and
|
|
335
|
+
answers as one result. One call scores what the endpoint takes, or
|
|
336
|
+
the smaller ``rows_per_call`` the caller asked for.
|
|
337
|
+
"""
|
|
338
|
+
run_mode = self._run_mode_for(num_context_samples)
|
|
339
|
+
refuse_more_entities_than_fit_in_memory(len(indices))
|
|
340
|
+
self._check_shape(options, RELATIONAL_LIMITS)
|
|
341
|
+
with self._engine_errors():
|
|
342
|
+
model = self._get_model()
|
|
343
|
+
_warn_rows_the_graph_dropped(model)
|
|
344
|
+
|
|
345
|
+
with self._engine_errors(model):
|
|
346
|
+
task = model._get_task_table(
|
|
347
|
+
query=query,
|
|
348
|
+
indices=list(indices),
|
|
349
|
+
run_mode=run_mode,
|
|
350
|
+
**{
|
|
351
|
+
key: options[key]
|
|
352
|
+
for key in (
|
|
353
|
+
'anchor_time',
|
|
354
|
+
'context_anchor_time',
|
|
355
|
+
'lag_timesteps',
|
|
356
|
+
'max_pq_iterations',
|
|
357
|
+
'random_seed',
|
|
358
|
+
)
|
|
359
|
+
if key in options
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
task._query = query.to_string()
|
|
363
|
+
if not hasattr(task, 'task_type'):
|
|
364
|
+
raise StructuredDataError(
|
|
365
|
+
'the resolved task does not say what kind it is, so the '
|
|
366
|
+
'entities it scores per call cannot be known',
|
|
367
|
+
code='INTERNAL_ERROR',
|
|
368
|
+
)
|
|
369
|
+
_warn_of_a_context_the_default_mode_capped(task, num_context_samples)
|
|
370
|
+
task = self._narrow_context(task, num_context_samples)
|
|
371
|
+
|
|
372
|
+
per_call = rows_one_call_scores(
|
|
373
|
+
rows_per_call_asked_for(options), task.task_type
|
|
374
|
+
)
|
|
375
|
+
_refuse_more_entities_than_fit_in_memory(task, per_call)
|
|
376
|
+
largest = _the_largest_batch(task, per_call)
|
|
377
|
+
with (
|
|
378
|
+
self._engine_errors(model, largest),
|
|
379
|
+
model.batch_mode(batch_size=per_call),
|
|
380
|
+
):
|
|
381
|
+
parts = model.materialize_task(
|
|
382
|
+
task,
|
|
383
|
+
run_mode=run_mode,
|
|
384
|
+
verbose=False,
|
|
385
|
+
top_k=_candidates_to_rank(query, options),
|
|
386
|
+
exclude_cols_dict=_columns_to_leave_out(query, options),
|
|
387
|
+
**{
|
|
388
|
+
key: options[key]
|
|
389
|
+
for key in (
|
|
390
|
+
'num_neighbors',
|
|
391
|
+
'num_hops',
|
|
392
|
+
'inference_config',
|
|
393
|
+
'use_prediction_time',
|
|
394
|
+
'return_embeddings',
|
|
395
|
+
'random_seed',
|
|
396
|
+
)
|
|
397
|
+
if key in options
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
if not parts:
|
|
401
|
+
raise StructuredDataError(
|
|
402
|
+
'the engine gathered nothing for this query',
|
|
403
|
+
code='INVALID_REQUEST',
|
|
404
|
+
)
|
|
405
|
+
for part in parts:
|
|
406
|
+
self._check_limits(part, RELATIONAL_LIMITS)
|
|
407
|
+
return tuple(part.payload for part in parts)
|
|
408
|
+
|
|
409
|
+
def _get_model(self) -> KumoRelational:
|
|
410
|
+
if self._model is None:
|
|
411
|
+
try:
|
|
412
|
+
from sdm_relational_engine.rfm import KumoRelational
|
|
413
|
+
except ImportError as error: # pragma: no cover - optional extra
|
|
414
|
+
raise MissingExtraError(
|
|
415
|
+
'relational', 'structured-data-models-relational-engine'
|
|
416
|
+
) from error
|
|
417
|
+
|
|
418
|
+
self._model = KumoRelational(self._build_graph(), verbose=False)
|
|
419
|
+
return self._model
|
|
420
|
+
|
|
421
|
+
def _refuse_a_name_the_wire_reserves(self) -> None:
|
|
422
|
+
"""Refuse a column whose name the request itself needs.
|
|
423
|
+
|
|
424
|
+
The wire adds its own columns to every table it sends, and a caller
|
|
425
|
+
column of the same name collided with them inside pandas, which said
|
|
426
|
+
`cannot insert instance_id, already exists` and named no table, no
|
|
427
|
+
remedy, and nothing about the name being spoken for.
|
|
428
|
+
|
|
429
|
+
The suggestion is made per column, and by suffixing rather than by
|
|
430
|
+
substituting a word only one of the two names contains. That left
|
|
431
|
+
`__kumo_anchor_time` told to rename itself to `__kumo_anchor_time`.
|
|
432
|
+
"""
|
|
433
|
+
taken = sorted(
|
|
434
|
+
(name, column)
|
|
435
|
+
for name, table in self.schema.tables.items()
|
|
436
|
+
for column in table.columns
|
|
437
|
+
if column in self._RESERVED
|
|
438
|
+
)
|
|
439
|
+
if not taken:
|
|
440
|
+
return
|
|
441
|
+
named = _and_list([f'{name}.{column}' for name, column in taken])
|
|
442
|
+
if len(taken) == 1:
|
|
443
|
+
column = taken[0][1]
|
|
444
|
+
renamed = f'to {self._unreserved(column)!r}'
|
|
445
|
+
else:
|
|
446
|
+
renamed = ', '.join(
|
|
447
|
+
f'{column!r} to {self._unreserved(column)!r}'
|
|
448
|
+
for _, column in taken
|
|
449
|
+
)
|
|
450
|
+
many = len(taken) > 1
|
|
451
|
+
raise StructuredDataError(
|
|
452
|
+
f'{named} {"use names" if many else "uses a name"} this client '
|
|
453
|
+
f'needs for the request it builds, so '
|
|
454
|
+
f'{"they cannot also be columns" if many else "it cannot also be a column"} '
|
|
455
|
+
f'of the data. Rename {"them" if many else "it"}, for instance '
|
|
456
|
+
f'{renamed}.',
|
|
457
|
+
code='INVALID_SCHEMA',
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def _unreserved(self, column: str) -> str:
|
|
461
|
+
"""A name like ``column`` that this client does not need for itself."""
|
|
462
|
+
suggestion = f'{column.lstrip("_")}_from_the_data'
|
|
463
|
+
return suggestion if suggestion not in self._RESERVED else column + '_'
|
|
464
|
+
|
|
465
|
+
def _warn_two_whole_numbers_are_being_asked_as_quantities(
|
|
466
|
+
self, query: ValidatedPredictiveQuery
|
|
467
|
+
) -> None:
|
|
468
|
+
"""Say when a 0/1 column is being asked as a regression.
|
|
469
|
+
|
|
470
|
+
The other route recognises two whole numbers as a label and says so.
|
|
471
|
+
Here the task follows the column's stype, so the same question over
|
|
472
|
+
the same column is asked as a regression and nothing is said. Also,
|
|
473
|
+
`task=` is refused on this route, so the warning's own remedy does not
|
|
474
|
+
apply either.
|
|
475
|
+
"""
|
|
476
|
+
named = str(getattr(query, 'target_column', '') or '')
|
|
477
|
+
table, _, column = named.partition('.')
|
|
478
|
+
if table not in self.schema or column not in self.schema[table]:
|
|
479
|
+
return
|
|
480
|
+
if self.schema[table][column].stype != 'numerical':
|
|
481
|
+
return
|
|
482
|
+
# A table in its source has no rows here to look at; the check reads
|
|
483
|
+
# the whole column, which against a warehouse is a scan per request.
|
|
484
|
+
frame = getattr(self.schema[table], 'data', None)
|
|
485
|
+
if frame is None or not _holds_two_whole_numbers(frame[column]):
|
|
486
|
+
return
|
|
487
|
+
warnings.warn(
|
|
488
|
+
f'{named} holds two whole numbers, which is how a binary label is '
|
|
489
|
+
f'usually written, and this is being asked as a regression '
|
|
490
|
+
f'because that is what the column says it holds. If they are '
|
|
491
|
+
f'labels, say so on the column: '
|
|
492
|
+
f"schema[{table!r}][{column!r}].stype = 'categorical'.",
|
|
493
|
+
category=SDMWarning,
|
|
494
|
+
stacklevel=caller_stacklevel(),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def _build_graph(self) -> Graph:
|
|
498
|
+
r"""Build exactly the graph described by the client schema.
|
|
499
|
+
|
|
500
|
+
The engine is not asked to guess. Its inference reads column *names*
|
|
501
|
+
without checking what the column holds, so a child's foreign key called
|
|
502
|
+
`ID` holding `[1, 1, 2, 2, 3]` became that table's key and linking it
|
|
503
|
+
was then refused for being one, and a string column called `release`
|
|
504
|
+
became a time column whose values it could not parse. Both are guesses
|
|
505
|
+
about tables the schema already describes, and the second could fail
|
|
506
|
+
before the caller had any chance to undo it.
|
|
507
|
+
|
|
508
|
+
Every key, time column and semantic type is therefore written here from
|
|
509
|
+
the schema, which is the only thing that decides them. A table the
|
|
510
|
+
caller gave no key or no time column is given none.
|
|
511
|
+
"""
|
|
512
|
+
from sdm_relational_engine.rfm import Graph
|
|
513
|
+
|
|
514
|
+
self.schema.check_consistent()
|
|
515
|
+
|
|
516
|
+
if self.schema.source is not None:
|
|
517
|
+
# Read in place: the engine picks the SQL sampler for this source,
|
|
518
|
+
# and only the columns the schema describes are read, so a column
|
|
519
|
+
# it left out as unsendable is never sampled either.
|
|
520
|
+
if self.schema._engine_tables is None:
|
|
521
|
+
self.schema._engine_tables = EngineTables()
|
|
522
|
+
graph = self.schema._engine_tables.graph(
|
|
523
|
+
self.schema.source,
|
|
524
|
+
{
|
|
525
|
+
name: (table.source_name, list(table.columns))
|
|
526
|
+
for name, table in self.schema.tables.items()
|
|
527
|
+
if isinstance(table, SourceTable)
|
|
528
|
+
},
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
frames = {
|
|
532
|
+
name: _as_typed(table)
|
|
533
|
+
for name, table in self.schema.tables.items()
|
|
534
|
+
}
|
|
535
|
+
graph = Graph.from_data(
|
|
536
|
+
frames, edges=[], infer_metadata=False, verbose=False
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
for name, table in self.schema.tables.items():
|
|
540
|
+
engine_table = graph[name]
|
|
541
|
+
if table.primary_key is not None:
|
|
542
|
+
engine_table.primary_key = table.primary_key
|
|
543
|
+
else:
|
|
544
|
+
engine_table.primary_key = None
|
|
545
|
+
if table.time_column is not None:
|
|
546
|
+
engine_table.time_column = table.time_column
|
|
547
|
+
else:
|
|
548
|
+
engine_table.time_column = None
|
|
549
|
+
for column_name, column in table.columns.items():
|
|
550
|
+
if column_name in engine_table:
|
|
551
|
+
engine_table[column_name].stype = column.stype
|
|
552
|
+
|
|
553
|
+
linked: set[tuple[str, str, str]] = set()
|
|
554
|
+
for link in self.schema.relationships:
|
|
555
|
+
edge = (link.source_table, link.source_column, link.target_table)
|
|
556
|
+
if edge in linked:
|
|
557
|
+
continue
|
|
558
|
+
linked.add(edge)
|
|
559
|
+
graph.link(
|
|
560
|
+
src_table=link.source_table,
|
|
561
|
+
fkey=link.source_column,
|
|
562
|
+
dst_table=link.target_table,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
sent = {
|
|
566
|
+
(edge.src_table, edge.fkey, edge.dst_table) for edge in graph.edges
|
|
567
|
+
}
|
|
568
|
+
described = {
|
|
569
|
+
(link.source_table, link.source_column, link.target_table)
|
|
570
|
+
for link in self.schema.relationships
|
|
571
|
+
}
|
|
572
|
+
misdirected = [
|
|
573
|
+
link
|
|
574
|
+
for link in self.schema.relationships
|
|
575
|
+
if link.target_column
|
|
576
|
+
not in _as_key_tuple(self.schema[link.target_table].primary_key)
|
|
577
|
+
]
|
|
578
|
+
if misdirected:
|
|
579
|
+
shown = ', '.join(
|
|
580
|
+
f'{link.source_table}.{link.source_column} -> '
|
|
581
|
+
f'{link.target_table}.{link.target_column}'
|
|
582
|
+
for link in misdirected
|
|
583
|
+
)
|
|
584
|
+
raise StructuredDataError(
|
|
585
|
+
f'{shown} name a target column that is not the primary key '
|
|
586
|
+
f'of the table they point at, and an edge can only join to that',
|
|
587
|
+
code='INVALID_REQUEST',
|
|
588
|
+
)
|
|
589
|
+
if sent != described: # pragma: no cover - defensive assertion
|
|
590
|
+
raise StructuredDataError(
|
|
591
|
+
f'the graph built for this request does not match the schema: '
|
|
592
|
+
f'{sorted(sent ^ described)}',
|
|
593
|
+
code='INTERNAL_ERROR',
|
|
594
|
+
)
|
|
595
|
+
return graph
|
|
596
|
+
|
|
597
|
+
@contextmanager
|
|
598
|
+
def _engine_errors(
|
|
599
|
+
self,
|
|
600
|
+
model: KumoRelational | None = None,
|
|
601
|
+
per_call: int | None = None,
|
|
602
|
+
) -> Iterator[None]:
|
|
603
|
+
r"""Re-raise engine failures through this client's error surface.
|
|
604
|
+
|
|
605
|
+
``model`` is optional because the phase that most needs this is the one
|
|
606
|
+
that builds it. `Graph.from_data`, the key and stype assignments, the
|
|
607
|
+
links and `graph.validate()` all raise bare `ValueError` out of a
|
|
608
|
+
package the caller never imported, and all of them used to run one line
|
|
609
|
+
above this guard. A caller who wrote the `except
|
|
610
|
+
StructuredDataError` the reference tells them to write did not catch
|
|
611
|
+
the whole construction phase.
|
|
612
|
+
|
|
613
|
+
What the graph dropped is appended to a complaint about the data and
|
|
614
|
+
not to one about an argument. A `TypeError` is raised over the type of
|
|
615
|
+
a value the caller passed, and `anchor_time='2020-10-01'` came back
|
|
616
|
+
with a postscript counting rows with no time, which reads as a second
|
|
617
|
+
fault and sends the caller to look at rows that were never the
|
|
618
|
+
problem.
|
|
619
|
+
"""
|
|
620
|
+
try:
|
|
621
|
+
from sdm_relational_engine import KumoRelationalError
|
|
622
|
+
except ImportError: # pragma: no cover - the extra is optional
|
|
623
|
+
caller_errors = (ValueError, KeyError, TypeError)
|
|
624
|
+
else:
|
|
625
|
+
caller_errors = (
|
|
626
|
+
ValueError,
|
|
627
|
+
KeyError,
|
|
628
|
+
TypeError,
|
|
629
|
+
KumoRelationalError,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
yield
|
|
634
|
+
except StructuredDataError:
|
|
635
|
+
raise
|
|
636
|
+
except Exception as error:
|
|
637
|
+
if not isinstance(error, caller_errors):
|
|
638
|
+
raise StructuredDataError(
|
|
639
|
+
f'the relational engine failed while building this '
|
|
640
|
+
f'request: {type(error).__name__}: {error}',
|
|
641
|
+
code='INTERNAL_ERROR',
|
|
642
|
+
) from error
|
|
643
|
+
if isinstance(error, TypeError) and self._is_call_signature_error(
|
|
644
|
+
error
|
|
645
|
+
):
|
|
646
|
+
raise StructuredDataError(
|
|
647
|
+
f'the relational engine was called incorrectly by this '
|
|
648
|
+
f'client: {type(error).__name__}: {error}',
|
|
649
|
+
code='INTERNAL_ERROR',
|
|
650
|
+
) from error
|
|
651
|
+
detail = (
|
|
652
|
+
''
|
|
653
|
+
if isinstance(error, TypeError)
|
|
654
|
+
else self._sanitization_detail(model)
|
|
655
|
+
)
|
|
656
|
+
refused = _what_the_engine_refused(str(error))
|
|
657
|
+
said = str(error)
|
|
658
|
+
if refused.get('batch_too_large_to_send'):
|
|
659
|
+
said = _said_in_this_client_s_terms(said, per_call)
|
|
660
|
+
raise StructuredDataError(
|
|
661
|
+
f'{said}{detail}',
|
|
662
|
+
code='INVALID_REQUEST',
|
|
663
|
+
details=refused,
|
|
664
|
+
) from error
|
|
665
|
+
|
|
666
|
+
@staticmethod
|
|
667
|
+
def _is_call_signature_error(error: TypeError) -> bool:
|
|
668
|
+
r"""Whether a ``TypeError`` is a bad call rather than a bad value."""
|
|
669
|
+
traceback = error.__traceback__
|
|
670
|
+
if traceback is not None:
|
|
671
|
+
deepest = traceback
|
|
672
|
+
while deepest.tb_next is not None:
|
|
673
|
+
deepest = deepest.tb_next
|
|
674
|
+
if deepest.tb_frame.f_globals.get('__name__') == __name__:
|
|
675
|
+
return True
|
|
676
|
+
message = str(error)
|
|
677
|
+
return any(
|
|
678
|
+
phrase in message
|
|
679
|
+
for phrase in (
|
|
680
|
+
'unexpected keyword argument',
|
|
681
|
+
'positional argument',
|
|
682
|
+
'required argument',
|
|
683
|
+
'takes no arguments',
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
@staticmethod
|
|
688
|
+
def _sanitization_detail(model: KumoRelational) -> str:
|
|
689
|
+
r"""Describe rows the engine dropped while constructing its graph."""
|
|
690
|
+
report = getattr(model, 'sanitization_report', None)
|
|
691
|
+
tables = getattr(report, 'tables', None)
|
|
692
|
+
if not tables:
|
|
693
|
+
return ''
|
|
694
|
+
reasons = []
|
|
695
|
+
for name, table in tables.items():
|
|
696
|
+
dropped = getattr(table, 'input_rows', 0) - getattr(
|
|
697
|
+
table, 'output_rows', 0
|
|
698
|
+
)
|
|
699
|
+
if dropped <= 0:
|
|
700
|
+
continue
|
|
701
|
+
causes = [
|
|
702
|
+
f'{count} with {label}'
|
|
703
|
+
for label, count in (
|
|
704
|
+
(
|
|
705
|
+
'no primary key',
|
|
706
|
+
getattr(table, 'null_primary_key_rows', 0),
|
|
707
|
+
),
|
|
708
|
+
(
|
|
709
|
+
'a repeated primary key',
|
|
710
|
+
getattr(table, 'duplicate_primary_key_rows', 0),
|
|
711
|
+
),
|
|
712
|
+
('no time', getattr(table, 'null_time_rows', 0)),
|
|
713
|
+
)
|
|
714
|
+
if count
|
|
715
|
+
]
|
|
716
|
+
reasons.append(
|
|
717
|
+
f'{name}: {dropped} of {table.input_rows} rows dropped'
|
|
718
|
+
+ (f' ({", ".join(causes)})' if causes else '')
|
|
719
|
+
)
|
|
720
|
+
return f' [{"; ".join(reasons)}]' if reasons else ''
|
|
721
|
+
|
|
722
|
+
@staticmethod
|
|
723
|
+
def _narrow_context(
|
|
724
|
+
task: TaskTable, num_context_samples: int | None
|
|
725
|
+
) -> TaskTable:
|
|
726
|
+
r"""Narrow gathered context to the requested number of examples."""
|
|
727
|
+
if num_context_samples is None:
|
|
728
|
+
return task
|
|
729
|
+
gathered = task.num_context_examples
|
|
730
|
+
if gathered <= num_context_samples:
|
|
731
|
+
return task
|
|
732
|
+
return task.narrow_context(0, num_context_samples)
|
|
733
|
+
|
|
734
|
+
@staticmethod
|
|
735
|
+
def _run_mode_for(num_context_samples: int | None) -> str:
|
|
736
|
+
r"""Return the endpoint run mode admitting the requested context."""
|
|
737
|
+
if num_context_samples is None:
|
|
738
|
+
return 'fast'
|
|
739
|
+
if num_context_samples <= RELATIONAL_LIMITS['context_rows_fast']:
|
|
740
|
+
return 'fast'
|
|
741
|
+
if num_context_samples <= RELATIONAL_LIMITS['context_rows_max']:
|
|
742
|
+
return 'best'
|
|
743
|
+
raise StructuredDataError(
|
|
744
|
+
f'num_context_samples is {num_context_samples:,}, and the endpoint '
|
|
745
|
+
f'accepts at most {RELATIONAL_LIMITS["context_rows_max"]:,} context '
|
|
746
|
+
f'rows',
|
|
747
|
+
code='UNSUPPORTED_REQUEST',
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
@staticmethod
|
|
751
|
+
def _check_limits(
|
|
752
|
+
part: MaterializedPredictionRequest, limits: Mapping[str, int]
|
|
753
|
+
) -> None:
|
|
754
|
+
r"""Refuse a gathered part the endpoint would reject before sending."""
|
|
755
|
+
payload = getattr(part, 'payload', None)
|
|
756
|
+
if not isinstance(payload, dict):
|
|
757
|
+
return
|
|
758
|
+
predict = payload.get('predict', {}).get('instance_table', {})
|
|
759
|
+
rows = predict.get('rows')
|
|
760
|
+
if rows is None:
|
|
761
|
+
return
|
|
762
|
+
kind = payload.get('task', {}).get('kind')
|
|
763
|
+
cap = _rows_per_call(kind, limits)
|
|
764
|
+
if len(rows) > cap:
|
|
765
|
+
raise StructuredDataError(
|
|
766
|
+
f'this batch scores {len(rows)} rows and the endpoint accepts at '
|
|
767
|
+
f'most {cap} for a {kind} task',
|
|
768
|
+
code='UNSUPPORTED_REQUEST',
|
|
769
|
+
)
|
|
770
|
+
carried = payload.get('schema', {}).get('related_tables')
|
|
771
|
+
if isinstance(carried, dict) and len(carried) > limits['tables_max']:
|
|
772
|
+
raise StructuredDataError(
|
|
773
|
+
f'this request carries {len(carried)} tables and the endpoint '
|
|
774
|
+
f'reads at most {limits["tables_max"]}; sample fewer hops, or '
|
|
775
|
+
f'keep fewer relationships in the schema',
|
|
776
|
+
code='UNSUPPORTED_REQUEST',
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
def _check_shape(
|
|
780
|
+
self, options: Mapping[str, Any], limits: Mapping[str, int]
|
|
781
|
+
) -> None:
|
|
782
|
+
r"""Refuse sampling options the endpoint will not read.
|
|
783
|
+
|
|
784
|
+
How wide the graph is, is not among them. The cap is on the sampled
|
|
785
|
+
subgraph and the payload carries nothing else, so the declared table
|
|
786
|
+
count is the wrong quantity: a warehouse schema of 21 tables whose
|
|
787
|
+
query reaches 6 of them was refused here while the endpoint answered
|
|
788
|
+
it. Reachability from the entity table is the wrong quantity too, and
|
|
789
|
+
for the same reason: it is an upper bound on what the sampler
|
|
790
|
+
gathers, and the 21-table graph above reaches all 21. Only the
|
|
791
|
+
gathered subgraph knows, which is why the check is in
|
|
792
|
+
`_check_limits`, against the payload that was actually built.
|
|
793
|
+
"""
|
|
794
|
+
neighbors = options.get('num_neighbors')
|
|
795
|
+
if neighbors is not None and (
|
|
796
|
+
not isinstance(neighbors, Sequence)
|
|
797
|
+
or isinstance(neighbors, (str, bytes))
|
|
798
|
+
):
|
|
799
|
+
raise StructuredDataError(
|
|
800
|
+
'num_neighbors must be a sequence of hop sizes',
|
|
801
|
+
code='INVALID_REQUEST',
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
num_hops = options.get('num_hops')
|
|
805
|
+
if num_hops is not None and (
|
|
806
|
+
isinstance(num_hops, bool) or not isinstance(num_hops, Integral)
|
|
807
|
+
):
|
|
808
|
+
raise StructuredDataError(
|
|
809
|
+
f'num_hops must be a whole number, got '
|
|
810
|
+
f'{type(num_hops).__name__} {num_hops!r}',
|
|
811
|
+
code='INVALID_REQUEST',
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
seed = options.get('random_seed')
|
|
815
|
+
if seed is not None and (
|
|
816
|
+
isinstance(seed, bool) or not isinstance(seed, Integral)
|
|
817
|
+
):
|
|
818
|
+
raise StructuredDataError(
|
|
819
|
+
f'random_seed must be a whole number, got '
|
|
820
|
+
f'{type(seed).__name__} {seed!r}',
|
|
821
|
+
code='INVALID_REQUEST',
|
|
822
|
+
)
|
|
823
|
+
if seed is not None and int(seed) < 0:
|
|
824
|
+
raise StructuredDataError(
|
|
825
|
+
f'random_seed is {seed}, and a seed is not negative; pass a '
|
|
826
|
+
f'number from 0 upwards, or leave it out to let the sampler '
|
|
827
|
+
f'choose',
|
|
828
|
+
code='INVALID_REQUEST',
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
hops = len(neighbors) if neighbors is not None else num_hops
|
|
832
|
+
if hops is not None and hops < 1:
|
|
833
|
+
spelled = (
|
|
834
|
+
'num_neighbors is empty'
|
|
835
|
+
if neighbors is not None
|
|
836
|
+
else f'num_hops is {hops}'
|
|
837
|
+
)
|
|
838
|
+
raise StructuredDataError(
|
|
839
|
+
f'{spelled}, which asks for no hop at all, and the endpoint '
|
|
840
|
+
f'reads between 1 and {limits["hops_max"]}. A request that '
|
|
841
|
+
f'crosses no relationship is a single-table one: give a hop, '
|
|
842
|
+
f'such as num_neighbors=[8, 8], or ask it of kumo-tabular',
|
|
843
|
+
code='INVALID_REQUEST',
|
|
844
|
+
)
|
|
845
|
+
if hops is not None and hops > limits['hops_max']:
|
|
846
|
+
raise StructuredDataError(
|
|
847
|
+
f'this request samples {hops} hops and the endpoint reads at most '
|
|
848
|
+
f'{limits["hops_max"]}',
|
|
849
|
+
code='UNSUPPORTED_REQUEST',
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _as_typed(table: Table) -> pd.DataFrame:
|
|
854
|
+
r"""The table's rows, with any decimal column cast to what it was typed.
|
|
855
|
+
|
|
856
|
+
A warehouse `DECIMAL` arrives from pandas as an `object` column of
|
|
857
|
+
:class:`~decimal.Decimal`. The schema layer reads the values and types it
|
|
858
|
+
(`int64` for whole numbers and `float64` otherwise), and the tabular path
|
|
859
|
+
sends it happily. The relational path handed the engine the raw frame
|
|
860
|
+
instead, which re-infers from the object column, reaches a different answer
|
|
861
|
+
and eventually meets a `Decimal` where a dtype name belongs:
|
|
862
|
+
|
|
863
|
+
dtype '<class 'decimal.Decimal'>' not understood
|
|
864
|
+
|
|
865
|
+
naming no table, no column and no remedy. Since every numeric column of a
|
|
866
|
+
Databricks or Snowflake table is `DECIMAL`, that is the first question
|
|
867
|
+
anyone asks of a warehouse through this SDK's own connector.
|
|
868
|
+
|
|
869
|
+
The two halves also disagreed about what such a column is. The engine maps
|
|
870
|
+
decimal content to float unconditionally, so an exact identifier became a
|
|
871
|
+
float and composite keys over warehouse data were refused outright. Handing
|
|
872
|
+
over the type the schema already computed settles both: there is one answer
|
|
873
|
+
now, and it is the one the schema reports.
|
|
874
|
+
"""
|
|
875
|
+
frame = table.data
|
|
876
|
+
if frame is None or frame.empty:
|
|
877
|
+
return frame
|
|
878
|
+
cast = {}
|
|
879
|
+
for name, column in table.columns.items():
|
|
880
|
+
if name not in frame.columns:
|
|
881
|
+
continue
|
|
882
|
+
where = f'{table.name}.{name}'
|
|
883
|
+
if name == table.time_column and _holds_text(frame[name]):
|
|
884
|
+
cast[name] = _to_time(frame[name], where)
|
|
885
|
+
elif _holds_decimal(frame[name]):
|
|
886
|
+
cast[name] = _to_declared(frame[name], column.dtype, where)
|
|
887
|
+
elif _holds_timestamps_loosely(frame[name], column.dtype):
|
|
888
|
+
cast[name] = pd.to_datetime(frame[name], utc=True)
|
|
889
|
+
return frame.assign(**cast) if cast else frame
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _holds_timestamps_loosely(values: pd.Series, declared: str | None) -> bool:
|
|
893
|
+
"""Whether schema timestamps are still loose in pandas.
|
|
894
|
+
|
|
895
|
+
An `object` column of `Timestamp` is typed `timestamp[us]` by the schema,
|
|
896
|
+
correctly, and handed to the engine unchanged it re-infers `string` and
|
|
897
|
+
then refuses mixed offsets as an internal error. Only mixed offsets fail,
|
|
898
|
+
which is what `from_records` and a warehouse read of a column spanning a
|
|
899
|
+
daylight-saving change both produce.
|
|
900
|
+
"""
|
|
901
|
+
if declared is None or not declared.startswith('timestamp'):
|
|
902
|
+
return False
|
|
903
|
+
if not pd.api.types.is_object_dtype(values.dtype):
|
|
904
|
+
return False
|
|
905
|
+
return pd.api.types.infer_dtype(values, skipna=True) in {
|
|
906
|
+
'datetime',
|
|
907
|
+
'datetime64',
|
|
908
|
+
'date',
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _holds_text(values: pd.Series) -> bool:
|
|
913
|
+
r"""Whether this column holds strings, rather than merely being `object`.
|
|
914
|
+
|
|
915
|
+
An `object` column can hold `datetime.date` or `pd.Timestamp` as readily as
|
|
916
|
+
text, and `infer_tfm_dtype` already calls those `timestamp[us]`. Reading
|
|
917
|
+
the dtype alone would send a column of real dates down the text path and
|
|
918
|
+
refuse it for being ambiguous, which it is not.
|
|
919
|
+
"""
|
|
920
|
+
if not (
|
|
921
|
+
pd.api.types.is_object_dtype(values.dtype)
|
|
922
|
+
or pd.api.types.is_string_dtype(values.dtype)
|
|
923
|
+
):
|
|
924
|
+
return False
|
|
925
|
+
return pd.api.types.infer_dtype(values, skipna=True) == 'string'
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
#: How the engine says a gathered batch is too large for the endpoint's body.
|
|
929
|
+
_A_BATCH_TOO_LARGE_TO_SEND = ('size exceeds the 30mb limit',)
|
|
930
|
+
|
|
931
|
+
#: How the engine offers a smaller batch, which it does only where the batch
|
|
932
|
+
#: that failed has more than one row in it. Read rather than worked out again
|
|
933
|
+
#: here, because which batch failed is the engine's to know: a request may be
|
|
934
|
+
#: gathered as several of different sizes, and the last of them is smaller
|
|
935
|
+
#: than the size the rest were built to.
|
|
936
|
+
_THE_ENGINE_S_ENTITIES = 'the number of entities predicted at once'
|
|
937
|
+
|
|
938
|
+
#: Where the engine's own remedy for that begins. It ends by offering the run
|
|
939
|
+
#: mode, which this client derives rather than takes, so a caller following it
|
|
940
|
+
#: lands on a refusal. Replaced rather than appended to, because advice that
|
|
941
|
+
#: cannot be taken is worse than none.
|
|
942
|
+
_THE_ENGINE_S_REMEDY = 'Please reduce either'
|
|
943
|
+
|
|
944
|
+
#: What to do about it here. Both halves are real and which one binds was
|
|
945
|
+
#: measured: a wide graph at the default context is gathered by asking in
|
|
946
|
+
#: smaller calls, and the same graph at 10,000 context rows is over the body
|
|
947
|
+
#: on the context alone, where only a narrower context helps.
|
|
948
|
+
_ASK_IN_SMALLER_CALLS = (
|
|
949
|
+
'What comes down is one of three. For an ordinary question it is the '
|
|
950
|
+
'call: materialize(rows_per_call=N) below the {per_call} this one holds '
|
|
951
|
+
'asks for the same entities in more and smaller calls. Where the context '
|
|
952
|
+
'is itself over the limit, no call is small enough, and it is '
|
|
953
|
+
'num_context_samples, num_neighbors or the columns the graph carries. '
|
|
954
|
+
'For a ranking or a forecast, which is one call per candidate or step '
|
|
955
|
+
'and is never batched, it is the entities.'
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
#: What to say when a call already scores one row. There is no smaller call
|
|
960
|
+
#: to ask for, so offering one would be asking for a number that does not
|
|
961
|
+
#: exist.
|
|
962
|
+
_NO_CALL_IS_SMALLER = (
|
|
963
|
+
'A call already scores one row, so there is no smaller one to ask for: '
|
|
964
|
+
'what comes down is num_context_samples, num_neighbors or the columns '
|
|
965
|
+
'the graph carries.'
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
_A_WINDOW_THAT_OUTRAN_THE_DATA = (
|
|
970
|
+
'aggregation time range',
|
|
971
|
+
'we would need data back to',
|
|
972
|
+
'anchor timestamp is too early',
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _said_in_this_client_s_terms(said: str, per_call: int | None) -> str:
|
|
977
|
+
r"""The engine's size complaint, keeping its figures and not its remedy.
|
|
978
|
+
|
|
979
|
+
What it measured is the useful half and is kept whole. What to do about it
|
|
980
|
+
is written for a caller who can set a run mode and a batch size directly,
|
|
981
|
+
and through this client one of those is derived and the other is named
|
|
982
|
+
differently, so that half is replaced.
|
|
983
|
+
|
|
984
|
+
The size named is the one this question holds to, which is not always the
|
|
985
|
+
10,000 of an ordinary task: a temporal link prediction holds 200, and
|
|
986
|
+
advice to come down to 500 would be followed into the same failure.
|
|
987
|
+
|
|
988
|
+
Whether a smaller call is worth offering at all is the engine's answer,
|
|
989
|
+
not one worked out from the size asked for. Only the engine knows which
|
|
990
|
+
batch failed, and a request gathered as `2, 2, 1` can fail on the one
|
|
991
|
+
that no batch size can divide.
|
|
992
|
+
"""
|
|
993
|
+
at = said.find(_THE_ENGINE_S_REMEDY)
|
|
994
|
+
measured = said if at < 0 else said[:at].rstrip()
|
|
995
|
+
if _THE_ENGINE_S_ENTITIES not in said:
|
|
996
|
+
return f'{measured} {_NO_CALL_IS_SMALLER}'
|
|
997
|
+
holds = f'{per_call:,}' if per_call else 'number'
|
|
998
|
+
return f'{measured} ' + _ASK_IN_SMALLER_CALLS.format(per_call=holds)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _what_the_engine_refused(said: str) -> dict[str, Any]:
|
|
1002
|
+
"""What the engine's complaint is about, as something to branch on.
|
|
1003
|
+
|
|
1004
|
+
A question asked as several parts appends an account of which part failed,
|
|
1005
|
+
and that account can only name a remedy where it knows the failure is one
|
|
1006
|
+
the remedy addresses. The engine says so in words; this records it so the
|
|
1007
|
+
seam reads a value rather than the words.
|
|
1008
|
+
"""
|
|
1009
|
+
lowered = said.lower()
|
|
1010
|
+
if any(mark in lowered for mark in _A_WINDOW_THAT_OUTRAN_THE_DATA):
|
|
1011
|
+
return {'window_outran_the_data': True}
|
|
1012
|
+
if any(mark in lowered for mark in _A_BATCH_TOO_LARGE_TO_SEND):
|
|
1013
|
+
return {'batch_too_large_to_send': True}
|
|
1014
|
+
return {}
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _only_junk_is_unread(present: pd.Series) -> bool:
|
|
1018
|
+
"""Whether what the column cannot read is junk rather than another format.
|
|
1019
|
+
|
|
1020
|
+
A value the column reads past can be either. `02/01/2020` among year-first
|
|
1021
|
+
dates is a date written the other way, and that is the ambiguity the
|
|
1022
|
+
message below is for. A value that is not a date on its own terms is not,
|
|
1023
|
+
and saying it is ambiguous sends the caller to a remedy that cannot work.
|
|
1024
|
+
|
|
1025
|
+
Asked of each value, by the one function that answers that question. It
|
|
1026
|
+
was asked here with the default reading, which leaves most of a day-first
|
|
1027
|
+
column unread, so the single value that is genuinely not a date was
|
|
1028
|
+
reported as ambiguity, while the same column written year-first was
|
|
1029
|
+
reported correctly.
|
|
1030
|
+
"""
|
|
1031
|
+
read = dates.reads_as_a_date(present)
|
|
1032
|
+
if read.all():
|
|
1033
|
+
return False
|
|
1034
|
+
if not read.any():
|
|
1035
|
+
return dates.looks_unambiguous(present)
|
|
1036
|
+
return bool(dates.looks_unambiguous(present[read]))
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _refuse_values_that_do_not_read(present: pd.Series, where: str) -> None:
|
|
1040
|
+
"""Refuse a column that says what its dates are and still will not read.
|
|
1041
|
+
|
|
1042
|
+
The ambiguity message below is true only of a column that says two things.
|
|
1043
|
+
A column that says one thing and carries a value that is not a date is a
|
|
1044
|
+
different mistake, and telling that caller their dates are ambiguous sends
|
|
1045
|
+
them to a remedy that cannot work: `dayfirst=True` on a year-first column
|
|
1046
|
+
either raises or reads every date it can as a different one.
|
|
1047
|
+
|
|
1048
|
+
One unreadable value is enough to make the whole column fail the shape
|
|
1049
|
+
test, so what the readable values say is asked separately.
|
|
1050
|
+
|
|
1051
|
+
Coercing is offered only where something would survive it: where no value
|
|
1052
|
+
reads it leaves a column of NaT, which this client refuses in turn.
|
|
1053
|
+
|
|
1054
|
+
What does not read is counted by the one function that answers that, or
|
|
1055
|
+
the count names as unread the dates the column reads perfectly well.
|
|
1056
|
+
"""
|
|
1057
|
+
column = where.split('.')[-1]
|
|
1058
|
+
unread = present[~dates.reads_as_a_date(present)]
|
|
1059
|
+
example = unread.iloc[0] if len(unread) else present.iloc[0]
|
|
1060
|
+
if len(unread) == len(present):
|
|
1061
|
+
raise StructuredDataError(
|
|
1062
|
+
f'{where} is the time column and none of its {len(present)} '
|
|
1063
|
+
f'values read as a date, such as {example!r}. A table is windowed '
|
|
1064
|
+
f'by its time column, so this one has no time axis: give it a '
|
|
1065
|
+
f'column that holds dates, or leave it without one and ask a '
|
|
1066
|
+
f'question that does not window it',
|
|
1067
|
+
code='INVALID_REQUEST',
|
|
1068
|
+
)
|
|
1069
|
+
raise StructuredDataError(
|
|
1070
|
+
f'{where} is the time column and {len(unread)} of {len(present)} of its '
|
|
1071
|
+
f'values do not read as a date, such as {example!r}. Fix or drop those '
|
|
1072
|
+
f'values, or read the column yourself and let them become NaT: '
|
|
1073
|
+
f'frame[{column!r}] = pd.to_datetime(frame[{column!r}], '
|
|
1074
|
+
f"errors='coerce'). This client accepts it and says how many rows "
|
|
1075
|
+
f'the model will not see',
|
|
1076
|
+
code='INVALID_REQUEST',
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _refuse_a_column_written_two_ways(
|
|
1081
|
+
shapes: dict[str, str], where: str, unread: int = 0
|
|
1082
|
+
) -> None:
|
|
1083
|
+
"""Refuse a column whose rows are written in more than one shape.
|
|
1084
|
+
|
|
1085
|
+
Every row says exactly what it is, so it is not ambiguous. What it has no
|
|
1086
|
+
answer to is which single call reads it: the one that reads `13/02/2021`
|
|
1087
|
+
reads `2021-02-04` as the second of April.
|
|
1088
|
+
|
|
1089
|
+
Said before the values that do not read, where a column has both, and
|
|
1090
|
+
saying so. Coercing is what the other message offers, and coercing a
|
|
1091
|
+
column written two ways throws away every row of the shape it does not
|
|
1092
|
+
settle on. The caller therefore has to know about the shapes first.
|
|
1093
|
+
"""
|
|
1094
|
+
year_first = shapes['year-first']
|
|
1095
|
+
other = shapes['day-or-month-first']
|
|
1096
|
+
column = where.split('.')[-1]
|
|
1097
|
+
also = (
|
|
1098
|
+
f' {unread} of its values do not read as a date either, and are a '
|
|
1099
|
+
f'separate thing to fix.'
|
|
1100
|
+
if unread
|
|
1101
|
+
else ''
|
|
1102
|
+
)
|
|
1103
|
+
raise StructuredDataError(
|
|
1104
|
+
f'{where} is the time column and is written two ways: {year_first!r} '
|
|
1105
|
+
f'leads with its year and {other!r} leads with its day or its month. '
|
|
1106
|
+
f'No one reading covers both. The call that reads {other!r} reads '
|
|
1107
|
+
f'{year_first!r} as a different date, so read the halves apart and '
|
|
1108
|
+
f'put them back as one column, or store frame[{column!r}] in one '
|
|
1109
|
+
f'shape before naming it as the time column.{also}',
|
|
1110
|
+
code='INVALID_REQUEST',
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _to_time(values: pd.Series, where: str) -> pd.Series:
|
|
1115
|
+
r"""A string time column read as dates, or refused for saying two things.
|
|
1116
|
+
|
|
1117
|
+
`supports_dtype('timestamp', 'string')` is true, so a column of date
|
|
1118
|
+
strings can be named as a table's time column and is accepted. The strings
|
|
1119
|
+
then have to become timestamps before the model sees them, and that was
|
|
1120
|
+
left to `pd.to_datetime`'s defaults, which read an ambiguous slash date
|
|
1121
|
+
month-first. A day-first column was silently re-dated: twelve monthly
|
|
1122
|
+
events across a year became twelve daily events in one week of February,
|
|
1123
|
+
and nothing in the request, the schema or the response recorded that a
|
|
1124
|
+
conversion had happened at all. It surfaced, when it surfaced, as an
|
|
1125
|
+
unrelated complaint about anchor times.
|
|
1126
|
+
|
|
1127
|
+
So a column is read only where it says what its dates are. A year-first one
|
|
1128
|
+
says it outright. A slash-separated one says it when a value of its own
|
|
1129
|
+
rules a reading out. No month is 15, so `15/03/2020` is day-first and
|
|
1130
|
+
every other row reads the same way. This is the ordinary case for any
|
|
1131
|
+
column spanning more than twelve days. Where nothing decides, `02/01/2020`
|
|
1132
|
+
is the 2nd of January to half the world and the 1st of February to the
|
|
1133
|
+
other half, and there the caller says, by converting it themselves.
|
|
1134
|
+
|
|
1135
|
+
The shape check reads a sample and is only a pre-filter; the conversion
|
|
1136
|
+
below reads every value, so a column that changes shape in its last row is
|
|
1137
|
+
caught there rather than here.
|
|
1138
|
+
|
|
1139
|
+
Ambiguity is considered last. A column written in no date shape, and one
|
|
1140
|
+
written in two, are each a different mistake with a different remedy.
|
|
1141
|
+
|
|
1142
|
+
Reading each value on its own terms is the last reading tried, and only
|
|
1143
|
+
once the column is known to be one shape: it will also read a column
|
|
1144
|
+
written two ways, by re-dating the half it does not settle on.
|
|
1145
|
+
"""
|
|
1146
|
+
present = values.dropna()
|
|
1147
|
+
if present.empty:
|
|
1148
|
+
return values
|
|
1149
|
+
reads = dates.looks_unambiguous(present)
|
|
1150
|
+
if reads:
|
|
1151
|
+
how = dates.conversion_that_works(present, only_plausible_years=False)
|
|
1152
|
+
if how is not None:
|
|
1153
|
+
return dates.read(values, how)
|
|
1154
|
+
shapes = dates.shapes_written(present)
|
|
1155
|
+
if len(shapes) > 1:
|
|
1156
|
+
unread = int((~dates.reads_as_a_date(present)).sum())
|
|
1157
|
+
return _refuse_a_column_written_two_ways(shapes, where, unread)
|
|
1158
|
+
if reads:
|
|
1159
|
+
how = dates.conversion_that_works(
|
|
1160
|
+
present, only_plausible_years=False, per_row=True
|
|
1161
|
+
)
|
|
1162
|
+
if how is not None:
|
|
1163
|
+
return dates.read(values, how)
|
|
1164
|
+
if not shapes or _only_junk_is_unread(present):
|
|
1165
|
+
return _refuse_values_that_do_not_read(present, where)
|
|
1166
|
+
example = next(
|
|
1167
|
+
(value for value in present if isinstance(value, str)), present.iloc[0]
|
|
1168
|
+
)
|
|
1169
|
+
column = where.split('.')[-1]
|
|
1170
|
+
raise StructuredDataError(
|
|
1171
|
+
f'{where} is the time column and holds text such as {example!r}, '
|
|
1172
|
+
f'which does not say unambiguously what date it is: read day-first it '
|
|
1173
|
+
f'is one date and month-first another, and nothing in the column says '
|
|
1174
|
+
f'which. Convert it yourself so the reading is yours: '
|
|
1175
|
+
f'frame[{column!r}] = pd.to_datetime(frame[{column!r}], '
|
|
1176
|
+
f'dayfirst=True), or store it year-first',
|
|
1177
|
+
code='INVALID_REQUEST',
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _holds_decimal(values: pd.Series) -> bool:
|
|
1182
|
+
r"""Whether a column holds decimals, however it was read.
|
|
1183
|
+
|
|
1184
|
+
Through Arrow the dtype says so; through pandas it is an `object` column
|
|
1185
|
+
and only the values do.
|
|
1186
|
+
"""
|
|
1187
|
+
if arrow_decimal_type(values.dtype) is not None:
|
|
1188
|
+
return True
|
|
1189
|
+
if not pd.api.types.is_object_dtype(values.dtype):
|
|
1190
|
+
return False
|
|
1191
|
+
return pd.api.types.infer_dtype(values, skipna=True) == 'decimal'
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _to_declared(values: pd.Series, dtype: str, where: str) -> pd.Series:
|
|
1195
|
+
r"""``values`` as ``dtype``, exactly.
|
|
1196
|
+
|
|
1197
|
+
Only what the schema asked for. A whole decimal too wide for an `int64` is
|
|
1198
|
+
typed `string` precisely so its digits survive, and reading "not an int" as
|
|
1199
|
+
"a float" would put 9223372036854775808 back as 9.223372036854776e+18,
|
|
1200
|
+
the corruption this exists to prevent, reintroduced one branch over.
|
|
1201
|
+
|
|
1202
|
+
A column with a missing value cannot be a plain `int64`, so it becomes
|
|
1203
|
+
pandas' nullable `Int64` rather than widening to float and rounding an
|
|
1204
|
+
identifier.
|
|
1205
|
+
|
|
1206
|
+
The remaining declaration is `string`, which is how a whole number wider
|
|
1207
|
+
than an int64 travels: only the digits matter and only a string keeps all
|
|
1208
|
+
of them. An integer is read with `int()` on the value and never through
|
|
1209
|
+
`pd.to_numeric`, which converts by way of a float64 and brings
|
|
1210
|
+
9007199254740993 back as 9007199254740992. These are identifiers, and the
|
|
1211
|
+
low bit is the whole of their job.
|
|
1212
|
+
"""
|
|
1213
|
+
if dtype.startswith('float'):
|
|
1214
|
+
return pd.to_numeric(values.astype(object), errors='coerce').astype(
|
|
1215
|
+
'float64'
|
|
1216
|
+
)
|
|
1217
|
+
if not dtype.startswith('int'):
|
|
1218
|
+
return values.map(lambda value: None if pd.isna(value) else str(value))
|
|
1219
|
+
fractional = values.map(
|
|
1220
|
+
lambda value: not pd.isna(value) and value != int(value)
|
|
1221
|
+
)
|
|
1222
|
+
if fractional.any():
|
|
1223
|
+
example = values[fractional].iloc[0]
|
|
1224
|
+
raise StructuredDataError(
|
|
1225
|
+
f'column {where} is declared {dtype!r} and holds {example!r}, '
|
|
1226
|
+
f'which is not a whole number. Sending it would round the value '
|
|
1227
|
+
f'without saying so; declare the column float64, or round it '
|
|
1228
|
+
f'yourself if that is what you meant',
|
|
1229
|
+
code='INVALID_REQUEST',
|
|
1230
|
+
)
|
|
1231
|
+
whole = values.map(lambda value: pd.NA if pd.isna(value) else int(value))
|
|
1232
|
+
if whole.isna().any():
|
|
1233
|
+
return whole.astype('Int64')
|
|
1234
|
+
return whole.astype('int64')
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _columns_to_leave_out(
|
|
1238
|
+
query: ValidatedPredictiveQuery, options: Mapping[str, Any]
|
|
1239
|
+
) -> dict[str, list[str]]:
|
|
1240
|
+
r"""Every column either the query or the caller asked to keep back.
|
|
1241
|
+
|
|
1242
|
+
`exclude_cols_dict` was accepted by `materialize()`, checked against the
|
|
1243
|
+
options this route takes, and then not passed on: the query's own answer
|
|
1244
|
+
was sent in its place, so the one option someone reaches for to keep a
|
|
1245
|
+
column away from the model did nothing.
|
|
1246
|
+
|
|
1247
|
+
Merged rather than replaced. The query excludes columns for reasons of its
|
|
1248
|
+
own, including the target. Letting a caller's list stand in for
|
|
1249
|
+
that would hand the model back something the query had deliberately
|
|
1250
|
+
removed.
|
|
1251
|
+
|
|
1252
|
+
Only an absent option and an explicit `None` mean there is nothing to
|
|
1253
|
+
exclude. Anything else is read and refused if it is not a mapping, since
|
|
1254
|
+
passing over a value that cannot be one is the same silence this exists to
|
|
1255
|
+
remove.
|
|
1256
|
+
"""
|
|
1257
|
+
merged = {
|
|
1258
|
+
table: list(columns)
|
|
1259
|
+
for table, columns in (query.get_exclude_cols_dict() or {}).items()
|
|
1260
|
+
}
|
|
1261
|
+
given = options.get('exclude_cols_dict')
|
|
1262
|
+
if given is None:
|
|
1263
|
+
given = {}
|
|
1264
|
+
if not isinstance(given, Mapping):
|
|
1265
|
+
raise StructuredDataError(
|
|
1266
|
+
f'exclude_cols_dict must map a table name to the columns to leave '
|
|
1267
|
+
f'out, got {type(given).__name__}',
|
|
1268
|
+
code='INVALID_REQUEST',
|
|
1269
|
+
)
|
|
1270
|
+
for table, columns in given.items():
|
|
1271
|
+
if isinstance(columns, str) or not isinstance(columns, Sequence):
|
|
1272
|
+
raise StructuredDataError(
|
|
1273
|
+
f'exclude_cols_dict[{table!r}] must be a sequence of column '
|
|
1274
|
+
f'names, got {type(columns).__name__}',
|
|
1275
|
+
code='INVALID_REQUEST',
|
|
1276
|
+
)
|
|
1277
|
+
kept = merged.setdefault(table, [])
|
|
1278
|
+
kept.extend(column for column in columns if column not in kept)
|
|
1279
|
+
return merged
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _candidates_to_rank(
|
|
1283
|
+
query: ValidatedPredictiveQuery, options: Mapping[str, Any]
|
|
1284
|
+
) -> int | None:
|
|
1285
|
+
r"""How many candidates to rank, held to the limits the language holds.
|
|
1286
|
+
|
|
1287
|
+
A `TOP K` written in the query is checked: at least one, and no more than
|
|
1288
|
+
the endpoint ranks. Passing it as an option went straight through to the
|
|
1289
|
+
task, so an override could put a number in a request that the same number
|
|
1290
|
+
written in the query would have been refused for.
|
|
1291
|
+
"""
|
|
1292
|
+
if 'top_k' not in options:
|
|
1293
|
+
return query.top_k
|
|
1294
|
+
given = options['top_k']
|
|
1295
|
+
if isinstance(given, bool) or not isinstance(given, Integral):
|
|
1296
|
+
raise StructuredDataError(
|
|
1297
|
+
f'top_k must be a whole number of candidates, got '
|
|
1298
|
+
f'{type(given).__name__}',
|
|
1299
|
+
code='INVALID_REQUEST',
|
|
1300
|
+
)
|
|
1301
|
+
if not 1 <= int(given) <= _MAX_TOP_K:
|
|
1302
|
+
raise StructuredDataError(
|
|
1303
|
+
f'top_k is {given}, and this model ranks between 1 and '
|
|
1304
|
+
f'{_MAX_TOP_K} candidates',
|
|
1305
|
+
code='INVALID_REQUEST',
|
|
1306
|
+
)
|
|
1307
|
+
return int(given)
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _statistic_asked_for(options: Mapping[str, Any]) -> str | None:
|
|
1311
|
+
r"""The regression statistic the caller chose, refused if it is not one
|
|
1312
|
+
the endpoint reports."""
|
|
1313
|
+
if 'prediction_statistic' not in options:
|
|
1314
|
+
return None
|
|
1315
|
+
given = options['prediction_statistic']
|
|
1316
|
+
if not isinstance(given, str) or given not in PREDICTION_STATISTICS:
|
|
1317
|
+
raise StructuredDataError(
|
|
1318
|
+
f'prediction_statistic is {given!r}, and a regression reports '
|
|
1319
|
+
f'{" or ".join(repr(name) for name in sorted(PREDICTION_STATISTICS))} '
|
|
1320
|
+
f'of its predicted distribution',
|
|
1321
|
+
code='INVALID_REQUEST',
|
|
1322
|
+
)
|
|
1323
|
+
return str(given)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _record_the_statistic(
|
|
1327
|
+
payloads: tuple[dict[str, Any], ...], statistic: str
|
|
1328
|
+
) -> None:
|
|
1329
|
+
r"""Carry the chosen statistic on each call, or refuse a question that
|
|
1330
|
+
has no distribution to summarise."""
|
|
1331
|
+
for payload in payloads:
|
|
1332
|
+
kind = str(payload.get('task', {}).get('kind', ''))
|
|
1333
|
+
if kind not in SUMMARISED_TASK_KINDS:
|
|
1334
|
+
raise StructuredDataError(
|
|
1335
|
+
f'prediction_statistic applies only to a regression, and '
|
|
1336
|
+
f'this question is a {kind}; ask it without '
|
|
1337
|
+
f'prediction_statistic',
|
|
1338
|
+
code='INVALID_REQUEST',
|
|
1339
|
+
)
|
|
1340
|
+
output = payload.setdefault('output', {})
|
|
1341
|
+
output['prediction_statistic'] = statistic
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _warn_rows_the_graph_dropped(model: KumoRelational) -> None:
|
|
1345
|
+
r"""Say what the engine left out while building the graph.
|
|
1346
|
+
|
|
1347
|
+
A fact row with no time, no key, or a key another row already used cannot
|
|
1348
|
+
be placed in the graph and is dropped. The count and the reason are worked
|
|
1349
|
+
out either way, and `_sanitization_detail` builds the sentence. It was
|
|
1350
|
+
only ever shown when something else had already failed. So a request that
|
|
1351
|
+
succeeded was answered from a graph smaller than the one the caller
|
|
1352
|
+
passed, the result looked ordinary, and the number was sitting there
|
|
1353
|
+
unread.
|
|
1354
|
+
"""
|
|
1355
|
+
detail = RelationalMaterializer._sanitization_detail(model)
|
|
1356
|
+
if not detail:
|
|
1357
|
+
return
|
|
1358
|
+
warnings.warn(
|
|
1359
|
+
f'the graph was built without some of the rows given to it '
|
|
1360
|
+
f'{detail.strip()}. Those rows are not in the context the model sees; '
|
|
1361
|
+
f'fill the missing values or drop the rows yourself if that is not '
|
|
1362
|
+
f'what you meant.',
|
|
1363
|
+
category=SDMWarning,
|
|
1364
|
+
stacklevel=caller_stacklevel(),
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
#: What the wire calls the column carrying the entity a row is about.
|
|
1369
|
+
_ENTITY_REF = '__kumo_entity_ref'
|
|
1370
|
+
|
|
1371
|
+
#: And the moment it is asked about.
|
|
1372
|
+
_ANCHOR = '__kumo_anchor_time'
|
|
1373
|
+
|
|
1374
|
+
#: What the envelope calls the answer a context row carries.
|
|
1375
|
+
_TARGET = 'TARGET'
|
|
1376
|
+
|
|
1377
|
+
#: What ties a related row to the instance it hangs off.
|
|
1378
|
+
INSTANCE_ID = 'instance_id'
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _drop_context_that_answers_the_question(
|
|
1382
|
+
payloads: tuple[dict[str, Any], ...], *, windowed: bool = True
|
|
1383
|
+
) -> None:
|
|
1384
|
+
r"""Take out of the context any row that is a scored row at its own anchor.
|
|
1385
|
+
|
|
1386
|
+
On a target with no time window the entity being scored is sampled into the
|
|
1387
|
+
context as well, at the same anchor, carrying the value being asked for,
|
|
1388
|
+
so the model is shown the answer to the question it is being asked.
|
|
1389
|
+
Measured against the live endpoint on a label that is pure noise, a hundred
|
|
1390
|
+
scored entities came back 0.660 correct where chance is 0.500.
|
|
1391
|
+
|
|
1392
|
+
**What counts as the same question depends on the target.** For an
|
|
1393
|
+
aggregation, an entity in the context at an *earlier* anchor is not a leak:
|
|
1394
|
+
it is an earlier example, with its features cut at that anchor and its
|
|
1395
|
+
label window closed before the prediction window opens, and it is what
|
|
1396
|
+
temporal in-context learning runs on. Dropping those would delete the
|
|
1397
|
+
examples an aggregation depends on, to fix a leak that is not there. So
|
|
1398
|
+
there, only the same entity at the same moment goes.
|
|
1399
|
+
|
|
1400
|
+
A static column is not a window over anything. Its value does not move with
|
|
1401
|
+
the anchor, so the same entity at *any* anchor carries the answer being
|
|
1402
|
+
asked for. A caller reaches this by setting `context_anchor_time`, and
|
|
1403
|
+
which matching on the anchor alone would have let straight through.
|
|
1404
|
+
|
|
1405
|
+
The two halves carry their own `instance_id` space, so removing a context
|
|
1406
|
+
row means removing the related rows that hang off it and nothing else.
|
|
1407
|
+
|
|
1408
|
+
What is found is collected before any of it is taken, keyed by the identity
|
|
1409
|
+
of the context it belongs to and unioned: two payloads can share one
|
|
1410
|
+
context object, where counting the entries would report a row once per
|
|
1411
|
+
entry and keeping only the first entry's rows would leave the rest behind.
|
|
1412
|
+
|
|
1413
|
+
The notice comes before the removal. A caller running with
|
|
1414
|
+
`simplefilter('error')` turns it into a raise, and a raise partway through
|
|
1415
|
+
would leave the payloads it had already reached short of rows nobody was
|
|
1416
|
+
told were gone. Which is why it states the rule rather than the edit:
|
|
1417
|
+
raised as an exception, nothing has been removed and no context has been
|
|
1418
|
+
sent, and a message claiming either would be false in the one case the
|
|
1419
|
+
ordering exists for.
|
|
1420
|
+
"""
|
|
1421
|
+
found: dict[int, tuple[Mapping[str, Any], set[Any]]] = {}
|
|
1422
|
+
for payload in payloads:
|
|
1423
|
+
context = payload.get('context')
|
|
1424
|
+
predict = payload.get('predict')
|
|
1425
|
+
if not isinstance(context, Mapping) or not isinstance(predict, Mapping):
|
|
1426
|
+
continue
|
|
1427
|
+
anchor_column = (payload.get('task') or {}).get(
|
|
1428
|
+
'anchor_time_column'
|
|
1429
|
+
) or _ANCHOR
|
|
1430
|
+
asked = _entities_at_anchors(
|
|
1431
|
+
predict.get('instance_table'), anchor_column, windowed
|
|
1432
|
+
)
|
|
1433
|
+
if not asked:
|
|
1434
|
+
continue
|
|
1435
|
+
answered = _instances_matching(
|
|
1436
|
+
context.get('instance_table'), asked, anchor_column, windowed
|
|
1437
|
+
)
|
|
1438
|
+
if not answered:
|
|
1439
|
+
continue
|
|
1440
|
+
carried = found.setdefault(id(context), (context, set()))
|
|
1441
|
+
carried[1].update(answered)
|
|
1442
|
+
if not found:
|
|
1443
|
+
return
|
|
1444
|
+
|
|
1445
|
+
total = sum(len(answered) for _, answered in found.values())
|
|
1446
|
+
# The two targets lose different rows, so they are told different things.
|
|
1447
|
+
# A window closes, so only the same moment carries the answer; a static
|
|
1448
|
+
# column does not move, so every moment does.
|
|
1449
|
+
when = 'at the same moment' if windowed else 'at one moment or another'
|
|
1450
|
+
warnings.warn(
|
|
1451
|
+
f'{total} of the rows sampled as context are the rows being scored, '
|
|
1452
|
+
f'{when}, carrying the value being asked for. Rows that carry the '
|
|
1453
|
+
f'answer to the question being asked are not sent as context, so '
|
|
1454
|
+
f'these do not go into it.',
|
|
1455
|
+
category=SDMWarning,
|
|
1456
|
+
stacklevel=caller_stacklevel(),
|
|
1457
|
+
)
|
|
1458
|
+
for context, answered in found.values():
|
|
1459
|
+
_remove_instances(context.get('instance_table'), answered)
|
|
1460
|
+
for related in (context.get('related_tables') or {}).values():
|
|
1461
|
+
_remove_instances(related, answered)
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def _positions(
|
|
1465
|
+
table: Any, anchor_column: str
|
|
1466
|
+
) -> tuple[list[Any], list[Any], int | None, int | None]:
|
|
1467
|
+
r"""Where the entity and the anchor sit in a wire table.
|
|
1468
|
+
|
|
1469
|
+
The anchor column is read from the task rather than assumed: the engine
|
|
1470
|
+
suffixes the name when it would collide with a real column, and looking
|
|
1471
|
+
only for the bare name went blind on exactly those graphs.
|
|
1472
|
+
"""
|
|
1473
|
+
columns = (table or {}).get('columns')
|
|
1474
|
+
rows = (table or {}).get('rows')
|
|
1475
|
+
if not isinstance(columns, list) or not isinstance(rows, list):
|
|
1476
|
+
return [], [], None, None
|
|
1477
|
+
reference = next(
|
|
1478
|
+
(
|
|
1479
|
+
index
|
|
1480
|
+
for index, name in enumerate(columns)
|
|
1481
|
+
if str(name).startswith(_ENTITY_REF)
|
|
1482
|
+
),
|
|
1483
|
+
None,
|
|
1484
|
+
)
|
|
1485
|
+
anchor = columns.index(anchor_column) if anchor_column in columns else None
|
|
1486
|
+
return columns, rows, reference, anchor
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def _entities_at_anchors(
|
|
1490
|
+
table: Any, anchor_column: str = _ANCHOR, windowed: bool = True
|
|
1491
|
+
) -> set[tuple[Any, Any]]:
|
|
1492
|
+
columns, rows, reference, anchor = _positions(table, anchor_column)
|
|
1493
|
+
if reference is None or (windowed and anchor is None):
|
|
1494
|
+
return set()
|
|
1495
|
+
needed = max(reference, anchor) if windowed else reference
|
|
1496
|
+
return {
|
|
1497
|
+
(row[reference], row[anchor] if windowed else None)
|
|
1498
|
+
for row in rows
|
|
1499
|
+
if isinstance(row, list) and len(row) > needed
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _instances_matching(
|
|
1504
|
+
table: Any,
|
|
1505
|
+
asked: set[tuple[Any, Any]],
|
|
1506
|
+
anchor_column: str = _ANCHOR,
|
|
1507
|
+
windowed: bool = True,
|
|
1508
|
+
) -> set[Any]:
|
|
1509
|
+
columns, rows, reference, anchor = _positions(table, anchor_column)
|
|
1510
|
+
if reference is None or 'instance_id' not in columns:
|
|
1511
|
+
return set()
|
|
1512
|
+
if windowed and anchor is None:
|
|
1513
|
+
return set()
|
|
1514
|
+
at = columns.index('instance_id')
|
|
1515
|
+
needed = max(reference, at, anchor) if windowed else max(reference, at)
|
|
1516
|
+
return {
|
|
1517
|
+
row[at]
|
|
1518
|
+
for row in rows
|
|
1519
|
+
if isinstance(row, list)
|
|
1520
|
+
and len(row) > needed
|
|
1521
|
+
and (row[reference], row[anchor] if windowed else None) in asked
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def _remove_instances(table: Any, instances: set[Any]) -> None:
|
|
1526
|
+
columns = (table or {}).get('columns')
|
|
1527
|
+
rows = (table or {}).get('rows')
|
|
1528
|
+
if not isinstance(columns, list) or not isinstance(rows, list):
|
|
1529
|
+
return
|
|
1530
|
+
if 'instance_id' not in columns:
|
|
1531
|
+
return
|
|
1532
|
+
at = columns.index('instance_id')
|
|
1533
|
+
table['rows'] = [
|
|
1534
|
+
row
|
|
1535
|
+
for row in rows
|
|
1536
|
+
if not (
|
|
1537
|
+
isinstance(row, list) and len(row) > at and row[at] in instances
|
|
1538
|
+
)
|
|
1539
|
+
]
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _tables_sent(
|
|
1543
|
+
payloads: Sequence[Mapping[str, Any]], schema: Schema
|
|
1544
|
+
) -> frozenset[str]:
|
|
1545
|
+
r"""Which of the schema's tables the built payloads carry.
|
|
1546
|
+
|
|
1547
|
+
Read off the payload rather than worked out from the schema, because only
|
|
1548
|
+
the sampler knows: a table the schema links and whose foreign key matches
|
|
1549
|
+
no row is not in the request, and an answer derived from the schema would
|
|
1550
|
+
say it was. A payload that names no related tables at all is a shape this
|
|
1551
|
+
does not recognise, and there the schema is assumed sent, so nothing is
|
|
1552
|
+
reported as lost on a guess.
|
|
1553
|
+
"""
|
|
1554
|
+
carried: set[str] = set()
|
|
1555
|
+
described = False
|
|
1556
|
+
for payload in payloads:
|
|
1557
|
+
related = (payload.get('schema') or {}).get('related_tables')
|
|
1558
|
+
if isinstance(related, Mapping):
|
|
1559
|
+
described = True
|
|
1560
|
+
carried.update(related)
|
|
1561
|
+
return frozenset(carried) if described else frozenset(schema.tables)
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _tables_linked_to(schema: Schema, entity_table: str) -> frozenset[str]:
|
|
1565
|
+
r"""The tables a relationship path connects to ``entity_table``.
|
|
1566
|
+
|
|
1567
|
+
Read in both directions, because the sampler traverses an edge either way:
|
|
1568
|
+
a parent is reached from its child as readily as a child from its parent.
|
|
1569
|
+
Used only to say why a table is missing, never to refuse one. What is
|
|
1570
|
+
reachable is an upper bound on what is gathered, and refusing on it denies
|
|
1571
|
+
graphs the endpoint answers.
|
|
1572
|
+
"""
|
|
1573
|
+
neighbours: dict[str, set[str]] = {}
|
|
1574
|
+
for link in schema.relationships:
|
|
1575
|
+
neighbours.setdefault(link.source_table, set()).add(link.target_table)
|
|
1576
|
+
neighbours.setdefault(link.target_table, set()).add(link.source_table)
|
|
1577
|
+
seen = {entity_table}
|
|
1578
|
+
queue = [entity_table]
|
|
1579
|
+
while queue:
|
|
1580
|
+
for nearby in neighbours.get(queue.pop(), ()):
|
|
1581
|
+
if nearby not in seen:
|
|
1582
|
+
seen.add(nearby)
|
|
1583
|
+
queue.append(nearby)
|
|
1584
|
+
return frozenset(seen)
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _warn_of_tables_left_out(
|
|
1588
|
+
schema: Schema, sent: frozenset[str], entity_table: str
|
|
1589
|
+
) -> None:
|
|
1590
|
+
r"""Say which of the schema's tables this request does not carry.
|
|
1591
|
+
|
|
1592
|
+
A table the sampler does not reach is removed from the payload, and the
|
|
1593
|
+
removal was silent. Measured on a parent whose value is the mean of its
|
|
1594
|
+
children's, leaving the one edge between them undeclared moved the error
|
|
1595
|
+
from 0.008 to 0.134, sixteen times worse, with nothing said and a result
|
|
1596
|
+
that looks entirely ordinary.
|
|
1597
|
+
|
|
1598
|
+
The two causes are said separately because they take different remedies.
|
|
1599
|
+
Nothing links the first kind, and only a relationship puts it back. The
|
|
1600
|
+
second is linked and was still not reached, which is either a hop budget
|
|
1601
|
+
that stops short of it or a foreign key that matches no row.
|
|
1602
|
+
"""
|
|
1603
|
+
missing = sorted(set(schema.tables) - sent)
|
|
1604
|
+
if not missing:
|
|
1605
|
+
return
|
|
1606
|
+
linked = _tables_linked_to(schema, entity_table)
|
|
1607
|
+
unlinked = [name for name in missing if name not in linked]
|
|
1608
|
+
unreached = [name for name in missing if name in linked]
|
|
1609
|
+
if unlinked:
|
|
1610
|
+
many = len(unlinked) > 1
|
|
1611
|
+
warnings.warn(
|
|
1612
|
+
f'{", ".join(repr(name) for name in unlinked)} '
|
|
1613
|
+
f'{"are" if many else "is"} in the schema and not in this '
|
|
1614
|
+
f'request: no relationship connects {"them" if many else "it"} '
|
|
1615
|
+
f'to {entity_table!r}, so the model answers without '
|
|
1616
|
+
f'{"them" if many else "it"}. Say how '
|
|
1617
|
+
f'{"they join" if many else "it joins"}: '
|
|
1618
|
+
f'schema.relationships.append(Relationship({unlinked[0]!r}, '
|
|
1619
|
+
f'<foreign key>, <parent table>, <its primary key>)), or leave '
|
|
1620
|
+
f'{"them" if many else "it"} out of the schema to say '
|
|
1621
|
+
f'{"they are" if many else "it is"} not wanted.',
|
|
1622
|
+
category=SDMWarning,
|
|
1623
|
+
stacklevel=caller_stacklevel(),
|
|
1624
|
+
)
|
|
1625
|
+
if unreached:
|
|
1626
|
+
many = len(unreached) > 1
|
|
1627
|
+
warnings.warn(
|
|
1628
|
+
f'{", ".join(repr(name) for name in unreached)} '
|
|
1629
|
+
f'{"are" if many else "is"} linked into the schema and not in '
|
|
1630
|
+
f'this request: the sampler reached no row of '
|
|
1631
|
+
f'{"them" if many else "it"} from the entities being scored, so '
|
|
1632
|
+
f'the model answers without {"them" if many else "it"}. Raise '
|
|
1633
|
+
f'num_hops if {"they lie" if many else "it lies"} further out '
|
|
1634
|
+
f'than the hops sampled, and check the foreign key holds values '
|
|
1635
|
+
f'the parent key actually has if '
|
|
1636
|
+
f'{"they do" if many else "it does"} not.',
|
|
1637
|
+
category=SDMWarning,
|
|
1638
|
+
stacklevel=caller_stacklevel(),
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _warn_of_relationships_that_join_nothing(schema: Schema) -> None:
|
|
1643
|
+
r"""Say when a declared relationship matches no row on either side.
|
|
1644
|
+
|
|
1645
|
+
Nothing looks at what a foreign key holds. `_require_joinable` checks the
|
|
1646
|
+
dtype and the shape of the parent key, and the sanitization report counts
|
|
1647
|
+
null and repeated keys, so an edge between two disjoint namespaces of the
|
|
1648
|
+
same dtype passes both. Declaring `flights.AIRLINE -> airports.IATA_CODE`
|
|
1649
|
+
where `flights.ORIGIN_AIRPORT` was meant is one word, and the same query
|
|
1650
|
+
on the same frames answered 159.7 with the right column and -0.006 with
|
|
1651
|
+
the wrong one, in byte-identical silence.
|
|
1652
|
+
|
|
1653
|
+
Said rather than refused: a child with no rows yet is legal and joins
|
|
1654
|
+
nothing for a reason that is not a mistake, which is why an empty frame on
|
|
1655
|
+
either side is passed over rather than reported.
|
|
1656
|
+
"""
|
|
1657
|
+
empty = [
|
|
1658
|
+
link for link in schema.relationships if _joins_nothing(schema, link)
|
|
1659
|
+
]
|
|
1660
|
+
if not empty:
|
|
1661
|
+
return
|
|
1662
|
+
first = empty[0]
|
|
1663
|
+
many = len(empty) > 1
|
|
1664
|
+
warnings.warn(
|
|
1665
|
+
f'{", ".join(str(link) for link in empty)} '
|
|
1666
|
+
f'{"join" if many else "joins"} none of the rows given: no value of '
|
|
1667
|
+
f'the foreign key appears in the key it points at, so the tables are '
|
|
1668
|
+
f'related in the schema and in no row of the data, and the question '
|
|
1669
|
+
f'is answered as though {"they" if many else "it"} were not declared. '
|
|
1670
|
+
f'Check that the foreign key is the column naming the parent row; one '
|
|
1671
|
+
f'spelled differently from the key it points at has to be said '
|
|
1672
|
+
f'outright, as relationships=[Relationship({first.source_table!r}, '
|
|
1673
|
+
f'<that column>, {first.target_table!r}, {first.target_column!r})].',
|
|
1674
|
+
category=SDMWarning,
|
|
1675
|
+
stacklevel=caller_stacklevel(),
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def _joins_nothing(schema: Schema, link: Relationship) -> bool:
|
|
1680
|
+
r"""Whether no row of the child's key is a row of the parent's.
|
|
1681
|
+
|
|
1682
|
+
Every shape this cannot read is not one: a link naming a table or a column
|
|
1683
|
+
the schema has not got is `check_consistent`'s to refuse, and answering it
|
|
1684
|
+
here would report a missing table as a join that found nothing.
|
|
1685
|
+
"""
|
|
1686
|
+
if (
|
|
1687
|
+
link.source_table not in schema.tables
|
|
1688
|
+
or link.target_table not in schema.tables
|
|
1689
|
+
):
|
|
1690
|
+
return False
|
|
1691
|
+
child = getattr(schema[link.source_table], 'data', None)
|
|
1692
|
+
parent = getattr(schema[link.target_table], 'data', None)
|
|
1693
|
+
if child is None or parent is None:
|
|
1694
|
+
return False
|
|
1695
|
+
if (
|
|
1696
|
+
link.source_column not in child.columns
|
|
1697
|
+
or link.target_column not in parent.columns
|
|
1698
|
+
):
|
|
1699
|
+
return False
|
|
1700
|
+
values = child[link.source_column].dropna().unique()
|
|
1701
|
+
keys = parent[link.target_column].dropna().unique()
|
|
1702
|
+
if not len(values) or not len(keys):
|
|
1703
|
+
return False
|
|
1704
|
+
return not bool(pd.Series(values).isin(keys).any())
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _warn_of_columns_the_model_does_not_read(
|
|
1708
|
+
schema: Schema, left_out: Mapping[str, Sequence[str]], sent: frozenset[str]
|
|
1709
|
+
) -> None:
|
|
1710
|
+
r"""Say which feature columns reach the model and are dropped there.
|
|
1711
|
+
|
|
1712
|
+
The model's recipe carries no text processor, so a column typed `text` is
|
|
1713
|
+
ignored wherever it reaches it. Refusing the request was considered and
|
|
1714
|
+
rejected: a column the answer never depended on should not be a reason not
|
|
1715
|
+
to answer. That leaves the caller believing a column contributed when it
|
|
1716
|
+
did not, which is worth one line. Measured on one determining token, the
|
|
1717
|
+
same column scores 1.00 typed `categorical` and chance typed `text`.
|
|
1718
|
+
|
|
1719
|
+
Read against the columns the request actually carries. A column the query
|
|
1720
|
+
or the caller already left out is not sent, so naming it would report a
|
|
1721
|
+
loss that did not happen, the target among them.
|
|
1722
|
+
|
|
1723
|
+
`sent` is the same question asked of the table. A table nothing links, or
|
|
1724
|
+
one further out than the hops sampled, is not in the payload either, and
|
|
1725
|
+
naming its columns reported a loss that did not happen. Its remedy, to
|
|
1726
|
+
type it categorical, changes nothing under that hop budget. Why
|
|
1727
|
+
that table is missing is said by `_warn_of_tables_left_out`, which is the
|
|
1728
|
+
line a caller can act on.
|
|
1729
|
+
"""
|
|
1730
|
+
unread = KUMO_RELATIONAL.runtime.unread_feature_stypes
|
|
1731
|
+
if not unread:
|
|
1732
|
+
return
|
|
1733
|
+
ignored = sorted(
|
|
1734
|
+
f'{table_name}.{column_name}'
|
|
1735
|
+
for table_name, table in schema.tables.items()
|
|
1736
|
+
if table_name in sent
|
|
1737
|
+
for column_name, column in table.columns.items()
|
|
1738
|
+
if column.stype in unread
|
|
1739
|
+
and column_name not in left_out.get(table_name, ())
|
|
1740
|
+
)
|
|
1741
|
+
if not ignored:
|
|
1742
|
+
return
|
|
1743
|
+
many = len(ignored) > 1
|
|
1744
|
+
warnings.warn(
|
|
1745
|
+
f'{", ".join(ignored)} {"are" if many else "is"} typed '
|
|
1746
|
+
f'{" or ".join(sorted(unread))}, which this model does not read, so '
|
|
1747
|
+
f'the request is answered without {"them" if many else "it"} and '
|
|
1748
|
+
f'{"they contribute" if many else "it contributes"} nothing to the '
|
|
1749
|
+
f'prediction. Type a column categorical if its values are labels '
|
|
1750
|
+
f'rather than prose, or leave it out to say so.',
|
|
1751
|
+
category=SDMWarning,
|
|
1752
|
+
stacklevel=caller_stacklevel(),
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def _warn_the_filter_chose_only_the_examples(
|
|
1757
|
+
query: ValidatedPredictiveQuery, indices: Sequence[Any]
|
|
1758
|
+
) -> None:
|
|
1759
|
+
r"""Say that an entity filter shapes the context and not the scored rows.
|
|
1760
|
+
|
|
1761
|
+
`FOR EACH sellers.seller_id WHERE sellers.seller_state = 'RJ'` reads as one
|
|
1762
|
+
question and is answered as two. The filter selects the labelled examples
|
|
1763
|
+
the model learns from; the rows actually scored are the caller's indices,
|
|
1764
|
+
untouched by it. Measured on synthetic data, a filter matching 12 of 50
|
|
1765
|
+
named sellers still scored all 50. The model was shown a thousand
|
|
1766
|
+
northern sellers and then asked about thirty-eight southern ones, with
|
|
1767
|
+
nothing said and predictions that look entirely ordinary.
|
|
1768
|
+
|
|
1769
|
+
Reported rather than repaired. Applying the filter here would mean
|
|
1770
|
+
evaluating the condition a second time, against the sampled neighbourhood
|
|
1771
|
+
the engine builds to evaluate it the first time, and two implementations of
|
|
1772
|
+
one condition disagreeing is a worse failure than the one being fixed.
|
|
1773
|
+
Naming the condition is what the caller needs to act, either by narrowing
|
|
1774
|
+
the indices or by dropping the filter.
|
|
1775
|
+
"""
|
|
1776
|
+
if not indices or not isinstance(query.entity_ast, Filter):
|
|
1777
|
+
return
|
|
1778
|
+
warnings.warn(
|
|
1779
|
+
f'the entity filter {str(query.entity_ast.condition)!r} selects the '
|
|
1780
|
+
f'labelled examples the model learns from, and does not narrow the '
|
|
1781
|
+
f'{len(indices)} rows named for scoring. Those are scored as given, '
|
|
1782
|
+
f'whether or not they satisfy it. Pass only indices that satisfy the '
|
|
1783
|
+
f'condition if that is what you meant.',
|
|
1784
|
+
category=SDMWarning,
|
|
1785
|
+
stacklevel=caller_stacklevel(),
|
|
1786
|
+
)
|