Skip to content

Commit cbac09f

Browse files
fix: unify load_dataset X/y convention across dataset families (#530)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2b9aa16 commit cbac09f

7 files changed

Lines changed: 151 additions & 54 deletions

File tree

src/sktime_mcp/runtime/executor.py

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,20 @@ def summarize_available_handles(self, limit: int = 5) -> dict[str, Any]:
193193
"n_available_handles": len(handle_ids),
194194
}
195195

196-
def _resolve_source(self, source: str) -> dict[str, Any]:
197-
"""Resolve a source id to a series, trying data_handle then demo dataset."""
196+
def _resolve_source(self, source: str, prefer: str = "y") -> dict[str, Any]:
197+
"""Resolve a source id to a series, trying data_handle then demo dataset.
198+
199+
``prefer`` selects which component of a demo dataset to return
200+
("y" or "X"); the other is the fallback when the preferred one is
201+
absent. Data handles always resolve to their primary series.
202+
"""
198203
if source in self._data_handles:
199204
return {"success": True, "data": self._data_handles[source]["y"]}
200205
res = self.load_dataset(source)
201206
if res["success"]:
202-
return {"success": True, "data": res["data"]}
207+
first, second = ("X", "y") if prefer == "X" else ("y", "X")
208+
data = res[first] if res[first] is not None else res[second]
209+
return {"success": True, "data": data}
203210
return res
204211

205212
def instantiate(
@@ -277,7 +284,12 @@ def mock_all_estimators(*args, **kwargs):
277284

278285
# L-7: We can also add custom load_dataset functions here
279286
def load_dataset(self, name: str) -> dict[str, Any]:
280-
"""Load a demo dataset."""
287+
"""Load a demo dataset.
288+
289+
Returns canonical keys with one consistent meaning for every
290+
dataset family: ``y`` is always the target/labels, ``X`` is always
291+
the features/panel (or None).
292+
"""
281293
demo_datasets = _get_demo_datasets()
282294
if name not in demo_datasets:
283295
return {
@@ -294,9 +306,8 @@ def load_dataset(self, name: str) -> dict[str, Any]:
294306
data = loader()
295307

296308
if isinstance(data, tuple):
297-
# sktime classifier/clusterer datasets typically return (X, y)
298-
# whereas forecaster datasets typically return (y) or (y, X)
299-
# Let's check the shape/type to be safe, or just hardcode known ones
309+
# sktime classifier/clusterer datasets return (X-panel, y-labels)
310+
# whereas forecaster datasets return (y-target, X-exog)
300311
if name in (
301312
"arrow_head",
302313
"italy_power_demand",
@@ -306,26 +317,21 @@ def load_dataset(self, name: str) -> dict[str, Any]:
306317
"plaid",
307318
):
308319
X, y = data[0], data[1] if len(data) > 1 else None
309-
# swap them back for our internal representation where 'data' is the primary object requested
310-
return {
311-
"success": True,
312-
"name": name,
313-
"data": X,
314-
"exog": y,
315-
"type": str(type(X).__name__),
316-
}
320+
primary = X
317321
else:
318322
y, X = data[0], data[1] if len(data) > 1 else None
323+
primary = y
319324
else:
320325
y, X = data, None
326+
primary = y
321327

322328
return {
323329
"success": True,
324330
"name": name,
325-
"shape": y.shape if hasattr(y, "shape") else len(y),
326-
"type": str(type(y).__name__),
327-
"data": y,
328-
"exog": X,
331+
"shape": primary.shape if hasattr(primary, "shape") else len(primary),
332+
"type": str(type(primary).__name__),
333+
"y": y,
334+
"X": X,
329335
}
330336
except Exception as e:
331337
return {"success": False, "error": str(e)}
@@ -560,19 +566,19 @@ async def predict_async(
560566
data_res = self.load_dataset(X_dataset)
561567
if not data_res["success"]:
562568
raise ValueError(data_res.get("error", "Failed to load dataset"))
563-
X = data_res["data"]
564-
y = data_res.get("exog")
569+
y = data_res["y"]
570+
X = data_res["X"]
565571
else:
566572
if X_dataset:
567573
data_res = self.load_dataset(X_dataset)
568574
if not data_res["success"]:
569575
raise ValueError(data_res.get("error", "Failed to load dataset"))
570-
X = data_res["data"]
576+
X = data_res["X"] if data_res["X"] is not None else data_res["y"]
571577
if y_dataset:
572578
data_res = self.load_dataset(y_dataset)
573579
if not data_res["success"]:
574580
raise ValueError(data_res.get("error", "Failed to load dataset"))
575-
y = data_res["data"]
581+
y = data_res["y"]
576582

577583
fh = list(range(1, horizon + 1))
578584

@@ -656,9 +662,14 @@ def call_method(
656662
if "available" in data_res:
657663
error_res["available"] = data_res["available"]
658664
return error_res
659-
# Replace the kwarg with the actual data (e.g. y_dataset -> y)
665+
# Replace the kwarg with the actual data (e.g. y_dataset -> y);
666+
# the prefix selects the dataset component
660667
actual_key = k.replace("_dataset", "")
661-
kwargs[actual_key] = data_res["data"]
668+
if actual_key == "X":
669+
value = data_res["X"] if data_res["X"] is not None else data_res["y"]
670+
else:
671+
value = data_res["y"]
672+
kwargs[actual_key] = value
662673
del kwargs[k]
663674
elif k.endswith("_data_handle") and isinstance(v, str):
664675
if v in self._data_handles:
@@ -802,23 +813,20 @@ async def fit_async(
802813
data_res = self.load_dataset(X_dataset)
803814
if not data_res["success"]:
804815
raise ValueError(data_res["error"])
805-
if data_res.get("exog") is not None:
806-
X = data_res["data"]
807-
y = data_res["exog"]
808-
else:
809-
y = data_res["data"]
816+
y = data_res["y"]
817+
X = data_res["X"]
810818
else:
811819
if X_dataset:
812820
data_res = self.load_dataset(X_dataset)
813821
if not data_res["success"]:
814822
raise ValueError(data_res["error"])
815-
X = data_res["data"]
823+
X = data_res["X"] if data_res["X"] is not None else data_res["y"]
816824

817825
if y_dataset:
818826
data_res = self.load_dataset(y_dataset)
819827
if not data_res["success"]:
820828
raise ValueError(data_res["error"])
821-
y = data_res["data"]
829+
y = data_res["y"]
822830

823831
# Step 2: Fit model
824832
self._job_manager.update_job(
@@ -901,7 +909,7 @@ async def evaluate_async(
901909

902910
_X = None
903911
if X:
904-
x_res = self._resolve_source(X)
912+
x_res = self._resolve_source(X, prefer="X")
905913
if not x_res["success"]:
906914
raise ValueError(x_res["error"])
907915
_X = x_res["data"]

src/sktime_mcp/tools/evaluate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def evaluate_tool(
8282

8383
_X = None
8484
if X:
85-
x_res = executor._resolve_source(X)
85+
x_res = executor._resolve_source(X, prefer="X")
8686
if not x_res["success"]:
8787
return x_res
8888
_X = x_res["data"]

src/sktime_mcp/tools/fit_predict.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,24 +69,20 @@ def fit_tool(
6969
data_res = executor.load_dataset(X_dataset)
7070
if not data_res["success"]:
7171
return data_res
72-
if data_res.get("exog") is not None:
73-
X = data_res["data"]
74-
y = data_res["exog"]
75-
else:
76-
y = data_res["data"]
77-
X = None
72+
y = data_res["y"]
73+
X = data_res["X"]
7874
else:
7975
if X_dataset:
8076
data_res = executor.load_dataset(X_dataset)
8177
if not data_res["success"]:
8278
return data_res
83-
X = data_res["data"]
79+
X = data_res["X"] if data_res["X"] is not None else data_res["y"]
8480

8581
if y_dataset:
8682
data_res = executor.load_dataset(y_dataset)
8783
if not data_res["success"]:
8884
return data_res
89-
y = data_res["data"]
85+
y = data_res["y"]
9086

9187
if run_async:
9288
import asyncio
@@ -213,20 +209,20 @@ def predict_tool(
213209
data_res = executor.load_dataset(X_dataset)
214210
if not data_res["success"]:
215211
return data_res
216-
X = data_res["data"]
217-
y = data_res.get("exog")
212+
y = data_res["y"]
213+
X = data_res["X"]
218214
else:
219215
if X_dataset:
220216
data_res = executor.load_dataset(X_dataset)
221217
if not data_res["success"]:
222218
return data_res
223-
X = data_res["data"]
219+
X = data_res["X"] if data_res["X"] is not None else data_res["y"]
224220

225221
if y_dataset:
226222
data_res = executor.load_dataset(y_dataset)
227223
if not data_res["success"]:
228224
return data_res
229-
y = data_res["data"]
225+
y = data_res["y"]
230226

231227
fh = list(range(1, horizon + 1))
232228

@@ -279,20 +275,20 @@ def update_tool(
279275
data_res = executor.load_dataset(X_dataset)
280276
if not data_res["success"]:
281277
return data_res
282-
X = data_res["data"]
283-
y = data_res.get("exog")
278+
y = data_res["y"]
279+
X = data_res["X"]
284280
else:
285281
if X_dataset:
286282
data_res = executor.load_dataset(X_dataset)
287283
if not data_res["success"]:
288284
return data_res
289-
X = data_res["data"]
285+
X = data_res["X"] if data_res["X"] is not None else data_res["y"]
290286

291287
if y_dataset:
292288
data_res = executor.load_dataset(y_dataset)
293289
if not data_res["success"]:
294290
return data_res
295-
y = data_res["data"]
291+
y = data_res["y"]
296292

297293
return executor.update(estimator_handle, y=y, X=X)
298294

tests/test_core.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,11 @@ def test_recent_handles_survive_eviction(self):
303303
assert f"data_{5:08x}" in ex._data_handles
304304

305305
def test_format_releases_original_handle(self):
306-
"""format_data_handle releases the source handle after creating the formatted copy."""
306+
"""format_data_handle releases the source only with release_original=True.
307+
308+
The explicit transform_data path preserves the caller's input handle;
309+
only internal auto-format-on-load passes release_original=True.
310+
"""
307311
import pandas as pd
308312

309313
ex = self._make_executor(max_handles=50)
@@ -325,12 +329,13 @@ def test_format_releases_original_handle(self):
325329
auto_infer_freq=True,
326330
fill_missing=False,
327331
remove_duplicates=False,
332+
release_original=True,
328333
)
329334

330335
assert result["success"]
331336
new_id = result["data_handle"]
332337
assert new_id != original_id
333-
# Original must be gone
338+
# Original must be gone (release_original=True is the load-path behavior)
334339
assert original_id not in ex._data_handles
335340
# Formatted handle must exist
336341
assert new_id in ex._data_handles
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""load_dataset must use one X/y convention for every dataset family.
2+
3+
Previously it returned {"data", "exog"} whose meaning flipped between
4+
classification datasets (data=X-panel, exog=y-labels) and forecasting
5+
datasets (data=y-target, exog=X-features). fit/predict/update assumed the
6+
classification convention, so fit(y_dataset="longley", X_dataset="longley")
7+
silently fitted with y and X swapped — the exogenous regressors became the
8+
target.
9+
"""
10+
11+
import pandas as pd
12+
import pytest
13+
14+
from sktime_mcp.runtime.executor import get_executor
15+
from sktime_mcp.tools.fit_predict import fit_tool
16+
from sktime_mcp.tools.instantiate import instantiate_tool
17+
18+
19+
def _release(handle):
20+
import contextlib
21+
22+
from sktime_mcp.runtime.handles import get_handle_manager
23+
24+
with contextlib.suppress(KeyError):
25+
get_handle_manager().release_handle(handle)
26+
27+
28+
class TestLoadDatasetCanonicalKeys:
29+
def test_forecasting_series_dataset(self):
30+
res = get_executor().load_dataset("airline")
31+
assert res["success"]
32+
assert isinstance(res["y"], pd.Series)
33+
assert res["X"] is None
34+
35+
def test_forecasting_dataset_with_exog(self):
36+
res = get_executor().load_dataset("longley")
37+
assert res["success"]
38+
# y is the univariate target (TOTEMP), X the exogenous regressors
39+
assert res["y"].ndim == 1 or res["y"].shape[1] == 1
40+
assert isinstance(res["X"], pd.DataFrame)
41+
assert res["X"].shape[1] == 5
42+
43+
def test_classification_dataset(self):
44+
res = get_executor().load_dataset("arrow_head")
45+
assert res["success"]
46+
# X is the panel DataFrame, y the class labels — one label per instance
47+
assert isinstance(res["X"], pd.DataFrame)
48+
assert res["y"].ndim == 1
49+
assert len(res["X"]) == len(res["y"])
50+
51+
52+
class TestFitResolution:
53+
def test_same_dataset_fit_uses_target_as_y(self):
54+
"""fit(y_dataset="longley", X_dataset="longley") must fit on TOTEMP."""
55+
result = instantiate_tool(spec="NaiveForecaster()")
56+
assert result["success"]
57+
handle = result["handle"]
58+
try:
59+
fit_res = fit_tool(
60+
estimator_handle=handle,
61+
y_dataset="longley",
62+
X_dataset="longley",
63+
)
64+
assert fit_res["success"], fit_res
65+
instance = get_executor()._handle_manager.get_instance(handle)
66+
fitted_y = instance._y
67+
# The buggy path fitted on the 5-column exogenous frame
68+
assert fitted_y.ndim == 1 or fitted_y.shape[1] == 1, (
69+
f"y was swapped with X: fitted on shape {fitted_y.shape}"
70+
)
71+
finally:
72+
_release(handle)
73+
74+
def test_same_dataset_fit_classifier_still_works(self):
75+
"""Classification datasets must keep panel→X, labels→y routing."""
76+
result = instantiate_tool(spec="KNeighborsTimeSeriesClassifier()")
77+
if not result["success"]:
78+
pytest.skip("classifier not available")
79+
handle = result["handle"]
80+
try:
81+
fit_res = fit_tool(
82+
estimator_handle=handle,
83+
y_dataset="arrow_head",
84+
X_dataset="arrow_head",
85+
)
86+
assert fit_res["success"], fit_res
87+
finally:
88+
_release(handle)

tests/test_evaluate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def test_evaluate_with_data_handle():
6868
executor = get_executor()
6969
data_res = executor.load_dataset("airline")
7070
assert data_res["success"]
71-
executor._data_handles["test_dh"] = {"y": data_res["data"]}
71+
executor._data_handles["test_dh"] = {"y": data_res["y"]}
7272
handle = executor._handle_manager.create_handle("NaiveForecaster", NaiveForecaster(), {})
7373

7474
try:

tests/test_predict_async.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def _fitted_handle():
2626
executor = get_executor()
2727
handle = executor._handle_manager.create_handle("NaiveForecaster", NaiveForecaster(), {})
2828
data = executor.load_dataset("airline")
29-
fit_res = executor.fit(handle, y=data["data"], fh=list(range(1, 4)))
29+
fit_res = executor.fit(handle, y=data["y"], fh=list(range(1, 4)))
3030
assert fit_res["success"]
3131
return handle
3232

0 commit comments

Comments
 (0)