Skip to content

Commit d35fa3a

Browse files
authored
better error message when variables are missing from env (#2211)
1 parent 3224744 commit d35fa3a

8 files changed

Lines changed: 193 additions & 39 deletions

File tree

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ New Features
1616

1717
Changes
1818
-------
19+
- The error message when a key is missing from the environment passed to a
20+
:class:`DataOp` or :class:`SkrubLearner` has been improved.
21+
:pr:`2211` by :user:`Jérôme Dockès <jeromedockes>`.
1922

2023
Bugfixes
2124
--------

skrub/_data_ops/_data_ops.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ class UninitializedVariable(KeyError):
150150
Evaluating a DataOp and a value has not been provided for one of the variables.
151151
"""
152152

153+
def __init__(self, name):
154+
self.name = name
155+
self.message = f"No value has been provided for {name!r}"
156+
super().__init__(name)
157+
158+
def __str__(self):
159+
return self.message
160+
153161

154162
def _remove_shell_frames(stack):
155163
"""
@@ -961,17 +969,15 @@ def compute(self, e, mode, environment):
961969
if mode == "preview":
962970
assert not environment
963971
if e.value is NULL:
964-
raise UninitializedVariable(
965-
f"No value has been provided for {e.name!r}"
966-
)
972+
raise UninitializedVariable(e.name)
967973
return e.value
968974
if e.name in environment:
969975
return environment[e.name]
970976
if (
971977
e.becomes_default or environment.get("_skrub_use_var_values", False)
972978
) and e.value is not NULL:
973979
return e.value
974-
raise UninitializedVariable(f"No value has been provided for {e.name!r}")
980+
raise UninitializedVariable(e.name)
975981

976982
def preview_if_available(self):
977983
return self.value

skrub/_data_ops/_estimator.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -925,7 +925,10 @@ def _score(self, X, y=None, cast_to_float=True, return_predictions=False):
925925
return (result, {}) if return_predictions else result
926926
env = self._get_env(X, y)
927927
scorers = evaluate(
928-
score_node._skrub_impl.scorers, mode="fit_transform", environment=env
928+
score_node._skrub_impl.scorers,
929+
mode="fit_transform",
930+
environment=env,
931+
ancestor_data_op=self.data_op,
929932
)
930933
all_scores = []
931934
cache = dict(env.get("_skrub_predictions", {}))
@@ -982,7 +985,13 @@ def _compute_X_y_and_cv(data_op, environment):
982985
# the estimator requests a y so some node must have been
983986
# marked as y
984987
raise ValueError('DataOp should have a node marked with "mark_as_y()"')
985-
values = evaluate(nodes, mode="fit_transform", environment=environment, clear=True)
988+
values = evaluate(
989+
nodes,
990+
mode="fit_transform",
991+
environment=environment,
992+
clear=True,
993+
ancestor_data_op=data_op,
994+
)
986995
if "y" in nodes:
987996
msg = (
988997
"\nAre `.skb.subsample()` and `.skb.mark_as_*()` applied in the same order"

skrub/_data_ops/_evaluation.py

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@
2626
DataOp,
2727
Scoring,
2828
SplitX,
29+
UninitializedVariable,
2930
Value,
3031
Var,
3132
)
32-
from ._utils import NULL, X_NAME, Y_NAME, simple_repr
33+
from ._utils import IS_PREVIEW_DATA_ENV_NAME, NULL, X_NAME, Y_NAME, simple_repr
3334

3435
_BUILTIN_SEQ = (list, tuple, set, frozenset)
3536

@@ -426,15 +427,15 @@ def _check_environment(environment):
426427
)
427428
# Notes about checking the env keys:
428429
#
429-
# - env ⊂ variables: in some cases we could check that there are no extra
430-
# keys in `environment`, ie all keys in `environment` correspond to a
431-
# name in the DataOp. However in other cases we naturally end up
432-
# using a bigger environment than what is needed. For example we want tu
433-
# evaluate a sub-DataOp (such as the `mark_as_X()` node), and to do
434-
# it we use the environment that was passed to evaluate the full
435-
# DataOp. So if we want such a verification it should be a separate
436-
# check done at a higher level (eg in the estimators' `fit`, `predict`
437-
# etc.) where we know we are not working with a sub-DataOp.
430+
# - env ⊂ variables: we could check that there are no extra keys in
431+
# `environment`, i.e. all keys in `environment` correspond to a name in
432+
# the DataOp. However in some cases we naturally end up using a bigger
433+
# environment than what is needed. For example we want to evaluate a
434+
# sub-DataOp (such as the result of `.skb.find()` or `.skb.find_X_y()`),
435+
# and to do it we use the environment created to evaluate the full
436+
# DataOp. We do perform this check when a key is missing from the env to
437+
# provide a better error message, but it is only used for the content of
438+
# the message rather than enforcing no extra keys ahead of time.
438439
#
439440
# - variables ⊂ env: we cannot check that all variables in the DataOp
440441
# have a matching key in the `environment`, because depending on the
@@ -450,7 +451,14 @@ def _check_environment(environment):
450451
# consistent with .skb.eval() in evaluate's params
451452

452453

453-
def evaluate(data_op, mode="preview", environment=None, clear=False, callbacks=()):
454+
def evaluate(
455+
data_op,
456+
mode="preview",
457+
environment=None,
458+
clear=False,
459+
callbacks=(),
460+
ancestor_data_op=None,
461+
):
454462
"""Evaluate a DataOp.
455463
456464
Parameters
@@ -475,6 +483,14 @@ def evaluate(data_op, mode="preview", environment=None, clear=False, callbacks=(
475483
Each will be called, in the provided order, after evaluating each node.
476484
The signature is callback(data_op, result) where data_op is the DataOp
477485
that was just evaluated and result is the resulting value.
486+
487+
ancestor_data_op : DataOp or None
488+
When we are evaluating a part of a DataOp (e.g. the X node only), pass
489+
this so that we can look at all the variable names of the full DataOp
490+
when producing error messages about missing or extra keys in the
491+
environment. It is not used for other purposes than inspecting the
492+
variable an choice names it contains. In particular it is not
493+
evaluated.
478494
"""
479495
_check_environment(environment)
480496
if clear:
@@ -486,11 +502,57 @@ def evaluate(data_op, mode="preview", environment=None, clear=False, callbacks=(
486502
return _Evaluator(mode=mode, environment=environment, callbacks=callbacks).run(
487503
data_op
488504
)
505+
except UninitializedVariable as e:
506+
if (
507+
hasattr(e, "add_note")
508+
and environment is not None
509+
and not environment.get(IS_PREVIEW_DATA_ENV_NAME)
510+
):
511+
# user passed an explicit environment rather than using the
512+
# variables' preview values.
513+
e.add_note(
514+
_uninitialized_variable_msg(
515+
e,
516+
ancestor_data_op if ancestor_data_op is not None else data_op,
517+
environment,
518+
)
519+
)
520+
raise
489521
finally:
490522
if clear:
491523
clear_results(data_op, mode=mode)
492524

493525

526+
def _uninitialized_variable_msg(error, data_op, environment):
527+
missing_name = error.name
528+
var_names = list(named_nodes(data_op).keys())
529+
choice_names = [n for c in choices(data_op).values() if (n := c.name) is not None]
530+
unused = list(
531+
{
532+
k
533+
for k in environment.keys()
534+
if isinstance(k, str) and not k.startswith("_skrub_")
535+
}.difference(var_names + choice_names)
536+
)
537+
538+
msg = (
539+
"- Note that preview values passed to initialize skrub variables\n"
540+
" are ignored by default whenever we pass "
541+
"an explicit 'environment' dictionary,\n"
542+
" for example when calling SkrubLearner.fit({'X': ..., 'y': ...}).\n"
543+
f" Please pass a value for {missing_name!r} in the environment.\n"
544+
" You can also use "
545+
f"skrub.var({missing_name!r}, value=..., becomes_default=True)\n"
546+
" to always retain the initialization value as a default."
547+
)
548+
if unused:
549+
msg += (
550+
"\n- WARNING: the following keys were passed in the environment but "
551+
f"have no corresponding variable in the DataOp:\n {unused}"
552+
)
553+
return msg
554+
555+
494556
def _cache_pruner(data_op, mode):
495557
g = graph(data_op)
496558
ref_counts = {node_id: len(parents) for node_id, parents in g["parents"].items()}
@@ -675,6 +737,12 @@ def nodes(data_op):
675737
return list(graph(data_op)["nodes"].values())
676738

677739

740+
def named_nodes(data_op):
741+
return {
742+
name: op for op in nodes(data_op) if (name := op._skrub_impl.name) is not None
743+
}
744+
745+
678746
def clear_results(data_op, mode=None):
679747
for n in nodes(data_op):
680748
if mode is None:

skrub/_data_ops/_skrub_namespace.py

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
find_node_by_name,
4343
find_node_by_uuid,
4444
find_X_y_and_cv,
45+
named_nodes,
4546
nodes,
4647
)
4748
from ._inspection import (
@@ -51,7 +52,7 @@
5152
)
5253
from ._optuna import OptunaParamSearch
5354
from ._subsampling import SubsamplePreviews, env_with_subsampling
54-
from ._utils import NULL, attribute_error
55+
from ._utils import IS_PREVIEW_DATA_ENV_NAME, NULL, attribute_error
5556

5657
# By default, select all columns
5758
_SELECT_ALL_COLUMNS = s.all()
@@ -143,6 +144,11 @@ class SkrubNamespace:
143144
def __init__(self, data_op):
144145
self._data_op = data_op
145146

147+
def _get_env(self, environment=None):
148+
if environment is not None:
149+
return environment
150+
return self.get_data() | {IS_PREVIEW_DATA_ENV_NAME: True}
151+
146152
def _apply(
147153
self,
148154
estimator,
@@ -1121,7 +1127,7 @@ def eval(self, environment=None, *, keep_subsampling=False):
11211127
f"got: '{type(environment)}'"
11221128
)
11231129
if environment is None:
1124-
environment = self.get_data()
1130+
environment = self._get_env()
11251131
else:
11261132
environment = {
11271133
**environment,
@@ -1482,18 +1488,13 @@ def get_vars(self, all_named_ops=False):
14821488
SkrubLearner(data_op=<BinOp: add>)
14831489
"""
14841490
from ._data_ops import Var
1485-
from ._evaluation import nodes
14861491

1487-
named_nodes = {
1488-
name: op
1489-
for op in nodes(self._data_op)
1490-
if (name := op._skrub_impl.name) is not None
1491-
}
1492+
found_nodes = named_nodes(self._data_op)
14921493
if all_named_ops:
1493-
return named_nodes
1494+
return found_nodes
14941495
return {
14951496
name: op
1496-
for name, op in named_nodes.items()
1497+
for name, op in found_nodes.items()
14971498
if isinstance(op._skrub_impl, Var)
14981499
}
14991500

@@ -1981,7 +1982,7 @@ def make_learner(self, *, fitted=False, keep_subsampling=False, choose="default"
19811982
if not fitted:
19821983
return learner
19831984
return learner.fit(
1984-
env_with_subsampling(self._data_op, self.get_data(), keep_subsampling)
1985+
env_with_subsampling(self._data_op, self._get_env(), keep_subsampling)
19851986
)
19861987

19871988
@_check_before
@@ -2112,8 +2113,7 @@ def train_test_split(
21122113
category=FutureWarning,
21132114
)
21142115
split_func = splitter
2115-
if environment is None:
2116-
environment = self.get_data()
2116+
environment = self._get_env(environment)
21172117
return train_test_split(
21182118
self._data_op,
21192119
environment,
@@ -2186,8 +2186,7 @@ def iter_cv_splits(self, environment=None, *, keep_subsampling=False, cv=None):
21862186
>>> accuracies
21872187
[1.0, 0.0, 1.0]
21882188
"""
2189-
if environment is None:
2190-
environment = self.get_data()
2189+
environment = self._get_env(environment)
21912190
yield from iter_cv_splits(
21922191
self._data_op, environment, keep_subsampling=keep_subsampling, cv=cv
21932192
)
@@ -2301,7 +2300,7 @@ def make_grid_search(self, *, fitted=False, keep_subsampling=False, **kwargs):
23012300
if not fitted:
23022301
return search
23032302
return search.fit(
2304-
env_with_subsampling(self._data_op, self.get_data(), keep_subsampling)
2303+
env_with_subsampling(self._data_op, self._get_env(), keep_subsampling)
23052304
)
23062305

23072306
@_check_before
@@ -2588,7 +2587,7 @@ def make_randomized_search(
25882587
if not fitted:
25892588
return search
25902589
return search.fit(
2591-
env_with_subsampling(self._data_op, self.get_data(), keep_subsampling)
2590+
env_with_subsampling(self._data_op, self._get_env(), keep_subsampling)
25922591
)
25932592

25942593
@_check_before
@@ -2809,8 +2808,7 @@ def cross_validate(self, environment=None, *, keep_subsampling=False, **kwargs):
28092808
4 0.90
28102809
Name: test_score, dtype: float64
28112810
"""
2812-
if environment is None:
2813-
environment = self.get_data()
2811+
environment = self._get_env(environment)
28142812

28152813
return cross_validate(
28162814
self.make_learner(),

skrub/_data_ops/_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
FITTED_ESTIMATOR_METHODS = FITTED_PREDICTOR_METHODS + ("transform",)
1313
X_NAME = "_skrub_X"
1414
Y_NAME = "_skrub_y"
15+
IS_PREVIEW_DATA_ENV_NAME = "_skrub_is_preview_data_env"
1516

1617

1718
class Sentinels(enum.Enum):

0 commit comments

Comments
 (0)