Skip to content

Commit 789b9ae

Browse files
committed
_
1 parent e52e7ff commit 789b9ae

3 files changed

Lines changed: 148 additions & 43 deletions

File tree

examples/02_data_ops/1130_choices.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,20 @@
269269
# performs better than the ``RidgeClassifier`` in most cases, that the ``StringEncoder``
270270
# outperforms the ``MinHashEncoder``, and that the choice of the additional ``length``
271271
# feature does not have a significant impact on the score.
272+
#
273+
# When we have many choices in our DataOp, or when we add many scorers with
274+
# :meth:`.skb.with_scoring() <DataOp.skb.with_scoring>` (not shown in this
275+
# example), the parallel coordinate plot can get very crowded or even
276+
# unreadable. To avoid this, :meth:`ParamSearch.plot_results` (and
277+
# :meth:`OptunaParamSearch.plot_results`) allow us to filter the scores and
278+
# parameters to include in the plot, and whether to include fitting and scoring
279+
# durations or not.
280+
#
281+
# For example if we wanted to include only the encoder and classifier and hide
282+
# the times:
283+
284+
# %%
285+
search.plot_results(show_choices=["encoder", "classifier"], show_times=False)
272286

273287
# %%
274288
# In this example, we've seen how to use skrub's ``choose_from`` objects to tune

skrub/_data_ops/_estimator.py

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,6 +1303,20 @@ def detailed_results_(self):
13031303
except NotFittedError:
13041304
attribute_error(self, "results_")
13051305

1306+
def _get_score_names(self):
1307+
return [
1308+
k.removeprefix("mean_test_")
1309+
for k in self.cv_results_.keys()
1310+
if k.startswith("mean_test_")
1311+
]
1312+
1313+
def _get_choice_names(self):
1314+
return [
1315+
n
1316+
for c in choice_graph(self.data_op)["choices"].values()
1317+
if (n := c.name) is not None
1318+
]
1319+
13061320
def _get_cv_results_table(self, detailed=False):
13071321
check_is_fitted(self, "cv_results_")
13081322
data_op_choices = choice_graph(self.data_op)
@@ -1318,30 +1332,20 @@ def _get_cv_results_table(self, detailed=False):
13181332
columns_metadata = []
13191333
for c_id, display_name in data_op_choices["choice_display_names"].items():
13201334
c = data_op_choices["choices"][c_id]
1321-
columns_metadata.append(
1322-
{
1323-
"type": "choice",
1324-
"id": c_id,
1325-
"name": c.name,
1326-
"display_name": display_name,
1327-
}
1328-
)
1329-
metric_names = [
1330-
k.removeprefix("mean_test_")
1331-
for k in self.cv_results_.keys()
1332-
if k.startswith("mean_test_")
1333-
]
1335+
columns_metadata.append({"type": "choice", "name": c.name})
1336+
score_names = self._get_score_names()
1337+
13341338
if isinstance(self.refit_, str):
1335-
metric_names.insert(0, metric_names.pop(metric_names.index(self.refit_)))
1339+
score_names.insert(0, score_names.pop(score_names.index(self.refit_)))
13361340
result_keys = [
1337-
*(f"mean_test_{n}" for n in metric_names),
1338-
*(f"std_test_{n}" for n in metric_names),
1341+
*(f"mean_test_{n}" for n in score_names),
1342+
*(f"std_test_{n}" for n in score_names),
13391343
"mean_fit_time",
13401344
"std_fit_time",
13411345
"mean_score_time",
13421346
"std_score_time",
1343-
*(f"mean_train_{n}" for n in metric_names),
1344-
*(f"std_train_{n}" for n in metric_names),
1347+
*(f"mean_train_{n}" for n in score_names),
1348+
*(f"std_train_{n}" for n in score_names),
13451349
]
13461350
new_names = _join_utils.pick_column_names(table.columns, result_keys)
13471351
renaming = dict(zip(table.columns, new_names))
@@ -1351,14 +1355,14 @@ def _get_cv_results_table(self, detailed=False):
13511355
renaming[c] for c in metadata["log_scale_columns"]
13521356
]
13531357
if detailed:
1354-
for k in result_keys[len(metric_names) :][::-1]:
1358+
for k in result_keys[len(score_names) :][::-1]:
13551359
if k in self.cv_results_:
13561360
table.insert(table.shape[1], k, self.cv_results_[k])
13571361
columns_metadata.append({"type": "score", "name": k})
1358-
for k in result_keys[: len(metric_names)][::-1]:
1362+
for k in result_keys[: len(score_names)][::-1]:
13591363
table.insert(table.shape[1], k, self.cv_results_[k])
13601364
columns_metadata.append({"type": "score", "name": k})
1361-
metadata["col_score"] = f"mean_test_{metric_names[0]}"
1365+
metadata["col_score"] = f"mean_test_{score_names[0]}"
13621366
metadata["columns_metadata"] = dict(zip(table.columns, columns_metadata))
13631367
table = table.sort_values(
13641368
metadata["col_score"],
@@ -1394,11 +1398,13 @@ def plot_results(
13941398
use of the colorscale's range.
13951399
13961400
show_scores : list of str, optional
1397-
List of score names to show. By default all are shown.
1401+
List of score names to show
1402+
(e.g. "accuracy" in ``.skb.with_scoring(["roc_auc", "accuracy"])``).
1403+
By default all are shown.
13981404
13991405
show_choices : list of str, optional
1400-
List of choice names (e.g. "alpha" in
1401-
``choose_float(0.0, 1.0, name="alpha")``) to show.
1406+
List of choice names to show
1407+
(e.g. "alpha" in ``choose_float(0.0, 1.0, name="alpha")``).
14021408
By default all are shown.
14031409
14041410
show_time : bool, optional, default=True
@@ -1408,11 +1414,38 @@ def plot_results(
14081414
-------
14091415
Plotly Figure
14101416
"""
1411-
cv_results, metadata = self._get_cv_results_table(detailed=True)
1417+
1418+
# Check that requested show_scores and show_choices (if any) are available
1419+
14121420
if isinstance(show_scores, str):
14131421
show_scores = [show_scores]
1422+
if show_scores is not None:
1423+
available_scores = self._get_score_names()
1424+
missing_scores = set(show_scores).difference(available_scores)
1425+
if missing_scores:
1426+
raise ValueError(
1427+
"The following scores were requested in show_scores "
1428+
f"but do not exist in the results:\n{missing_scores}.\n"
1429+
f"The available scores are:\n{available_scores}."
1430+
)
14141431
if isinstance(show_choices, str):
14151432
show_choices = [show_choices]
1433+
if show_choices is not None:
1434+
available_choices = self._get_choice_names()
1435+
missing_choices = set(show_choices).difference(available_choices)
1436+
if missing_choices:
1437+
raise ValueError(
1438+
"The following choices (params) were requested in show_choices "
1439+
f"but do not exist in the results:\n{missing_choices}.\n"
1440+
f"The available choices are: {available_choices}."
1441+
)
1442+
1443+
# Prepare the figure data
1444+
1445+
cv_results, metadata = self._get_cv_results_table(detailed=True)
1446+
1447+
# Find columns to show based on show_scores, show_choices and show_time
1448+
14161449
to_drop = set()
14171450
for col_name, col_meta in metadata["columns_metadata"].items():
14181451
if col_meta["type"] == "score":
@@ -1425,21 +1458,23 @@ def plot_results(
14251458
and col_meta["name"].removeprefix("mean_test_") not in show_scores
14261459
):
14271460
to_drop.add(col_name)
1428-
if (
1429-
show_choices is not None
1430-
and col_meta["type"] == "choice"
1431-
and col_meta["name"] not in show_choices
1432-
):
1433-
to_drop.add(col_name)
1461+
if col_meta["type"] == "choice":
1462+
if show_choices is not None and col_meta["name"] not in show_choices:
1463+
to_drop.add(col_name)
14341464
time_cols = {"mean_fit_time", "mean_score_time"}
14351465
to_drop = (to_drop - time_cols) if show_time else (to_drop | time_cols)
14361466
to_show = set(cv_results.columns) - to_drop
14371467

1468+
# Find rows to show based on min_score
1469+
14381470
if min_score is not None:
14391471
col_score = metadata["col_score"]
14401472
cv_results = cv_results[cv_results[col_score] >= min_score]
14411473
if not cv_results.shape[0]:
14421474
raise ValueError("No results to plot")
1475+
1476+
# Make the figure
1477+
14431478
return plot_parallel_coord(
14441479
cv_results=cv_results,
14451480
show_columns=to_show,

skrub/_data_ops/tests/test_parallel_coord.py

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ def test_no_plotly():
3535
search.plot_results()
3636

3737

38-
def test_parallel_coord():
38+
@pytest.mark.parametrize("specify_show_scores", [False, True])
39+
def test_parallel_coord(specify_show_scores):
3940
X_a, y_a = make_classification(n_samples=20, n_features=4, n_informative=2)
4041
c0 = skrub.choose_from({"a": 0, "b": 1}, name="c0")
4142
c1 = skrub.choose_from([0, 1], name="c1")
@@ -56,7 +57,7 @@ def test_parallel_coord():
5657

5758
pytest.importorskip("plotly")
5859

59-
fig = search.plot_results()
60+
fig = search.plot_results(show_scores=["score"] if specify_show_scores else None)
6061
data = iter(fig.data[0]["dimensions"])
6162
dim = next(data)
6263
assert dim["label"] == "c0"
@@ -153,7 +154,8 @@ def test_column_preparation(is_log_scale, is_int):
153154
assert np.all(jittered["values"] == 1.0)
154155

155156

156-
def test_multi_scoring():
157+
@pytest.fixture(scope="module")
158+
def classif_grid_search():
157159
pytest.importorskip("plotly")
158160

159161
X, y = make_classification()
@@ -162,19 +164,73 @@ def test_multi_scoring():
162164
X, y = skrub.X(X), skrub.y(y)
163165

164166
cols = skrub.choose_from([["0"], ["1"]], name="cols")
165-
pred = X[cols].skb.apply(DummyClassifier(), y=y)
167+
add = skrub.choose_float(0.0, 1.0, name="add", n_steps=3)
168+
mul = skrub.choose_float(1.0, 2.0, n_steps=3) # no name
169+
pred = ((X[cols] + add) * mul).skb.apply(DummyClassifier(), y=y)
166170
search = pred.skb.make_grid_search(
167171
fitted=True,
168172
scoring=["accuracy", "neg_brier_score"],
169173
refit="accuracy",
170174
)
171-
fig = search.plot_results()
175+
return search
172176

177+
178+
@pytest.mark.parametrize(
179+
"show_scores, show_choices, show_time, expected",
180+
[
181+
(
182+
None,
183+
None,
184+
True,
185+
[
186+
"cols",
187+
"add",
188+
"choose_float(1.0,<br>\n2.0, n_steps=3)",
189+
"score time",
190+
"fit time",
191+
"mean_test_neg_brier_<br>\nscore",
192+
"mean_test_accuracy",
193+
],
194+
),
195+
(
196+
"accuracy",
197+
["cols", "add"],
198+
False,
199+
[
200+
"cols",
201+
"add",
202+
"mean_test_accuracy",
203+
],
204+
),
205+
(
206+
[],
207+
"add",
208+
True,
209+
[
210+
"add",
211+
"score time",
212+
"fit time",
213+
],
214+
),
215+
],
216+
)
217+
def test_multi_scoring_and_filtering(
218+
classif_grid_search, show_scores, show_choices, show_time, expected
219+
):
220+
fig = classif_grid_search.plot_results(
221+
show_scores=show_scores, show_choices=show_choices, show_time=show_time
222+
)
173223
dimensions = fig.data[0]["dimensions"]
174-
assert [d["label"].replace("<br>\n", "") for d in dimensions] == [
175-
"cols",
176-
"score time",
177-
"fit time",
178-
"mean_test_neg_brier_score",
179-
"mean_test_accuracy",
180-
]
224+
assert [d["label"] for d in dimensions] == expected
225+
226+
227+
def test_bad_filtering_params(classif_grid_search):
228+
# Ask for a score that is not available, available scores are shown
229+
with pytest.raises(ValueError, match="['accuracy', 'neg_brier_score']"):
230+
classif_grid_search.plot_results(show_scores=["roc_auc"])
231+
# Only choices with an actual name can be selected. Here we ask for a
232+
# choice name that does not exist, available ones are shown.
233+
with pytest.raises(ValueError, match="['cols', 'add']"):
234+
classif_grid_search.plot_results(
235+
show_choices=["choose_float(1.0, 2.0, n_steps=3)"]
236+
)

0 commit comments

Comments
 (0)