From 39ab95dcbcb6c4ad12515e406a8079325fac354c Mon Sep 17 00:00:00 2001 From: Irozuku Date: Wed, 2 Sep 2026 15:12:31 -0400 Subject: [PATCH 1/7] feat: describe which splits of a run can be predicted --- .../back/api/api_v1/endpoints/explainers.py | 15 +- .../evaluation/base_evaluation_strategy.py | 9 +- DashAI/back/splitters/splits_payload.py | 215 +++++++++++++++++- DashAI/back/tasks/base_task.py | 2 + DashAI/back/tasks/forecasting_task.py | 2 + .../back/splitters/test_predictable_splits.py | 142 ++++++++++++ 6 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 tests/back/splitters/test_predictable_splits.py diff --git a/DashAI/back/api/api_v1/endpoints/explainers.py b/DashAI/back/api/api_v1/endpoints/explainers.py index d71d7ebde..06f25620f 100644 --- a/DashAI/back/api/api_v1/endpoints/explainers.py +++ b/DashAI/back/api/api_v1/endpoints/explainers.py @@ -28,7 +28,7 @@ ModelSession, Run, ) -from DashAI.back.splitters.splits_payload import splitter_class_for +from DashAI.back.splitters.splits_payload import run_splits if TYPE_CHECKING: from sqlalchemy.orm import sessionmaker @@ -1107,8 +1107,6 @@ async def get_explainable_splits( HTTPException If the run does not exist in the database. """ - import json - with session_factory() as db: try: run: Run = db.get(Run, run_id) @@ -1123,10 +1121,8 @@ async def get_explainable_splits( status_code=status.HTTP_404_NOT_FOUND, detail="Model session not found", ) - split_indexes = json.loads(run.split_indexes) if run.split_indexes else {} + split_indexes = run.split_indexes session_splits = model_session.splits - if isinstance(session_splits, str): - session_splits = json.loads(session_splits) except exc.SQLAlchemyError as e: log.exception(e) raise HTTPException( @@ -1135,14 +1131,12 @@ async def get_explainable_splits( ) from e try: - splitter_class = splitter_class_for(session_splits, component_registry) + return {"splits": run_splits(session_splits, split_indexes, component_registry)} except ValueError as e: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) ) from e - return {"splits": splitter_class.explainable_splits(split_indexes)} - @router.post("/local/valid-datasets") @inject @@ -1167,9 +1161,6 @@ async def valid_datasets( dict ``{"valid_dataset_ids": [...]}`` with the ids of the valid datasets. """ - # get_columns_spec reads only the Arrow schema metadata (column names + - # types), never the rows, so validating every dataset stays cheap even with - # many/large (e.g. image) datasets on the platform. from DashAI.back.dataloaders.classes.dashai_dataset import get_columns_spec with session_factory() as db: diff --git a/DashAI/back/evaluation/base_evaluation_strategy.py b/DashAI/back/evaluation/base_evaluation_strategy.py index 5f911cdea..0e23ff3ef 100644 --- a/DashAI/back/evaluation/base_evaluation_strategy.py +++ b/DashAI/back/evaluation/base_evaluation_strategy.py @@ -23,16 +23,11 @@ class BaseEvaluationStrategy(metaclass=ABCMeta): TYPE: Final[str] = "EvaluationStrategy" - # How this strategy divides the dataset. The frontend renders holdout - # controls or fold controls from this rather than comparing class names, - # which is what previously made a new strategy unreachable from the UI. KIND: str = "holdout" - - # Which partitions this strategy records metrics for. Scoring the training - # partition means predicting on rows the model was fitted on, which is a - # fit statistic; a forecaster has no such thing to report. SCORED_SPLITS: tuple = (SplitEnum.TRAIN, SplitEnum.VALIDATION, SplitEnum.TEST) + FINAL_FIT_PARTITIONS: tuple = ("train",) + @classmethod def get_metadata(cls) -> dict: """Describe the strategy for the frontend. diff --git a/DashAI/back/splitters/splits_payload.py b/DashAI/back/splitters/splits_payload.py index ef7d68d06..39322f9c1 100644 --- a/DashAI/back/splitters/splits_payload.py +++ b/DashAI/back/splitters/splits_payload.py @@ -7,7 +7,8 @@ different set of keys, so every reader normalizes the payload first. """ -from typing import Any, Dict, List, Tuple, Type +import json +from typing import Any, Dict, List, Optional, Tuple, Type # Before fold splitters existed the payload named no splitter at all: every # split was a holdout split. @@ -177,3 +178,215 @@ def explainable_indexes( evaluation = unseen.get("test") or next(iter(unseen.values())) return partitions.get(training, []), evaluation, partitions.get("val", []) + + +def _parse_payload(payload: Any) -> Dict[str, Any]: + """Return a splits payload as a dictionary, decoding it when stored as text. + + Parameters + ---------- + payload : Any + A ``ModelSession.splits`` or ``Run.split_indexes`` value, which the + database may hold either as a dict or as its JSON encoding. + + Returns + ------- + dict + The decoded payload, or an empty dictionary when there is none. + """ + if isinstance(payload, str): + return json.loads(payload) if payload else {} + return payload or {} + + +def run_splits( + session_splits: Any, split_indexes: Any, component_registry +) -> List[Dict[str, Any]]: + """Describe the dataset partitions of a run that a later job may target. + + Which partitions exist depends on how the run was evaluated, so the + splitter that produced it decides the list and its names. Both explaining + and predicting on the training dataset offer the same set: the partitions + the saved model was measured on, plus the whole dataset. + + Parameters + ---------- + session_splits : Any + The ``ModelSession.splits`` payload, as a dict or its JSON encoding. + split_indexes : Any + The ``Run.split_indexes`` payload, as a dict or its JSON encoding. + component_registry : ComponentRegistry + Registry used to resolve the splitter by name. + + Returns + ------- + list[dict] + One ``{"name", "rows"}`` entry per partition, empty when the run has no + partition worth offering. + + Raises + ------ + ValueError + If the payload names a splitter that is not registered. + """ + splitter_class = splitter_class_for( + _parse_payload(session_splits), component_registry + ) + return splitter_class.explainable_splits(_parse_payload(split_indexes)) + + +def run_split_indexes( + session_splits: Any, split_indexes: Any, component_registry, split: str +) -> Optional[List[int]]: + """Resolve the rows one named partition of a run holds. + + Parameters + ---------- + session_splits : Any + The ``ModelSession.splits`` payload, as a dict or its JSON encoding. + split_indexes : Any + The ``Run.split_indexes`` payload, as a dict or its JSON encoding. + component_registry : ComponentRegistry + Registry used to resolve the splitter by name. + split : str + Name of the partition, as reported by :func:`run_splits`. ``"all"`` + stands for the whole dataset. + + Returns + ------- + list[int] or None + The row indexes of the partition, or None when ``split`` covers the + whole dataset and no selection is needed. + + Raises + ------ + ValueError + If the splitter is not registered, or if it declares no partition by + that name. + """ + if not split or split == "all": + return None + + splitter_class = splitter_class_for( + _parse_payload(session_splits), component_registry + ) + try: + partitions = splitter_class.explainable_partitions( + _parse_payload(split_indexes) + ) + except (KeyError, NotImplementedError, TypeError) as e: + raise ValueError( + "The run's split indexes do not match the splitter that produced it." + ) from e + + if split not in partitions: + raise ValueError(f"{split} is not a partition of this run.") + return list(partitions[split]) + + +def predictable_splits( + session_splits: Any, + split_indexes: Any, + component_registry, + *, + task_name: str, + evaluation_strategy: str, +) -> List[Dict[str, Any]]: + """Describe the partitions of a run its saved model can be asked to predict. + + For most tasks that is every partition the run has, plus the whole dataset: + a fitted classifier will label the rows it was trained on. A model of a + task that predicts forward only cannot. It reads a date and answers how far + past the end of training it lies, so every partition inside the window the + kept model was fitted through is dropped, and so is the whole dataset entry + that contains them. + + Which rows those are comes from the evaluation strategy, since strategies + differ on what the kept model is allowed to learn from: a holdout run fits + on its training partition, while the folds of a rolling origin run walk + through everything outside the reserved tail. The comparison is made on row + indexes, which run in time order for every splitter a forward-only task can + use. + + Parameters + ---------- + session_splits : Any + The ``ModelSession.splits`` payload, as a dict or its JSON encoding. + split_indexes : Any + The ``Run.split_indexes`` payload, as a dict or its JSON encoding. + component_registry : ComponentRegistry + Registry used to resolve the splitter, task and strategy by name. + task_name : str + The ``ModelSession.task_name`` of the run. + evaluation_strategy : str + The ``ModelSession.evaluation_strategy`` of the run. + + Returns + ------- + list[dict] + One ``{"name", "rows"}`` entry per partition that can be predicted, + empty when there is none. + + Raises + ------ + ValueError + If the payload names a splitter that is not registered. + """ + splits = run_splits(session_splits, split_indexes, component_registry) + + task_class = _registered_class(component_registry, task_name) + if not getattr(task_class, "PREDICTS_FORWARD_ONLY", False): + return splits + + strategy_class = _registered_class(component_registry, evaluation_strategy) + fitted_partitions = getattr(strategy_class, "FINAL_FIT_PARTITIONS", ("train",)) + + splitter_class = splitter_class_for( + _parse_payload(session_splits), component_registry + ) + try: + partitions = splitter_class.explainable_partitions( + _parse_payload(split_indexes) + ) + except (KeyError, NotImplementedError, TypeError): + return [] + + fitted = [ + index for name in fitted_partitions for index in partitions.get(name) or [] + ] + if not fitted: + return [] + + last_fitted = max(fitted) + return [ + {"name": name, "rows": len(indexes)} + for name, indexes in partitions.items() + if indexes and min(indexes) > last_fitted + ] + + +def _registered_class(component_registry, name: str) -> Optional[Type]: + """Return a registered component class, or None when it is not installed. + + A run whose task or strategy came from a plugin that has since been removed + still has to describe itself as well as it can, so a missing name is not an + error here. + + Parameters + ---------- + component_registry : ComponentRegistry + Registry used to resolve the component by name. + name : str + Name of the component to resolve. + + Returns + ------- + type or None + The registered class, or None when the registry does not have it. + """ + try: + if name not in component_registry: + return None + return component_registry[name]["class"] + except (KeyError, TypeError): + return None diff --git a/DashAI/back/tasks/base_task.py b/DashAI/back/tasks/base_task.py index 45f9b511b..357ba00b3 100644 --- a/DashAI/back/tasks/base_task.py +++ b/DashAI/back/tasks/base_task.py @@ -16,6 +16,8 @@ class BaseTask: TYPE: Final[str] = "Task" + PREDICTS_FORWARD_ONLY: bool = False + @property @abstractmethod def schema(self) -> Dict[str, Any]: diff --git a/DashAI/back/tasks/forecasting_task.py b/DashAI/back/tasks/forecasting_task.py index 74e847d66..245d49cf5 100644 --- a/DashAI/back/tasks/forecasting_task.py +++ b/DashAI/back/tasks/forecasting_task.py @@ -29,6 +29,8 @@ class ForecastingTask(BaseTask): directly and cannot be expressed that way. """ + PREDICTS_FORWARD_ONLY: bool = True + DESCRIPTION: str = MultilingualString( en=( "Predict the future values of a time series from its own history. " diff --git a/tests/back/splitters/test_predictable_splits.py b/tests/back/splitters/test_predictable_splits.py new file mode 100644 index 000000000..097a5c27d --- /dev/null +++ b/tests/back/splitters/test_predictable_splits.py @@ -0,0 +1,142 @@ +"""Which partitions of a run its saved model can actually be asked to predict. + +For most tasks that is every partition the run has: a fitted classifier will +happily label the rows it was trained on. A forecaster cannot. It answers "how +far past the end of training is this date", so any row inside the window it was +fitted through has no forecast to give, only a fit. Which rows those are +depends on the evaluation strategy: a holdout run fits on the training +partition and can forecast everything after it, while the folds of a rolling +origin run walk through everything outside the reserved tail. +""" + +from DashAI.back.evaluation.forecasting_cv import ( + ForecastingCrossValidationEvaluationStrategy, +) +from DashAI.back.evaluation.forecasting_holdout import ( + ForecastingHoldoutEvaluationStrategy, +) +from DashAI.back.evaluation.holdout import HoldoutEvaluationStrategy +from DashAI.back.splitters.holdout import HoldoutSplitter +from DashAI.back.splitters.rolling_origin import RollingOriginSplitter +from DashAI.back.splitters.splits_payload import predictable_splits +from DashAI.back.splitters.temporal_holdout import TemporalHoldoutSplitter +from DashAI.back.tasks.forecasting_task import ForecastingTask +from DashAI.back.tasks.tabular_classification_task import TabularClassificationTask + +TEMPORAL_RUN = { + "train_indexes": [0, 1, 2, 3, 4, 5], + "val_indexes": [6, 7], + "test_indexes": [8, 9], +} + +ROLLING_RUN = { + "fold_0": {"train_indexes": [0, 1, 2, 3], "test_indexes": [4, 5]}, + "full_dataset": { + "train_indexes": [0, 1, 2, 3, 4, 5, 6, 7], + "test_indexes": [8, 9], + }, +} + +SHUFFLED_RUN = { + "train_indexes": [0, 1, 2, 3, 4, 5], + "val_indexes": [6, 7], + "test_indexes": [8, 9], +} + + +def registry_for(splitter, task, strategy): + """Build the smallest mapping the resolver needs.""" + return { + splitter.__name__: {"class": splitter}, + task.__name__: {"class": task}, + strategy.__name__: {"class": strategy}, + } + + +def test_forecasting_holdout_offers_every_partition_after_training(): + registry = registry_for( + TemporalHoldoutSplitter, + ForecastingTask, + ForecastingHoldoutEvaluationStrategy, + ) + + assert predictable_splits( + {"splitter_name": "TemporalHoldoutSplitter"}, + TEMPORAL_RUN, + registry, + task_name="ForecastingTask", + evaluation_strategy="ForecastingHoldoutEvaluationStrategy", + ) == [{"name": "test", "rows": 2}, {"name": "val", "rows": 2}] + + +def test_forecasting_holdout_without_rows_after_training_offers_nothing(): + registry = registry_for( + TemporalHoldoutSplitter, + ForecastingTask, + ForecastingHoldoutEvaluationStrategy, + ) + + assert ( + predictable_splits( + {"splitter_name": "TemporalHoldoutSplitter"}, + {"train_indexes": [0, 1, 2, 3], "val_indexes": [], "test_indexes": []}, + registry, + task_name="ForecastingTask", + evaluation_strategy="ForecastingHoldoutEvaluationStrategy", + ) + == [] + ) + + +def test_rolling_origin_offers_only_the_reserved_tail(): + registry = registry_for( + RollingOriginSplitter, + ForecastingTask, + ForecastingCrossValidationEvaluationStrategy, + ) + + assert predictable_splits( + {"splitter_name": "RollingOriginSplitter"}, + ROLLING_RUN, + registry, + task_name="ForecastingTask", + evaluation_strategy="ForecastingCrossValidationEvaluationStrategy", + ) == [{"name": "test", "rows": 2}] + + +def test_an_ordinary_task_keeps_every_partition_and_the_whole_dataset(): + registry = registry_for( + HoldoutSplitter, + TabularClassificationTask, + HoldoutEvaluationStrategy, + ) + + assert predictable_splits( + {"splitter_name": "HoldoutSplitter"}, + SHUFFLED_RUN, + registry, + task_name="TabularClassificationTask", + evaluation_strategy="HoldoutEvaluationStrategy", + ) == [ + {"name": "train", "rows": 6}, + {"name": "test", "rows": 2}, + {"name": "val", "rows": 2}, + {"name": "all", "rows": 10}, + ] + + +def test_an_unregistered_task_is_treated_as_an_ordinary_one(): + registry = {"HoldoutSplitter": {"class": HoldoutSplitter}} + + assert predictable_splits( + {"splitter_name": "HoldoutSplitter"}, + SHUFFLED_RUN, + registry, + task_name="APluginTask", + evaluation_strategy="APluginStrategy", + ) == [ + {"name": "train", "rows": 6}, + {"name": "test", "rows": 2}, + {"name": "val", "rows": 2}, + {"name": "all", "rows": 10}, + ] From 19a5edfa83ce60a3db42ad1c87e9baa656037c7e Mon Sep 17 00:00:00 2001 From: Irozuku Date: Wed, 2 Sep 2026 15:12:38 -0400 Subject: [PATCH 2/7] feat: store the split a prediction ran on --- .../a5f2c71e9d40_add_split_to_prediction.py | 42 +++++++++++++++++++ .../api/api_v1/schemas/prediction_params.py | 1 + DashAI/back/dependencies/database/models.py | 1 + 3 files changed, 44 insertions(+) create mode 100644 DashAI/alembic/versions/a5f2c71e9d40_add_split_to_prediction.py diff --git a/DashAI/alembic/versions/a5f2c71e9d40_add_split_to_prediction.py b/DashAI/alembic/versions/a5f2c71e9d40_add_split_to_prediction.py new file mode 100644 index 000000000..8d0f948c0 --- /dev/null +++ b/DashAI/alembic/versions/a5f2c71e9d40_add_split_to_prediction.py @@ -0,0 +1,42 @@ +"""add the split column to prediction + +Revision ID: a5f2c71e9d40 +Revises: b7c1d4e9f206 +Create Date: 2026-09-01 10:00:00.000000 + +Predictions made before this column existed covered the whole dataset, which +is what a null value means, so no backfill is needed. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +revision: str = "a5f2c71e9d40" +down_revision: Union[str, None] = "b7c1d4e9f206" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "prediction" not in inspector.get_table_names(): + return + columns = {column["name"] for column in inspector.get_columns("prediction")} + if "split" in columns: + return + op.add_column("prediction", sa.Column("split", sa.String(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "prediction" not in inspector.get_table_names(): + return + columns = {column["name"] for column in inspector.get_columns("prediction")} + if "split" not in columns: + return + op.drop_column("prediction", "split") diff --git a/DashAI/back/api/api_v1/schemas/prediction_params.py b/DashAI/back/api/api_v1/schemas/prediction_params.py index 0b963bee7..5f1dae7c0 100644 --- a/DashAI/back/api/api_v1/schemas/prediction_params.py +++ b/DashAI/back/api/api_v1/schemas/prediction_params.py @@ -6,3 +6,4 @@ class PredictionCreationParams(BaseModel): run_id: int dataset_id: Optional[int] = None + split: Optional[str] = None diff --git a/DashAI/back/dependencies/database/models.py b/DashAI/back/dependencies/database/models.py index c6ab9ff95..fa7b2fe6e 100644 --- a/DashAI/back/dependencies/database/models.py +++ b/DashAI/back/dependencies/database/models.py @@ -233,6 +233,7 @@ class Prediction(Base): id: Mapped[int] = mapped_column(primary_key=True) run_id: Mapped[int] = mapped_column(ForeignKey("run.id", ondelete="CASCADE")) dataset_id: Mapped[int] = mapped_column(ForeignKey("dataset.id"), nullable=True) + split: Mapped[str] = mapped_column(String, nullable=True) huey_id: Mapped[str] = mapped_column(String, nullable=True) created: Mapped[DateTime] = mapped_column(DateTime, default=datetime.now) last_modified: Mapped[DateTime] = mapped_column( From d6c0ff89bf75d9b01cfffa8c739e3abe127b891e Mon Sep 17 00:00:00 2001 From: Irozuku Date: Wed, 2 Sep 2026 16:55:51 -0400 Subject: [PATCH 3/7] feat: predict on a split of the training dataset --- DashAI/back/api/api_v1/endpoints/predict.py | 78 +++++++++++++++++++++ DashAI/back/job/predict_job.py | 53 +++++++++++--- tests/back/api/test_predict_api.py | 73 +++++++++++++++++++ 3 files changed, 195 insertions(+), 9 deletions(-) diff --git a/DashAI/back/api/api_v1/endpoints/predict.py b/DashAI/back/api/api_v1/endpoints/predict.py index 23da1a336..da3778af3 100644 --- a/DashAI/back/api/api_v1/endpoints/predict.py +++ b/DashAI/back/api/api_v1/endpoints/predict.py @@ -15,6 +15,7 @@ Run, ) from DashAI.back.job.predict_job import run_manual_prediction +from DashAI.back.splitters.splits_payload import predictable_splits if TYPE_CHECKING: from sqlalchemy.orm import Session, sessionmaker @@ -44,6 +45,10 @@ async def create_prediction( The ID of the trained model/run. dataset_id : int | None The ID of the dataset to use for prediction (optional). + split : str | None + The partition of that dataset to predict on (optional). Only applies + when the dataset is the one the model was trained on; ``None`` and + ``"all"`` both mean every row. session_factory : Callable[..., ContextManager[Session]] A factory that creates a context manager that handles a SQLAlchemy session. The generated session can be used to access and query the database. @@ -69,6 +74,7 @@ async def create_prediction( prediction = Prediction( run_id=run.id, dataset_id=params.dataset_id, + split=params.split, ) db.add(prediction) db.commit() @@ -127,6 +133,78 @@ async def get_all_predictions( return predictions +@router.get("/splits/{run_id}") +@inject +async def get_prediction_splits( + run_id: int, + session_factory: "sessionmaker" = Depends(lambda: di["session_factory"]), + component_registry: "ComponentRegistry" = Depends(lambda: di["component_registry"]), +): + """Return the dataset partitions a prediction of this run may target. + + The partitions describe the dataset the model was trained on, so they only + apply to a prediction run on that same dataset; any other dataset is + predicted whole. Which partitions exist depends on how the run was + evaluated, so the splitter that produced it decides the list and its names, + and a task whose models only predict forward keeps just the partitions + outside the window its saved model was fitted through. + + Parameters + ---------- + run_id : int + The ID of the trained model/run. + session_factory : Callable[..., ContextManager[Session]] + A factory that creates a context manager that handles a SQLAlchemy session. + component_registry : ComponentRegistry + Registry used to resolve the splitter that produced the run. + + Returns + ------- + dict + A ``splits`` list of ``{"name", "rows"}`` entries, empty when the run + has no partition worth offering. + + Raises + ------ + HTTPException + If the run or its model session does not exist, or if the run was + produced by a splitter that is no longer registered. + """ + db: Session + with session_factory() as db: + run: Run = db.get(Run, run_id) + if not run: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Run not found" + ) + model_session: ModelSession = db.get(ModelSession, run.model_session_id) + if not model_session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Model session not found", + ) + session_splits = model_session.splits + split_indexes = run.split_indexes + training_dataset_id = model_session.dataset_id + task_name = model_session.task_name + evaluation_strategy = model_session.evaluation_strategy + + try: + splits = predictable_splits( + session_splits, + split_indexes, + component_registry, + task_name=task_name, + evaluation_strategy=evaluation_strategy, + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) + ) from e + + return {"splits": splits, "training_dataset_id": training_dataset_id} + + @router.get("/filter_datasets") async def filter_datasets_endpoint( run_id: int = Query(..., description="The ID of the trained model/run"), diff --git a/DashAI/back/job/predict_job.py b/DashAI/back/job/predict_job.py index 6ed3d1d0d..f509c101d 100644 --- a/DashAI/back/job/predict_job.py +++ b/DashAI/back/job/predict_job.py @@ -11,6 +11,7 @@ from DashAI.back.dependencies.database.models import Dataset, ModelSession, Prediction from DashAI.back.job.base_job import BaseJob, JobError from DashAI.back.models.base_model import BaseModel +from DashAI.back.splitters.splits_payload import run_split_indexes from DashAI.back.tasks.base_task import BaseTask from DashAI.back.tasks.regression_task import RegressionTask @@ -30,7 +31,26 @@ def _run_prediction_pipeline( loaded_dataset: "DashAIDataset", model_session: ModelSession, ) -> Tuple["DashAIDataset", Any]: - """Run shared prediction steps from prepared input data to final predictions.""" + """Run shared prediction steps from prepared input data to final predictions. + + Parameters + ---------- + task : BaseTask + The task the run belongs to. + trained_model : BaseModel + The model loaded from the run. + train_dataset : DashAIDataset + The dataset the model was trained on, for type and label information. + loaded_dataset : DashAIDataset + The rows to predict. + model_session : ModelSession + The session declaring the input and output columns. + + Returns + ------- + tuple + The prepared inputs and the predictions for them. + """ import numpy as np prepared_dataset = loaded_dataset.select_columns(model_session.input_columns) @@ -411,12 +431,31 @@ def run( f"{dataset_trained.file_path}/dataset/" ) from e + row_indexes = None + if prediction.split and dataset_id == model_session.dataset_id: + try: + row_indexes = run_split_indexes( + model_session.splits, + prediction.run.split_indexes, + component_registry, + prediction.split, + ) + except ValueError as e: + prediction.set_status_as_error() + db.commit() + log.exception(e) + raise JobError( + f"Cannot predict on the {prediction.split} split: {e}" + ) from e + try: # Load or create prediction dataset if dataset_id: loaded_dataset: "DashAIDataset" = load_dataset( str(Path(f"{dataset.file_path}/dataset/")) ) + if row_indexes is not None: + loaded_dataset = loaded_dataset.select(row_indexes) else: dataset_trained_path = str( Path(f"{dataset_trained.file_path}/dataset/") @@ -438,16 +477,12 @@ def run( prediction.set_status_as_error() db.commit() log.error(f"Validation Error: {ve}") - raise HTTPException( - status_code=400, - detail=f"Invalid input data: {str(ve)}", - ) from ve + raise JobError(f"Invalid input data: {ve}") from ve except TypeError as te: + prediction.set_status_as_error() + db.commit() log.error(f"Type Error: {te}") - raise HTTPException( - status_code=400, - detail=f"Type validation failed: {str(te)}", - ) from te + raise JobError(f"Type validation failed: {te}") from te except Exception as e: prediction.set_status_as_error() db.commit() diff --git a/tests/back/api/test_predict_api.py b/tests/back/api/test_predict_api.py index 5ab03647a..5c5249e97 100644 --- a/tests/back/api/test_predict_api.py +++ b/tests/back/api/test_predict_api.py @@ -1,20 +1,28 @@ import json from pathlib import Path +import dill import joblib import pytest from datasets import ClassLabel, Value from fastapi.testclient import TestClient +from DashAI.back.core.enums.status import PredictionStatus from DashAI.back.dataloaders.classes.csv_dataloader import CSVDataLoader from DashAI.back.dataloaders.classes.json_dataloader import JSONDataLoader from DashAI.back.dependencies.database.models import Dataset, ModelSession, Run from DashAI.back.dependencies.registry import ComponentRegistry +from DashAI.back.job.base_job import JobError from DashAI.back.job.dataset_job import DatasetJob from DashAI.back.job.model_job import ModelJob +from DashAI.back.job.predict_job import PredictJob from DashAI.back.metrics.base_metric import BaseMetric from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.scikit_learn.k_neighbors_classifier import ( + KNeighborsClassifier, +) from DashAI.back.optimizers.optuna_optimizer import OptunaOptimizer +from DashAI.back.splitters.holdout import HoldoutSplitter from DashAI.back.tasks.base_task import BaseTask from DashAI.back.tasks.tabular_classification_task import TabularClassificationTask @@ -74,6 +82,10 @@ def setup_test_registry(client, monkeypatch: pytest.MonkeyPatch): ModelJob, OptunaOptimizer, TabularClassificationTask, + # The run these tests predict with, and the splitter that carved + # its partitions, both have to be resolvable by name. + KNeighborsClassifier, + HoldoutSplitter, ] ) @@ -392,3 +404,64 @@ def test_run_not_found(client: TestClient): ) assert response.status_code == 404, response.text assert response.json()["detail"] == "Run not found" + + +def test_splits_endpoint_offers_every_partition_of_an_ordinary_run( + client: TestClient, trained_run_id: int, dataset: Dataset +): + """A classifier can be asked about any partition, including the whole set.""" + response = client.get(f"/api/v1/predict/splits/{trained_run_id}") + assert response.status_code == 200, response.text + + body = response.json() + assert body["training_dataset_id"] == dataset["id"] + assert [split["name"] for split in body["splits"]] == [ + "train", + "test", + "val", + "all", + ] + assert all(split["rows"] > 0 for split in body["splits"]) + + +def test_splits_endpoint_run_not_found(client: TestClient): + response = client.get("/api/v1/predict/splits/99999") + assert response.status_code == 404, response.text + assert response.json()["detail"] == "Run not found" + + +def test_a_failing_prediction_reports_why( + client: TestClient, + trained_run_id: int, + dataset: Dataset, + monkeypatch: pytest.MonkeyPatch, +): + """The model's own complaint has to survive the trip out of the worker. + + Prediction jobs run in a subprocess and their exception is sent back with + dill. A starlette HTTPException never reaches the caller: it stores nothing + in ``args``, so unpickling calls it with no status code and the real + message is replaced by a deserialisation failure. + """ + response = client.post( + "/api/v1/predict/", + json={"run_id": trained_run_id, "dataset_id": dataset["id"]}, + ) + assert response.status_code == 200, response.text + prediction_id = response.json()["id"] + + def explode(**kwargs): + raise ValueError("ARIMA forecasts forward only") + + monkeypatch.setattr("DashAI.back.job.predict_job._run_prediction_pipeline", explode) + + job = PredictJob(job_type="PredictJob", kwargs={"prediction_id": prediction_id}) + with pytest.raises(JobError) as raised: + job.run() + + assert "ARIMA forecasts forward only" in str(raised.value) + assert "ARIMA forecasts forward only" in str(dill.loads(dill.dumps(raised.value))) + + # The prediction is left marked as failed rather than running forever. + response = client.get("/api/v1/predict/", params={"prediction_id": prediction_id}) + assert response.json()[0]["status"] == PredictionStatus.ERROR.value From 614877dd7db18fc229bb661446772fe8f8f751b0 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Wed, 2 Sep 2026 16:55:59 -0400 Subject: [PATCH 4/7] feat: choose a split in the prediction dialog --- DashAI/front/src/api/predict.ts | 28 +++++++ .../models/DatasetPredictionPanel.jsx | 9 ++- .../predictions/DatasetSelector.jsx | 77 +++++++++++++++++++ .../predictions/PredictionModal.jsx | 7 ++ .../src/utils/i18n/locales/de/prediction.json | 5 ++ .../src/utils/i18n/locales/en/prediction.json | 5 ++ .../src/utils/i18n/locales/es/prediction.json | 6 ++ .../src/utils/i18n/locales/pt/prediction.json | 6 ++ .../src/utils/i18n/locales/zh/prediction.json | 4 + 9 files changed, 146 insertions(+), 1 deletion(-) diff --git a/DashAI/front/src/api/predict.ts b/DashAI/front/src/api/predict.ts index c38555a3e..002a2229f 100644 --- a/DashAI/front/src/api/predict.ts +++ b/DashAI/front/src/api/predict.ts @@ -27,14 +27,42 @@ export const downloadPredict = async (prediction_id: string) => { export const createPrediction = async ( run_id: number, dataset_id?: number, + split?: string | null, ): Promise => { const response = await api.post(`${predictEndpoint}/`, { run_id, dataset_id, + split, }); return response.data; }; +export interface IPredictionSplit { + name: string; + rows: number; +} + +/** + * The partitions the run carved its training dataset into, which a prediction + * may target when it runs on that same dataset. Which ones exist depends on how + * the run was evaluated, so the backend decides the list and its names. + */ +export const getPredictionSplits = async ( + runId: number, +): Promise<{ + splits: IPredictionSplit[]; + trainingDatasetId: number | null; +}> => { + const response = await api.get<{ + splits: IPredictionSplit[]; + training_dataset_id: number | null; + }>(`${predictEndpoint}/splits/${runId}`); + return { + splits: response.data.splits, + trainingDatasetId: response.data.training_dataset_id, + }; +}; + export const getPredictions = async ( run_id?: number, prediction_id?: string, diff --git a/DashAI/front/src/components/models/DatasetPredictionPanel.jsx b/DashAI/front/src/components/models/DatasetPredictionPanel.jsx index e3346cb98..bc6713adc 100644 --- a/DashAI/front/src/components/models/DatasetPredictionPanel.jsx +++ b/DashAI/front/src/components/models/DatasetPredictionPanel.jsx @@ -29,6 +29,7 @@ export default function DatasetPredictionPanel({ }) { const [datasets, setDatasets] = useState([]); const [selectedDataset, setSelectedDataset] = useState(null); + const [selectedSplit, setSelectedSplit] = useState("all"); const [modelSession, setModelSession] = useState(null); const [loading, setLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); @@ -91,7 +92,11 @@ export default function DatasetPredictionPanel({ setIsSubmitting(true); try { - const prediction = await createPrediction(run.id, selectedDataset.id); + const prediction = await createPrediction( + run.id, + selectedDataset.id, + selectedSplit !== "all" ? selectedSplit : null, + ); const jobResponse = await enqueuePredictionJob(prediction.id); if (!jobResponse || !jobResponse.id) { @@ -190,6 +195,8 @@ export default function DatasetPredictionPanel({ datasets={datasets} selectedDataset={selectedDataset} setSelectedDataset={setSelectedDataset} + runId={run.id} + onSplitChange={setSelectedSplit} actionSlot={