diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 984be7e91..14296a885 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -32,6 +32,11 @@ jobs: pytest: needs: react-build-test runs-on: ${{ matrix.os }} + # Without this a hung test holds the runner for GitHub's six hour default. + # The suite takes four to nine minutes depending on the platform, so thirty + # leaves plenty of headroom while turning a deadlock into a red job with a + # readable log instead of a silent afternoon of runner time. + timeout-minutes: 30 strategy: fail-fast: false matrix: diff --git a/DashAI/back/api/api_v1/schemas/converter_params.py b/DashAI/back/api/api_v1/schemas/converter_params.py index 3e4683260..d0eba004b 100644 --- a/DashAI/back/api/api_v1/schemas/converter_params.py +++ b/DashAI/back/api/api_v1/schemas/converter_params.py @@ -5,7 +5,7 @@ class ConverterParams(BaseModel): order: int = 0 - params: Dict[str, Union[str, int, float, bool, None]] = None + params: Dict[str, Any] = None scope: Dict[str, Union[List[int], List[Dict[str, Any]]]] = None target: Union[Dict[str, Any], None] = None diff --git a/DashAI/back/converters/base_converter.py b/DashAI/back/converters/base_converter.py index 834b172b4..1ecca7dce 100644 --- a/DashAI/back/converters/base_converter.py +++ b/DashAI/back/converters/base_converter.py @@ -101,6 +101,45 @@ def get_metadata(cls) -> Dict[str, Any]: return meta + def changes_row_count(self) -> bool: + """Indicate whether this converter changes the number of dataset rows. + + Samplers (e.g. SMOTE, RandomUnderSampler) return True because they + add or remove rows. Most transformers return False. + + Returns + ------- + bool + True if the converter may add or remove rows, False otherwise. + """ + return False + + def get_report(self) -> Union[Dict[str, Any], None]: + """Return the converter report produced after execution, if any. + + The report complements the transformed dataset with information that + helps downstream tools (explorers, visualisations) interpret what the + converter did during its pipeline step. Persisted to disk per converter + execution. Optional — converters that produce no supplementary + information return ``None``. + + Returns + ------- + Dict[str, Any] or None + JSON-serializable report dict, or ``None`` when the converter does + not produce one. + """ + return None + + def build_report(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Wrap converter-specific data with the producer converter name. + + Subclasses pass only information that is useful after the conversion + has finished. Configuration parameters are already persisted with the + converter DB row. + """ + return {"converter": self.__class__.__name__, **data} + @abstractmethod def get_output_type(self, column_name: str = None) -> DashAIDataType: """Return the DashAI data type produced by this converter for a given column. diff --git a/DashAI/back/converters/category/clustering.py b/DashAI/back/converters/category/clustering.py new file mode 100644 index 000000000..77c36c1af --- /dev/null +++ b/DashAI/back/converters/category/clustering.py @@ -0,0 +1,28 @@ +from typing import Final + +from DashAI.back.converters.base_converter import BaseConverter +from DashAI.back.core.utils import MultilingualString +from DashAI.back.static.icons import Icon + + +class ClusteringConverter(BaseConverter): + """Base class for converters that assign clusters to dataset rows. + + Clustering converters learn an unsupervised grouping from the selected + feature columns and typically enrich the dataset with a new categorical + column containing the cluster label assigned to each row. + + Use these converters when you want to discover natural groupings in the + data or create new features based on similarity, without relying on any target + variable. + """ + + CATEGORY = MultilingualString( + en="Clustering", + es="Agrupamiento", + pt="Agrupamento", + de="Clustering", + zh="聚类", + ) + ICON: Final[str] = Icon.Psychology.value + COLOR: Final[str] = "rgb(72, 149, 239)" diff --git a/DashAI/back/converters/clustering/__init__.py b/DashAI/back/converters/clustering/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/DashAI/back/converters/clustering/clustering.py b/DashAI/back/converters/clustering/clustering.py new file mode 100644 index 000000000..3eb6c6ba5 --- /dev/null +++ b/DashAI/back/converters/clustering/clustering.py @@ -0,0 +1,562 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Union + +from DashAI.back.converters.base_converter import BaseConverter +from DashAI.back.converters.category.clustering import ClusteringConverter +from DashAI.back.core.schema_fields import ( + schema_field, + string_field, +) +from DashAI.back.core.schema_fields.base_schema import ( + BaseSchema, + replace_defs_in_schema, +) +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.clustering_model import ClusteringModel +from DashAI.back.types.dashai_data_type import DashAIDataType +from DashAI.back.types.value_types import Float, Integer + +if TYPE_CHECKING: + import numpy as np + import pandas as pd + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class ClusteringSchema(BaseSchema): + """Schema for the generic clustering converter.""" + + algorithm: schema_field( + string_field(), + "KMeansClustering", + description=MultilingualString( + en="Clustering algorithm to apply.", + es="Algoritmo de clustering a aplicar.", + pt="Algoritmo de clustering a ser aplicado.", + de="Clustering-Algorithmus, der angewendet werden soll.", + zh="要应用的聚类算法。", + ), + ) # type: ignore + algorithm_params: schema_field( + Dict[str, Any], + {}, + description=MultilingualString( + en="Parameters for the selected clustering algorithm.", + es="Parametros del algoritmo de clustering seleccionado.", + pt="Parâmetros para o algoritmo de clustering selecionado.", + de="Parameter für den ausgewählten Clustering-Algorithmus.", + zh="所选聚类算法的参数。", + ), + ) # type: ignore + output_column_name: schema_field( + string_field(), + "cluster", + description=MultilingualString( + en="Name of the output column to store the cluster labels.", + es="Nombre de la columna de salida para guardar las etiquetas de cluster.", + pt="Nome da coluna de saída para armazenar as etiquetas de cluster.", + de="Name der Ausgabespalte zum Speichern der Clusterbezeichnungen.", + zh="用于存储聚类标签的输出列名称。", + ), + ) # type: ignore + + +class Clustering(ClusteringConverter, BaseConverter): + """Apply a clustering algorithm to numeric columns and append a cluster label. + + Numeric columns in scope are passed to the selected algorithm, which assigns + each row to a cluster without requiring a target column. The resulting label + is stored in a new column appended to the original dataset. + + After fitting, a JSON-serializable report is available via ``get_report()`` + with per-cluster metrics, sizes, feature profiles, and algorithm-specific + attributes (e.g. centroids for K-Means, linkage data for Agglomerative). + """ + + SCHEMA = ClusteringSchema + DESCRIPTION = MultilingualString( + en=( + "Groups dataset rows into clusters based on numeric feature similarity and " + "adds a cluster label column. No target column is required, the algorithm " + "finds natural groupings in the data on its own." + ), + es=( + "Agrupa las filas del dataset en clusters según la similitud de sus " + "características numéricas y agrega una columna de etiqueta. No requiere " + "columna objetivo, el algoritmo descubre la estructura en los datos." + ), + pt=( + "Agrupa linhas do dataset em clusters com base na similaridade das " + "características numéricas e adiciona uma coluna de rótulo. Não requer " + "coluna alvo, o algoritmo encontra estrutura nos dados." + ), + de=( + "Gruppiert Datensatzzeilen nach Ähnlichkeit numerischer Merkmale in " + "Cluster und fügt eine Cluster-Label-Spalte hinzu. Es wird keine " + "Zielspalte benötigt, der Algorithmus findet die Struktur in den Daten." + ), + zh=( + "根据数值特征的相似性将数据集的行分组为多个聚类,并添加一个聚类标签列。" + "无需目标列,算法会自动发现数据中的自然分组。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="Groups rows into clusters based on their numeric features.", + es="Agrupa filas en clusters según sus características numéricas.", + pt="Agrupa linhas em clusters com base em características numéricas.", + de="Gruppiert Zeilen anhand numerischer Merkmale in Cluster.", + zh="根据数值特征将行分组为多个聚类。", + ) + DISPLAY_NAME = MultilingualString( + en="Clustering", + es="Agrupamiento", + pt="Agrupamento", + de="Clustering", + zh="聚类", + ) + IMAGE_PREVIEW = "clustering.png" + + metadata = { + "allowed_types": [Float, Integer], + "allowed_dtypes": [], + } + + @classmethod + def get_schema(cls) -> dict: + schema = super().get_schema() + registry = ClusteringModel.get_registry() + conditional_schemas = {} + algorithm_keys = [] + algorithm_names = [] + algorithm_descriptions = [] + + for algorithm_name, model_class in registry.items(): + algorithm_keys.append(algorithm_name) + display_name = getattr(model_class, "DISPLAY_NAME", algorithm_name) + algorithm_names.append(display_name) + algorithm_descriptions.append(getattr(model_class, "DESCRIPTION", "")) + model_schema = replace_defs_in_schema( + model_class.SCHEMA.model_json_schema() + ) + required = set(model_schema.get("required", [])) + properties = { + name: dict(prop_schema) + for name, prop_schema in model_schema.get("properties", {}).items() + } + + for property_name, property_schema in properties.items(): + property_schema["required"] = property_name in required + + for property_name, schema_overrides in getattr( + model_class, "UI_SCHEMA_OVERRIDES", {} + ).items(): + if property_name in properties: + properties[property_name].update(schema_overrides) + + conditional_schemas[algorithm_name] = {"properties": properties} + + schema["properties"]["algorithm"]["enum"] = algorithm_keys + schema["properties"]["algorithm"]["enumNames"] = algorithm_names + schema["properties"]["algorithm"]["optionDescriptions"] = algorithm_descriptions + default_algorithm = schema["properties"]["algorithm"].get( + "placeholder", "KMeansClustering" + ) + default_schema = conditional_schemas.get(default_algorithm, {}) + default_properties = default_schema.get("properties", {}) + default_algorithm_params = { + key: value.get("placeholder") for key, value in default_properties.items() + } + + schema["properties"]["algorithm_params"]["properties"] = default_properties + schema["properties"]["algorithm_params"]["placeholder"] = ( + default_algorithm_params + ) + schema["properties"]["algorithm_params"]["dependsOn"] = "algorithm" + schema["properties"]["algorithm_params"]["conditionalSchemas"] = ( + conditional_schemas + ) + return schema + + def __init__(self, **kwargs): + """Initialise the clustering converter and build the selected model. + + Parameters + ---------- + **kwargs + Configuration keyword arguments validated by ``ClusteringSchema``. + General parameters are consumed here, while model-specific + parameters are forwarded to the corresponding clustering model. + """ + self.algorithm_name = kwargs.get("algorithm", "KMeansClustering") + self.algorithm_params = kwargs.get("algorithm_params", {}) + self.output_column_name = kwargs.get("output_column_name", "cluster") + self._report: Dict[str, object] | None = None + self._labels = None + self._model = self._build_model() + + def _build_model(self) -> ClusteringModel: + """Instantiate the selected DashAI clustering model. + + The model class is resolved from the live registry of + ``ClusteringModel`` subclasses. These are normal DashAI models, but + this converter uses only the small clustering contract: ``train(x)`` + and ``get_cluster_labels(x)``. + + Returns + ------- + ClusteringModel + A clustering model compatible with the converter. + + Raises + ------ + ValueError + If the selected algorithm is not found in the registry. + """ + registry = ClusteringModel.get_registry() + if self.algorithm_name not in registry: + raise ValueError( + f"Unsupported clustering algorithm: {self.algorithm_name!r}. " + f"Available: {list(registry)}" + ) + return registry[self.algorithm_name](**self.algorithm_params) + + def get_output_type(self, column_name: str = None) -> DashAIDataType: + """Return the DashAI type produced by the cluster label column. + + Parameters + ---------- + column_name : str, optional + Not used. Defaults to None. + + Returns + ------- + DashAIDataType + An Integer type backed by ``pyarrow.int64()``. + """ + import pyarrow as pa + + return Integer(arrow_type=pa.int64()) + + def fit( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "Clustering": + """Fit the selected clustering algorithm and store execution metadata. + + The clustering model is trained on the scoped dataset ``x``. Once fitted, + the algorithm adapter must expose its execution report through + ``get_report()``, which is wrapped using the common converter report + structure defined in ``BaseConverter``. + + Parameters + ---------- + x : DashAIDataset + Input dataset containing the scoped feature columns. + y : DashAIDataset, optional + Ignored. Present for API compatibility. Defaults to None. + + Returns + ------- + Clustering + The fitted converter instance (self). + """ + numeric_dataset = self._select_numeric_dataset(x) + self.output_column_name = self._make_unique_column_name( + x.column_names, self.output_column_name + ) + + self._model.train(numeric_dataset) + self._labels = self._model.get_cluster_labels() + + algorithm_key = self.algorithm_name.lower().removesuffix("clustering") + self._report = self.build_report( + { + "algorithm": self.algorithm_name, + "algorithm_key": algorithm_key, + "cluster_column": self.output_column_name, + **self._build_report_data(numeric_dataset), + } + ) + return self + + @staticmethod + def _make_unique_column_name(existing_names: list, base_name: str) -> str: + """Return ``base_name``, or a ``{base_name}_{n}`` variant if taken. + + Re-running the converter on a dataset that already carries a cluster + label column (e.g. a previous run with the same output column name) + would otherwise collide when ``transform`` appends the new column. + + Parameters + ---------- + existing_names : list + Column names already present in the dataset. + base_name : str + The configured output column name. + + Returns + ------- + str + ``base_name`` if unused, otherwise the first free + ``{base_name}_{n}`` variant. + """ + if base_name not in existing_names: + return base_name + + counter = 1 + candidate = f"{base_name}_{counter}" + while candidate in existing_names: + counter += 1 + candidate = f"{base_name}_{counter}" + return candidate + + def _select_numeric_dataset(self, x: "DashAIDataset") -> "DashAIDataset": + """Select the numeric columns of ``x`` used to train the model. + + Clustering algorithms operate only on numeric features. Non-numeric + columns are excluded here so that they are neither passed to the + model nor included in the report, while remaining untouched in the + dataset returned by ``transform``. + + Parameters + ---------- + x : DashAIDataset + Scoped dataset received by ``fit``. + + Returns + ------- + DashAIDataset + A dataset containing only the numeric columns of ``x``. + + Raises + ------ + ValueError + If ``x`` has no numeric column, or if the numeric columns + contain NaN values. + """ + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + numeric_columns = [ + name + for name, dtype in x.types.items() + if isinstance(dtype, (Float, Integer)) + ] + if not numeric_columns: + raise ValueError( + f"{self.__class__.__name__} requires at least one numeric " + "column to fit." + ) + + x_pandas = x.to_pandas() + if x_pandas[numeric_columns].isna().to_numpy().any(): + raise ValueError( + f"{self.__class__.__name__} input contains NaN values in the " + "numeric columns used for clustering." + ) + + numeric_table = x.arrow_table.select(numeric_columns) + numeric_types = {name: x.types[name] for name in numeric_columns} + return DashAIDataset(numeric_table, types=numeric_types, splits=x.splits) + + def transform( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "DashAIDataset": + """Append the cluster labels computed during ``fit`` to the dataset. + + Parameters + ---------- + x : DashAIDataset + Input dataset to transform. Must have the same number of rows as + the dataset used in ``fit``. + y : DashAIDataset, optional + Ignored. Present for API compatibility. Defaults to None. + + Returns + ------- + DashAIDataset + The original dataset with a new cluster label column appended. + + Raises + ------ + ValueError + If ``fit`` has not been called yet. + """ + import pyarrow as pa + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + if self._labels is None: + raise ValueError( + f"{self.__class__.__name__} must be fitted before transform." + ) + + result_table = x.arrow_table.append_column( + self.output_column_name, + pa.array(self._labels.tolist(), type=pa.int64()), + ) + result_types = dict(x.types) + result_types[self.output_column_name] = self.get_output_type() + + return DashAIDataset(result_table, types=result_types, splits=x.splits) + + def get_report(self) -> Dict[str, object] | None: + """Return the report produced after the last ``fit``, or ``None``.""" + return self._report + + def _build_report_data(self, x: "DashAIDataset") -> Dict[str, object]: + """Build the data included in the converter report after fitting. + + Fields present for every algorithm sit at the top level. Algorithm-specific + attributes are obtained from the model via ``get_fit_attributes()`` and + grouped under ``fit_attributes``. + + Parameters + ---------- + x : DashAIDataset + The scoped dataset used during ``fit``. + + Returns + ------- + Dict[str, object] + Dict merged into the top-level report by ``fit``. + """ + + import numpy as np + + x_pandas = x.to_pandas() + labels = self._labels + excluded_labels = [-1] if np.any(np.asarray(labels) == -1) else [] + unique_labels, counts = np.unique(labels, return_counts=True) + cluster_sizes = { + int(label): int(count) + for label, count in zip(unique_labels, counts, strict=False) + if int(label) not in excluded_labels + } + n_clusters = len(cluster_sizes) + + fit_attributes = self._model.get_fit_attributes() + metrics = self._compute_metrics(x_pandas, labels) + + return { + "n_clusters": n_clusters, + "feature_columns": list(x_pandas.columns), + "metrics": metrics, + "cluster_sizes": cluster_sizes, + "cluster_profiles": self._compute_cluster_profiles( + x_pandas, labels, excluded_labels + ), + "fit_attributes": fit_attributes, + } + + @staticmethod + def _compute_metrics( + x: "pd.DataFrame", + labels: "np.ndarray", + ) -> Dict[str, Any]: + """Compute all registered clustering metrics. + + Discovers concrete ``ClusteringMetric`` subclasses at runtime, so new + metrics are picked up automatically without changes here. Keys match + the class name (consistent with the metrics ``ModelJob`` persists). Each score + is ``None`` when undefined — handled by ``prepare_to_metric`` inside + each metric class. + + Parameters + ---------- + x : pd.DataFrame + Numeric feature matrix used during fitting. + labels : np.ndarray + Cluster label assigned to each row (noise points are ``-1``). + + Returns + ------- + Dict[str, Any] + Metric class name → score. Any score is ``None`` when undefined. + """ + from DashAI.back.metrics.clustering_metric import ClusteringMetric + + return { + name: cls.score(x, labels) + for name, cls in ClusteringMetric.get_registry().items() + } + + @staticmethod + def _compute_cluster_profiles( + x: "pd.DataFrame", + labels: "np.ndarray", + excluded_labels: list, + ) -> list: + """Build per-cluster feature summaries for notebook exploration. + + For each cluster produces: label, size, per-feature descriptive statistics + (mean, std, min, max), and the top features that deviate most from the global + mean expressed as a z-score, which helps identify what makes each cluster + distinctive. + + Parameters + ---------- + x : pd.DataFrame + Numeric feature matrix used during fitting. + labels : np.ndarray + Cluster label assigned to each row. + excluded_labels : list + Labels to skip (noise points). + + Returns + ------- + list + List of dicts, one per cluster, each with keys ``cluster``, ``size``, + ``feature_stats``, and ``distinctive_features``. + """ + import numpy as np + import pandas as pd + + label_arr = np.asarray(labels) + global_mean = x.mean() + global_std = x.std().replace(0, 1) + profiles = [] + + for cluster_label in sorted(np.unique(label_arr)): + if int(cluster_label) in excluded_labels: + continue + + mask = label_arr == cluster_label + cluster_x = x[mask] + + feature_stats: Dict[str, Any] = {} + for col in x.columns: + col_data = cluster_x[col].dropna() + feature_stats[col] = { + "mean": float(col_data.mean()) if len(col_data) > 0 else None, + "std": float(col_data.std()) if len(col_data) > 1 else None, + "min": float(col_data.min()) if len(col_data) > 0 else None, + "max": float(col_data.max()) if len(col_data) > 0 else None, + } + + cluster_mean = pd.Series( + {col: feature_stats[col]["mean"] for col in x.columns} + ) + z_scores = ((cluster_mean - global_mean) / global_std).abs() + top_features = z_scores.nlargest(min(3, len(x.columns))) + + distinctive_features = [ + { + "feature": col, + "cluster_mean": feature_stats[col]["mean"], + "global_mean": float(global_mean[col]), + "z_score": float(z_scores[col]), + } + for col in top_features.index + if feature_stats[col]["mean"] is not None + ] + + profiles.append( + { + "cluster": int(cluster_label), + "size": int(mask.sum()), + "feature_stats": feature_stats, + "distinctive_features": distinctive_features, + } + ) + + return profiles diff --git a/DashAI/back/converters/converter_report.py b/DashAI/back/converters/converter_report.py new file mode 100644 index 000000000..460fc45ca --- /dev/null +++ b/DashAI/back/converters/converter_report.py @@ -0,0 +1,38 @@ +"""Helpers to persist the report produced by converter executions.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict + + +def get_converter_report_path(notebook_path: str | Path, converter_id: int) -> Path: + """Return the JSON file path used to store a converter execution report.""" + + return Path(notebook_path) / "converters" / str(converter_id) / "report.json" + + +def save_converter_report( + notebook_path: str | Path, converter_id: int, report: Dict[str, Any] +) -> Path: + """Persist a JSON-serializable converter report to disk.""" + + report_path = get_converter_report_path(notebook_path, converter_id) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text( + json.dumps(report, indent=2, ensure_ascii=True), + encoding="utf-8", + ) + return report_path + + +def load_converter_report( + notebook_path: str | Path, converter_id: int +) -> Dict[str, Any] | None: + """Load a converter execution report from disk, if it exists.""" + + report_path = get_converter_report_path(notebook_path, converter_id) + if not report_path.exists(): + return None + return json.loads(report_path.read_text(encoding="utf-8")) diff --git a/DashAI/back/core/enums/metrics.py b/DashAI/back/core/enums/metrics.py index 5392b1d63..78267f9c7 100644 --- a/DashAI/back/core/enums/metrics.py +++ b/DashAI/back/core/enums/metrics.py @@ -5,6 +5,7 @@ class SplitEnum(Enum): TRAIN = "train" VALIDATION = "validation" TEST = "test" + FULL = "full" class LevelEnum(Enum): diff --git a/DashAI/back/exploration/base_explorer.py b/DashAI/back/exploration/base_explorer.py index a71379ee9..af55d0865 100644 --- a/DashAI/back/exploration/base_explorer.py +++ b/DashAI/back/exploration/base_explorer.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Dict, Final, List +from typing import TYPE_CHECKING, Any, Dict, Final, List, Optional from DashAI.back.config_object import ConfigObject from DashAI.back.core.artifacts import Artifact @@ -33,7 +33,7 @@ class BaseExplorer(ConfigObject, ABC): - Create a new class that extends `BaseExplorer` and assign the previous schema to the `SCHEMA` attribute. - Implement the `launch_exploration` method. - - Implement the `save_exploration` method. + - Implement the `save_notebook` method. - Implement the `get_results` method. You can also optionally: @@ -45,6 +45,12 @@ class BaseExplorer(ConfigObject, ABC): name in the frontend. - Add a description to the `DESCRIPTION` attribute to show a custom description in the frontend. + - Set ``REQUIRES_CONVERTER_REPORT`` to ``True`` if the explorer needs + the report produced by a converter execution. + - Set ``REQUIRES_CONVERTER_CLASS`` to the converter class name string + (e.g. ``"Clustering"``) to restrict metadata loading to a specific + converter type instead of the most recently finished converter of any + type. """ TYPE: Final[str] = "Explorer" @@ -55,6 +61,8 @@ class BaseExplorer(ConfigObject, ABC): CATEGORY: Final[str] = "Other" ICON: Final[str] = Icon.Extension.value COLOR: Final[str] = "rgb(255, 255, 255)" + REQUIRES_CONVERTER_REPORT: Final[bool] = False + REQUIRES_CONVERTER_CLASS: Final[Optional[str]] = None SCHEMA: BaseExplorerSchema metadata: Dict[str, Any] = {} @@ -68,6 +76,16 @@ def __init__(self, **kwargs) -> None: explorer's SCHEMA. """ self.kwargs = kwargs + self.context: Dict[str, Any] = {} + + def set_context(self, context: Dict[str, Any]) -> None: + """Set optional runtime context for explorers that need extra inputs. + + The default explorer contract remains dataset + columns + parameters. + This context is an opt-in extension for explorers that declare extra + runtime needs, such as converter execution report. + """ + self.context = context @classmethod def get_metadata(cls) -> Dict[str, Any]: @@ -78,7 +96,8 @@ def get_metadata(cls) -> Dict[str, Any]: Dict[str, Any] Dictionary containing display name, description, image preview path, category, icon, color, allowed semantic - types, allowed dtypes, and input cardinality constraints. + types, allowed dtypes, input cardinality constraints, and any + optional capability flags declared by the explorer. """ meta: Dict[str, Any] = dict(getattr(cls, "metadata", {}) or {}) meta["display_name"] = cls.DISPLAY_NAME if cls.DISPLAY_NAME else cls.__name__ @@ -89,6 +108,12 @@ def get_metadata(cls) -> Dict[str, Any]: meta["category"] = cls.CATEGORY if cls.CATEGORY else "Other" meta["icon"] = cls.ICON if cls.ICON else Icon.Extension.value meta["color"] = cls.COLOR if cls.COLOR else "rgb(255, 255, 255)" + meta["requires_converter_report"] = ( + cls.REQUIRES_CONVERTER_REPORT if cls.REQUIRES_CONVERTER_REPORT else False + ) + meta["requires_converter_class"] = ( + cls.REQUIRES_CONVERTER_CLASS if cls.REQUIRES_CONVERTER_CLASS else None + ) meta["requires_download"] = bool(getattr(cls, "REQUIRES_DOWNLOAD", False)) meta["download_size_bytes"] = getattr(cls, "DOWNLOAD_SIZE_BYTES", None) diff --git a/DashAI/back/exploration/clustering_explorer.py b/DashAI/back/exploration/clustering_explorer.py new file mode 100644 index 000000000..35d920827 --- /dev/null +++ b/DashAI/back/exploration/clustering_explorer.py @@ -0,0 +1,38 @@ +from typing import Final, Optional + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.exploration.base_explorer import BaseExplorer +from DashAI.back.static.icons import Icon + + +class ClusteringExplorer(BaseExplorer): + """Base class for explorers that interpret clustering results. + + Clustering explorers operate on a dataset that has already been enriched + with a cluster label column by a ``Clustering`` converter. They require the + execution report produced by that converter (algorithm name, metrics, + cluster profiles, and optional algorithm-specific artefacts such as cluster + centres or linkage data) to generate visualisations and summaries that go + beyond what the dataset alone can provide. + + All subclasses automatically inherit ``REQUIRES_CONVERTER_REPORT = True`` + and ``REQUIRES_CONVERTER_CLASS = "Clustering"``, so the job infrastructure + will locate the most recent finished ``Clustering`` converter in the same + notebook and pass its report through ``set_context`` before calling + ``launch_exploration``. + + Subclass this and implement `launch_exploration`, `save_notebook`, and + `get_results` to create a new clustering explorer. + """ + + CATEGORY: Final[str] = MultilingualString( + en="Clustering Analysis", + es="Análisis de Agrupamiento", + pt="Análise de Agrupamento", + de="Clustering-Analyse", + zh="聚类分析", + ) + ICON: Final[str] = Icon.ScatterPlot.value + COLOR: Final[str] = "rgb(72, 149, 239)" + REQUIRES_CONVERTER_REPORT: Final[bool] = True + REQUIRES_CONVERTER_CLASS: Final[Optional[str]] = "Clustering" diff --git a/DashAI/back/exploration/explorers/cluster_distribution.py b/DashAI/back/exploration/explorers/cluster_distribution.py new file mode 100644 index 000000000..f78cc5262 --- /dev/null +++ b/DashAI/back/exploration/explorers/cluster_distribution.py @@ -0,0 +1,364 @@ +from typing import TYPE_CHECKING, Any, Dict, List + +from DashAI.back.core.schema_fields import enum_field, schema_field +from DashAI.back.core.utils import MultilingualString +from DashAI.back.dependencies.database.models import Explorer, Notebook +from DashAI.back.exploration.base_explorer import BaseExplorerSchema +from DashAI.back.exploration.clustering_explorer import ClusteringExplorer +from DashAI.back.types.value_types import Float, Integer + +if TYPE_CHECKING: + from pathlib import Path + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + +_CLUSTER_COLORS = [ + "#4472C4", + "#ED7D31", + "#A9D18E", + "#E00000", + "#7030A0", + "#00B0F0", + "#FFC000", + "#70AD47", + "#FF7F50", + "#9DC3E6", +] + + +class ClusterDistributionSchema(BaseExplorerSchema): + """Schema for ClusterDistributionExplorer.""" + + plot_type: schema_field( + enum_field(["violin", "box"]), + "violin", + description=MultilingualString( + en=( + "Type of plot used to display feature distributions within each " + "cluster. 'violin' shows the full probability density shape " + "alongside a box plot; 'box' shows quartiles, median, and outliers" + " only." + ), + es=( + "Tipo de gráfico usado para mostrar las distribuciones de " + "características dentro de cada clúster. 'violin' muestra la forma " + "completa de densidad de probabilidad junto con un diagrama de caja;" + " 'box' muestra cuartiles, mediana y valores atípicos únicamente." + ), + pt=( + "Tipo de gráfico usado para exibir distribuições de características " + "dentro de cada cluster. 'violin' mostra a forma completa da " + "densidade de probabilidade junto com um diagrama de caixa; 'box' " + "mostra quartis, mediana e outliers apenas." + ), + de=( + "Diagrammtyp zur Darstellung der Merkmalsverteilungen innerhalb " + "jedes Clusters. 'violin' zeigt die vollständige " + "Wahrscheinlichkeitsdichteform neben einem Boxplot; 'box' zeigt " + "nur Quartile, Median und Ausreißer." + ), + zh=( + "用于显示每个聚类内特征分布的图表类型。'violin'(小提琴图)在箱线图旁" + "展示完整的概率密度形状;'box'(箱线图)仅展示四分位数、中位数和异常值。" + ), + ), + alias=MultilingualString( + en="Plot type", + es="Tipo de gráfico", + pt="Tipo de gráfico", + de="Diagrammtyp", + zh="图表类型", + ), + ) # type: ignore + + +class ClusterDistributionExplorer(ClusteringExplorer): + """Violin or box plots of feature distributions grouped by cluster. + + Shows how the values of the selected numeric features are distributed + within each cluster, using one subplot per feature. This goes beyond + the heatmap means: it reveals differences in spread, skewness, and + outliers between clusters for each feature. + + The cluster label column is added automatically from the converter report + and must **not** be included in the column selection. Noise points + (cluster ``-1``) are shown as a separate "Noise" group with a neutral colour. + + Select one or more numeric features to compare across clusters. For the + most informative view, choose features that appear in the top distinctive + features reported by the Clustering Profile. + """ + + DISPLAY_NAME = MultilingualString( + en="Cluster Feature Distribution", + es="Distribución de Características por Clúster", + pt="Distribuição de Características por Cluster", + de="Cluster-Merkmalsverteilung", + zh="聚类特征分布", + ) + DESCRIPTION = MultilingualString( + en=( + "Shows the distribution of the selected numeric features within each " + "cluster using violin or box plots. Reveals differences in spread, " + "skewness, and outliers that are not visible in the heatmap means. " + "One subplot per selected feature." + ), + es=( + "Muestra la distribución de las características numéricas seleccionadas" + " dentro de cada clúster usando gráficos de violín o de caja. Revela " + "diferencias en dispersión, asimetría y valores atípicos que no son " + "visibles en las medias del mapa de calor. Un subgráfico por " + "característica." + ), + pt=( + "Mostra a distribuição das características numéricas selecionadas " + "dentro de cada cluster usando gráficos de violino ou de caixa. Revela " + "diferenças em dispersão, assimetria e outliers não visíveis nas médias " + "do mapa de calor. Um subgráfico por característica selecionada." + ), + de=( + "Zeigt die Verteilung der ausgewählten numerischen Merkmale innerhalb " + "jedes Clusters als Violin- oder Boxplots. Deckt Unterschiede in " + "Streuung, Schiefe und Ausreißern auf, die in den Heatmap-Mittelwerten " + "nicht sichtbar sind. Ein Teildiagramm pro ausgewähltem Merkmal." + ), + zh=( + "使用小提琴图或箱线图展示所选数值特征在每个聚类内的分布。揭示热力图均值中" + "看不到的离散度、偏度和异常值差异。每个所选特征对应一个子图。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="Feature distributions per cluster as violin or box plots.", + es="Distribuciones de características por clúster como violín o caja.", + pt="Distribuições de características por cluster como violino ou caixa.", + de="Merkmalsverteilungen pro Cluster als Violin- oder Boxplots.", + zh="以小提琴图或箱线图展示每个聚类的特征分布。", + ) + IMAGE_PREVIEW = "cluster_distribution.png" + SCHEMA = ClusterDistributionSchema + metadata: Dict[str, Any] = { + "allowed_types": [Float, Integer], + "allowed_dtypes": [], + "input_cardinality": {"min": 1}, + } + + def __init__(self, **kwargs) -> None: + """Initialize the ClusterDistributionExplorer. + + Parameters + ---------- + **kwargs + Configuration keyword arguments. Recognized keys: + plot_type (str, optional): ``"violin"`` for violin plots with + embedded box and mean line, or ``"box"`` for standard box plots. + Defaults to ``"violin"``. + """ + self.plot_type: str = kwargs.get("plot_type", "violin") + super().__init__(**kwargs) + + def prepare_dataset( + self, loaded_dataset: "DashAIDataset", columns: List[Dict[str, Any]] + ) -> "DashAIDataset": + """Extend column selection to include the cluster label column. + + Reads the cluster column name from the converter report and appends + it to the selection if not already present, so that + ``launch_exploration`` can group samples by cluster. + + Parameters + ---------- + loaded_dataset : DashAIDataset + The full dataset. + columns : List[Dict[str, Any]] + Explicitly selected column descriptors (numeric features only). + + Returns + ------- + DashAIDataset + Dataset restricted to the selected feature columns plus the + cluster label column. + """ + cluster_column = self.context.get("converter_report", {}).get( + "cluster_column", "cluster" + ) + column_names = {col["columnName"] for col in columns} + if cluster_column not in column_names: + columns = list(columns) + [{"columnName": cluster_column}] + return super().prepare_dataset(loaded_dataset, columns) + + def launch_exploration( + self, dataset: "DashAIDataset", explorer_info: Explorer + ) -> Any: + """Build violin or box plot subplots for each selected feature. + + Creates one subplot per selected numeric feature. Within each subplot, + each cluster is shown as a separate violin or box, sorted numerically + with noise points last. The legend is shown once (on the first subplot) + and shared across all subplots via ``legendgroup``. + + Parameters + ---------- + dataset : DashAIDataset + Dataset with the selected feature columns and the cluster label + column (injected by ``prepare_dataset``). + explorer_info : Explorer + Explorer record with column descriptors and optional custom title. + + Returns + ------- + plotly.graph_objects.Figure + A multi-subplot figure with one panel per selected feature. + + Raises + ------ + ValueError + If no numeric feature columns remain after excluding the cluster + label column. + """ + import plotly.graph_objects as go + from plotly.subplots import make_subplots + + cr = self.context.get("converter_report", {}) + cluster_column = cr.get("cluster_column", "cluster") + algorithm = cr.get("algorithm", "unknown") + + data = dataset.to_pandas() + feature_cols = [ + c["columnName"] + for c in explorer_info.columns + if c["columnName"] != cluster_column + ] + + if not feature_cols: + raise ValueError("At least one numeric feature column must be selected.") + + data[cluster_column] = data[cluster_column].astype(str).replace({"-1": "Noise"}) + + # Sort clusters numerically, place Noise last + unique_clusters = sorted( + data[cluster_column].unique(), + key=lambda x: (x == "Noise", int(x) if x != "Noise" else 0), + ) + + cluster_color = { + c: ( + _CLUSTER_COLORS[i % len(_CLUSTER_COLORS)] if c != "Noise" else "#AAAAAA" + ) + for i, c in enumerate(c for c in unique_clusters if c != "Noise") + } + cluster_color["Noise"] = "#AAAAAA" + + n_features = len(feature_cols) + fig = make_subplots( + rows=n_features, + cols=1, + subplot_titles=feature_cols, + shared_xaxes=False, + vertical_spacing=max(0.03, 0.15 / n_features), + ) + + for row_idx, feature in enumerate(feature_cols, start=1): + for cluster_label in unique_clusters: + mask = data[cluster_column] == cluster_label + values = data.loc[mask, feature].dropna() + color = cluster_color.get(cluster_label, "#888888") + show_legend = row_idx == 1 + + if self.plot_type == "violin": + trace: Any = go.Violin( + y=values, + name=cluster_label, + legendgroup=cluster_label, + showlegend=show_legend, + box_visible=True, + meanline_visible=True, + line_color=color, + fillcolor=color, + opacity=0.7, + ) + else: + trace = go.Box( + y=values, + name=cluster_label, + legendgroup=cluster_label, + showlegend=show_legend, + marker_color=color, + boxmean=True, + ) + + fig.add_trace(trace, row=row_idx, col=1) + + title = ( + explorer_info.name + if explorer_info.name + else f"Feature Distribution by Cluster — {algorithm}" + ) + layout_kwargs: Dict[str, Any] = { + "title": title, + "height": max(350, 280 * n_features), + } + if self.plot_type == "violin": + layout_kwargs["violinmode"] = "group" + else: + layout_kwargs["boxmode"] = "group" + + fig.update_layout(**layout_kwargs) + + return fig + + def save_notebook( + self, + __notebook_info__: Notebook, + explorer_info: Explorer, + save_path: "Path", + result: Any, + ) -> str: + """Save the distribution figure to disk as a JSON file. + + Parameters + ---------- + __notebook_info__ : Notebook + The notebook database record (unused). + explorer_info : Explorer + The explorer record used for filename generation. + save_path : Path + Directory where the file will be saved. + result : Any + The Plotly figure returned by ``launch_exploration``. + + Returns + ------- + str + The path of the saved JSON file as a POSIX string. + """ + import os + from pathlib import Path + + filename = f"{explorer_info.id}.json" + path = Path(os.path.join(save_path, filename)) + + result.write_json(path.as_posix()) + return path.as_posix() + + def get_results( + self, exploration_path: str, options: Dict[str, Any] + ) -> Dict[str, Any]: + """Load the saved distribution figure and return it for the frontend. + + Parameters + ---------- + exploration_path : str + Path to the JSON file saved by ``save_notebook``. + options : Dict[str, Any] + Rendering options from the frontend (unused). + + Returns + ------- + Dict[str, Any] + Dictionary with keys ``"data"`` (JSON-serialised Plotly figure), + ``"type"`` (``"plotly_json"``), and ``"config"`` (empty dict). + """ + from plotly.io import read_json + + fig = read_json(exploration_path) + return {"data": fig.to_json(), "type": "plotly_json", "config": {}} diff --git a/DashAI/back/exploration/explorers/cluster_stability.py b/DashAI/back/exploration/explorers/cluster_stability.py new file mode 100644 index 000000000..88a332d0f --- /dev/null +++ b/DashAI/back/exploration/explorers/cluster_stability.py @@ -0,0 +1,278 @@ +import json +from typing import TYPE_CHECKING, Any, Dict, List + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.dependencies.database.models import Explorer, Notebook +from DashAI.back.exploration.base_explorer import BaseExplorerSchema +from DashAI.back.exploration.clustering_explorer import ClusteringExplorer + +if TYPE_CHECKING: + from pathlib import Path + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + +_CLUSTER_COLORS = [ + "#4472C4", + "#ED7D31", + "#A9D18E", + "#E00000", + "#7030A0", + "#00B0F0", + "#FFC000", + "#70AD47", + "#FF7F50", + "#9DC3E6", +] + + +class ClusterStabilitySchema(BaseExplorerSchema): + """Schema for ClusterStabilityExplorer — no parameters needed.""" + + +class ClusterStabilityExplorer(ClusteringExplorer): + """Cluster persistence bar chart for HDBSCAN. + + Reads the ``cluster_persistence_`` attribute stored by the ``Clustering`` + converter when run with the ``hdbscan`` algorithm. + + Each bar represents one cluster; its height is the persistence score — + a measure of how long that cluster survives across the range of density + thresholds explored by HDBSCAN. A higher persistence indicates a more + robust, stable cluster; a low persistence means the cluster appears only + in a narrow density window and may be unreliable. + + A dashed horizontal line marks the mean persistence across all clusters. + The result also includes a summary table with min, max, and mean values. + + This explorer raises an informative error if the last ``Clustering`` + converter was **not** run with HDBSCAN. No column selection is required. + """ + + DISPLAY_NAME = MultilingualString( + en="Cluster Stability (HDBSCAN)", + es="Estabilidad de Clústeres (HDBSCAN)", + pt="Estabilidade de Clusters (HDBSCAN)", + de="Cluster-Stabilität (HDBSCAN)", + zh="聚类稳定性(HDBSCAN)", + ) + DESCRIPTION = MultilingualString( + en=( + "Shows the persistence score for each cluster discovered by HDBSCAN. " + "Persistence measures how long a cluster survives across density " + "thresholds. Higher values indicate more robust, stable clusters. " + "Only available after running HDBSCAN clustering." + ), + es=( + "Muestra la puntuación de persistencia de cada clúster descubierto por " + "HDBSCAN. La persistencia mide cuánto tiempo sobrevive un clúster a través" + " de los umbrales de densidad. Valores más altos indican clústeres más " + "robustos y estables. Solo disponible tras ejecutar agrupamiento HDBSCAN." + ), + pt=( + "Mostra a pontuação de persistência para cada cluster descoberto pelo " + "HDBSCAN. A persistência mede por quanto tempo um cluster sobrevive nos " + "limiares de densidade. Valores mais altos indicam clusters mais robustos " + "e estáveis. Disponível apenas após executar o HDBSCAN." + ), + de=( + "Zeigt den Persistenz-Score für jeden von HDBSCAN entdeckten Cluster. " + "Persistenz misst, wie lange ein Cluster über Dichteschwellenwerte hinweg " + "überlebt. Höhere Werte zeigen robustere, stabilere Cluster an. " + "Nur nach HDBSCAN-Clustering verfügbar." + ), + zh=( + "显示HDBSCAN发现的每个聚类的持久性得分。持久性衡量一个聚类在不同密度阈值" + "范围内存续的时长,值越高表示聚类越稳健、越稳定。仅在运行HDBSCAN聚类后可用。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="Per-cluster persistence scores from HDBSCAN.", + es="Puntuaciones de persistencia por clúster de HDBSCAN.", + pt="Pontuações de persistência por cluster do HDBSCAN.", + de="Pro-Cluster-Persistenzwerte von HDBSCAN.", + zh="HDBSCAN每个聚类的持久性得分。", + ) + IMAGE_PREVIEW = "cluster_stability.png" + SCHEMA = ClusterStabilitySchema + metadata: Dict[str, Any] = { + "allowed_types": [], + "allowed_dtypes": [], + "input_cardinality": {"exact": 0}, + "requires_algorithm": "hdbscan", + } + + def prepare_dataset( + self, loaded_dataset: "DashAIDataset", _columns: List[Dict[str, Any]] + ) -> "DashAIDataset": + """Return the dataset unchanged. + + No columns are needed — all data is read from the converter report's + ``fit_attributes``. + + Parameters + ---------- + loaded_dataset : DashAIDataset + The full dataset. + _columns : List[Dict[str, Any]] + Ignored. Present for API compatibility. + + Returns + ------- + DashAIDataset + The dataset as received, unmodified. + """ + return loaded_dataset + + def launch_exploration( + self, _dataset: "DashAIDataset", explorer_info: Explorer + ) -> Dict[str, Any]: + """Build a bar chart of HDBSCAN cluster persistence scores. + + Reads ``cluster_persistence`` from the converter report's + ``fit_attributes`` and renders a bar chart where each bar represents + one cluster. A dashed horizontal line marks the mean persistence + across all clusters. + + Parameters + ---------- + _dataset : DashAIDataset + Not used. All data is read from the converter report. + explorer_info : Explorer + Explorer record, used for the optional custom title. + + Returns + ------- + Dict[str, Any] + Dictionary with keys ``"figure"`` (JSON-serialised Plotly figure) + and ``"summary"`` (dict with ``n_clusters``, ``min_persistence``, + ``max_persistence``, ``mean_persistence``, and ``per_cluster`` list). + + Raises + ------ + ValueError + If the converter report does not contain ``cluster_persistence`` + data, indicating the converter was not run with HDBSCAN. + """ + import numpy as np + import plotly.graph_objects as go + + cr = self.context.get("converter_report", {}) + algorithm = cr.get("algorithm", "unknown") + fit_attributes = cr.get("fit_attributes", {}) + + if "cluster_persistence" not in fit_attributes: + raise ValueError( + f"Cluster Stability requires HDBSCAN clustering, but the last " + f"converter ran '{algorithm}' and did not produce cluster persistence " + f"data. Re-run the Clustering converter with the HDBSCAN algorithm." + ) + + persistence = np.array(fit_attributes["cluster_persistence"]) + n_clusters = len(persistence) + cluster_labels = [f"Cluster {i}" for i in range(n_clusters)] + bar_colors = [ + _CLUSTER_COLORS[i % len(_CLUSTER_COLORS)] for i in range(n_clusters) + ] + mean_val = float(np.mean(persistence)) + + fig = go.Figure( + go.Bar( + x=cluster_labels, + y=persistence.tolist(), + marker_color=bar_colors, + text=[f"{v:.4f}" for v in persistence], + textposition="outside", + ) + ) + fig.add_hline( + y=mean_val, + line_dash="dash", + line_color="red", + annotation_text=f"Mean = {mean_val:.4f}", + annotation_position="top right", + ) + + title = ( + explorer_info.name if explorer_info.name else "Cluster Stability — HDBSCAN" + ) + fig.update_layout( + title=title, + xaxis_title="Cluster", + yaxis_title="Persistence Score", + yaxis={"range": [0, float(np.max(persistence)) * 1.2]}, + ) + + summary = { + "n_clusters": n_clusters, + "min_persistence": float(np.min(persistence)), + "max_persistence": float(np.max(persistence)), + "mean_persistence": mean_val, + "per_cluster": [ + {"cluster": i, "persistence": float(v)} + for i, v in enumerate(persistence) + ], + } + + return {"figure": fig.to_json(), "summary": summary} + + def save_notebook( + self, + __notebook_info__: Notebook, + explorer_info: Explorer, + save_path: "Path", + result: Any, + ) -> str: + """Save the stability result (figure + summary) to disk as a JSON file. + + Parameters + ---------- + __notebook_info__ : Notebook + The notebook database record (unused). + explorer_info : Explorer + The explorer record used for filename generation. + save_path : Path + Directory where the file will be saved. + result : Any + The dict returned by ``launch_exploration``, containing + ``"figure"`` and ``"summary"`` keys. + + Returns + ------- + str + The path of the saved JSON file as a POSIX string. + """ + import os + from pathlib import Path + + filename = f"{explorer_info.id}.json" + path = Path(os.path.join(save_path, filename)) + + with open(path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False) + return path.as_posix() + + def get_results( + self, exploration_path: str, options: Dict[str, Any] + ) -> Dict[str, Any]: + """Load the saved stability result and return it for the frontend. + + Parameters + ---------- + exploration_path : str + Path to the JSON file saved by ``save_notebook``. + options : Dict[str, Any] + Rendering options from the frontend (unused). + + Returns + ------- + Dict[str, Any] + Dictionary with keys ``"data"`` (Plotly figure JSON string), + ``"type"`` (``"plotly_json"``), and ``"config"`` (empty dict). + """ + with open(exploration_path, "r", encoding="utf-8") as f: + data = json.load(f) + return { + "data": data["figure"], + "type": "plotly_json", + "config": {}, + } diff --git a/DashAI/back/exploration/explorers/clustering_heatmap.py b/DashAI/back/exploration/explorers/clustering_heatmap.py new file mode 100644 index 000000000..1607e20ac --- /dev/null +++ b/DashAI/back/exploration/explorers/clustering_heatmap.py @@ -0,0 +1,335 @@ +from typing import TYPE_CHECKING, Any, Dict, List + +from DashAI.back.core.schema_fields import bool_field, schema_field +from DashAI.back.core.utils import MultilingualString +from DashAI.back.dependencies.database.models import Explorer, Notebook +from DashAI.back.exploration.base_explorer import BaseExplorerSchema +from DashAI.back.exploration.clustering_explorer import ClusteringExplorer + +if TYPE_CHECKING: + from pathlib import Path + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class ClusteringHeatmapSchema(BaseExplorerSchema): + """Schema for ClusteringHeatmapExplorer.""" + + normalize: schema_field( + bool_field(), + True, + description=MultilingualString( + en=( + "When enabled, cell colours represent z-score standardised values " + "(how many standard deviations each cluster mean is from the global" + " mean), making clusters with very different feature scales " + "directly comparable. Raw mean values are always shown as cell " + "annotations. When disabled, the colour scale uses raw means." + ), + es=( + "Cuando está activado, los colores representan valores z-score " + "estandarizados (cuántas desviaciones estándar se aleja la media de" + " cada clúster de la media global), haciendo que los clústeres con " + "escalas de características muy distintas sean directamente " + "comparables. Los valores de media cruda siempre se muestran como " + "anotaciones. Cuando está desactivado, la escala de colores usa las" + " medias crudas." + ), + pt=( + "Quando habilitado, as cores das células representam valores " + "z-score padronizados, tornando clusters com escalas de " + "características muito diferentes diretamente comparáveis. Os " + "valores médios brutos sempre são mostrados como anotações. Quando " + "desabilitado, a escala de cores usa as médias brutas." + ), + de=( + "Wenn aktiviert, stellen die Zellenfarben z-score-standardisierte " + "Werte dar, wodurch Cluster mit sehr unterschiedlichen " + "Merkmalsskalen direkt vergleichbar werden. Rohe Mittelwerte werden" + " immer als Zellenbeschriftung angezeigt. Wenn deaktiviert, " + "verwendet die Farbskala die rohen Mittelwerte." + ), + zh=( + "启用后,单元格颜色表示z-score标准化值(每个聚类均值与全局均值相差" + "的标准差数),使特征尺度差异很大的聚类可以直接比较。原始均值始终以" + "单元格标注形式显示。禁用时,颜色刻度使用原始均值。" + ), + ), + alias=MultilingualString( + en="Z-score normalisation", + es="Normalización z-score", + pt="Normalização z-score", + de="Z-Score-Normalisierung", + zh="Z-score标准化", + ), + ) # type: ignore + + +class ClusteringHeatmapExplorer(ClusteringExplorer): + """Annotated heatmap of mean feature values per cluster. + + Reads the per-cluster feature profiles stored in the ``Clustering`` + converter report and renders an annotated heatmap where rows correspond + to clusters and columns to the numeric features used during fitting. + + Each cell shows the raw mean value of a feature within a cluster as a + number annotation. The cell colour can optionally be z-score normalised + (default), which maps how far each cluster mean is from the global feature + mean in standard deviations — making features with very different scales + visually comparable and highlighting which clusters are the most distinctive + for each feature. + + A diverging red–blue colour scale is used: red cells indicate values well + above the global mean, blue cells indicate values well below it. + + No column selection is required — all data comes from the converter report. + """ + + DISPLAY_NAME = MultilingualString( + en="Cluster Feature Heatmap", + es="Mapa de Calor de Características por Clúster", + pt="Mapa de Calor de Características por Cluster", + de="Cluster-Merkmals-Heatmap", + zh="聚类特征热力图", + ) + DESCRIPTION = MultilingualString( + en=( + "Heatmap where rows are clusters and columns are the numeric features " + "used for clustering. Each cell shows the mean feature value within " + "that cluster, with optional z-score normalisation so that distinctive " + "features stand out regardless of their scale." + ), + es=( + "Mapa de calor donde las filas son clústeres y las columnas son las " + "características numéricas usadas para el agrupamiento. Cada celda " + "muestra la media de la característica dentro de ese clúster, con " + "normalización z-score opcional para que las características " + "distintivas destaquen independientemente de su escala." + ), + pt=( + "Mapa de calor onde as linhas são clusters e as colunas são as " + "características numéricas usadas para o agrupamento. Cada célula " + "mostra a média da característica dentro daquele cluster, com " + "normalização z-score opcional." + ), + de=( + "Heatmap, bei der Zeilen Cluster und Spalten die numerischen Merkmale " + "darstellen. Jede Zelle zeigt den mittleren Merkmalswert innerhalb " + "dieses Clusters, mit optionaler z-Score-Normalisierung, damit markante" + " Merkmale unabhängig von ihrer Skala hervorstechen." + ), + zh=( + "热力图中行代表聚类,列代表用于聚类的数值特征。每个单元格显示该特征在" + "该聚类内的均值,可选z-score标准化,使具有代表性的特征不受量纲影响而" + "更加突出。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="Mean feature values per cluster with optional z-score normalisation.", + es=( + "Valores medios de características por clúster con normalización " + "z-score opcional." + ), + pt=( + "Valores médios de características por cluster com normalização " + "z-score opcional." + ), + de="Mittlere Merkmalswerte pro Cluster mit optionaler z-Score-Normalisierung.", + zh="每个聚类的平均特征值,可选z-score标准化。", + ) + IMAGE_PREVIEW = "clustering_heatmap.png" + + SCHEMA = ClusteringHeatmapSchema + metadata: Dict[str, Any] = { + "allowed_types": [], + "allowed_dtypes": [], + "input_cardinality": {"exact": 0}, + } + + def __init__(self, **kwargs) -> None: + """Initialize the ClusteringHeatmapExplorer. + + Parameters + ---------- + **kwargs + Configuration keyword arguments. Recognized keys: + normalize (bool, optional): When True, cell colours use z-score + standardised values; raw means are always shown as annotations. + Defaults to True. + """ + self.normalize: bool = kwargs.get("normalize", True) + super().__init__(**kwargs) + + def prepare_dataset( + self, loaded_dataset: "DashAIDataset", _columns: List[Dict[str, Any]] + ) -> "DashAIDataset": + """Return the dataset unchanged. + + No column selection is needed — all data is read from the converter + report's cluster profiles, not from the dataset itself. + + Parameters + ---------- + loaded_dataset : DashAIDataset + The full dataset. + _columns : List[Dict[str, Any]] + Ignored. Present for API compatibility. + + Returns + ------- + DashAIDataset + The dataset as received, unmodified. + """ + return loaded_dataset + + def launch_exploration( + self, _dataset: "DashAIDataset", explorer_info: Explorer + ) -> Any: + """Build an annotated heatmap of mean feature values per cluster. + + Reads cluster profiles from the converter report and constructs a + Plotly heatmap where rows are clusters and columns are the numeric + features used during fitting. Cell annotations always show raw means; + cell colours use z-score normalised values when ``self.normalize`` is + True, making features with very different scales visually comparable. + + Parameters + ---------- + _dataset : DashAIDataset + Not used. All data is read from the converter report. + explorer_info : Explorer + Explorer record, used for the optional custom title. + + Returns + ------- + plotly.graph_objects.Figure + An annotated heatmap figure. + + Raises + ------ + ValueError + If no cluster profiles are found in the converter report. + """ + import numpy as np + import pandas as pd + import plotly.express as px + + cr = self.context.get("converter_report", {}) + cluster_profiles = cr.get("cluster_profiles", []) + algorithm = cr.get("algorithm", "unknown") + + if not cluster_profiles: + raise ValueError( + "No cluster profiles found in the converter report. " + "Make sure the Clustering converter ran successfully before " + "launching this explorer." + ) + + rows = [] + for profile in cluster_profiles: + row: Dict[str, Any] = {"Cluster": str(profile["cluster"])} + for feature, stats in profile.get("feature_stats", {}).items(): + row[feature] = stats.get("mean") + rows.append(row) + + means_df = pd.DataFrame(rows).set_index("Cluster") + + if self.normalize: + col_means = means_df.mean() + col_stds = means_df.std(ddof=0).replace(0, 1) + color_df = (means_df - col_means) / col_stds + color_label = "z-score" + else: + color_df = means_df.copy() + color_label = "Mean" + + fig = px.imshow( + color_df, + text_auto=False, + aspect="auto", + color_continuous_scale="RdBu_r", + color_continuous_midpoint=0 if self.normalize else None, + title=f"Cluster Feature Heatmap — {algorithm}", + labels={"x": "Feature", "y": "Cluster", "color": color_label}, + ) + + # Add raw mean annotations manually + for r_idx, cluster in enumerate(means_df.index): + for c_idx, feature in enumerate(means_df.columns): + val = means_df.loc[cluster, feature] + if val is not None and not np.isnan(val): + fig.add_annotation( + x=c_idx, + y=r_idx, + text=f"{val:.2f}", + showarrow=False, + font={"size": 11, "color": "black"}, + xref="x", + yref="y", + ) + + fig.update_xaxes(tickangle=45) + + if explorer_info.name: + fig.update_layout(title=explorer_info.name) + + return fig + + def save_notebook( + self, + __notebook_info__: Notebook, + explorer_info: Explorer, + save_path: "Path", + result: Any, + ) -> str: + """Save the heatmap figure to disk as a JSON file. + + Parameters + ---------- + __notebook_info__ : Notebook + The notebook database record (unused). + explorer_info : Explorer + The explorer record used for filename generation. + save_path : Path + Directory where the file will be saved. + result : Any + The Plotly figure returned by ``launch_exploration``. + + Returns + ------- + str + The path of the saved JSON file as a POSIX string. + """ + import os + from pathlib import Path + + filename = f"{explorer_info.id}.json" + path = Path(os.path.join(save_path, filename)) + + result.write_json(path.as_posix()) + return path.as_posix() + + def get_results( + self, exploration_path: str, options: Dict[str, Any] + ) -> Dict[str, Any]: + """Load the saved heatmap and return it for the frontend. + + Parameters + ---------- + exploration_path : str + Path to the JSON file saved by ``save_notebook``. + options : Dict[str, Any] + Rendering options from the frontend (unused). + + Returns + ------- + Dict[str, Any] + Dictionary with keys ``"data"`` (JSON-serialised Plotly figure), + ``"type"`` (``"plotly_json"``), and ``"config"`` (empty dict). + """ + from plotly.io import read_json + + result = read_json(exploration_path) + result = result.to_json() + + return {"data": result, "type": "plotly_json", "config": {}} diff --git a/DashAI/back/exploration/explorers/clustering_profile.py b/DashAI/back/exploration/explorers/clustering_profile.py new file mode 100644 index 000000000..776b97273 --- /dev/null +++ b/DashAI/back/exploration/explorers/clustering_profile.py @@ -0,0 +1,266 @@ +import json +from typing import TYPE_CHECKING, Any, Dict, List + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.dependencies.database.models import Explorer, Notebook +from DashAI.back.exploration.base_explorer import BaseExplorerSchema +from DashAI.back.exploration.clustering_explorer import ClusteringExplorer + +if TYPE_CHECKING: + from pathlib import Path + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +_METRIC_LABELS = { + "Silhouette": "Silhouette Score", + "DaviesBouldin": "Davies-Bouldin Index", + "CalinskiHarabasz": "Calinski-Harabasz Index", +} + +_METRIC_BETTER = { + "Silhouette": "higher is better (range −1 to 1)", + "DaviesBouldin": "lower is better (≥ 0)", + "CalinskiHarabasz": "higher is better (≥ 0)", +} + + +class ClusteringProfileSchema(BaseExplorerSchema): + """Schema for ClusteringProfileExplorer — no parameters needed.""" + + +class ClusteringProfileExplorer(ClusteringExplorer): + """Structured summary of the last clustering run. + + Shows cluster sizes, internal evaluation metrics (Silhouette, + Davies-Bouldin, Calinski-Harabasz), and the distinctive feature + profile of each cluster. All data comes from the converter report — + no column selection required. + """ + + DISPLAY_NAME = MultilingualString( + en="Clustering Profile", + es="Perfil de Agrupamiento", + pt="Perfil de Agrupamento", + de="Clustering-Profil", + zh="聚类概况", + ) + DESCRIPTION = MultilingualString( + en=( + "Structured summary of the last clustering run: algorithm used, " + "internal evaluation metrics, cluster sizes, and per-cluster feature " + "profiles with the most distinctive features ranked by distance from " + "the global mean." + ), + es=( + "Resumen estructurado de la última ejecución de clustering: algoritmo " + "utilizado, métricas de evaluación interna, tamaños de clúster y perfiles " + "de características por clúster con las más distintivas ordenadas por " + "distancia a la media global." + ), + pt=( + "Resumo estruturado da última execução de agrupamento: algoritmo " + "utilizado, métricas de avaliação interna, tamanhos de cluster e perfis " + "de características por cluster com as mais distintivas ordenadas por " + "distância à média global." + ), + de=( + "Strukturierte Zusammenfassung des letzten Clustering-Durchlaufs: " + "verwendeter Algorithmus, interne Bewertungsmetriken, Clustergrößen und " + "Merkmalsprofile pro Cluster mit den markantesten Merkmalen, geordnet " + "nach Abstand vom globalen Mittelwert." + ), + zh=( + "上一次聚类运行的结构化摘要:使用的算法、内部评估指标、聚类大小,以及" + "按与全局均值距离排序的每个聚类的特征概况。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="Metrics, cluster sizes, and distinctive feature profiles.", + es="Métricas, tamaños de clúster y perfiles de características distintivas.", + pt="Métricas, tamanhos de cluster e perfis de características distintivas.", + de="Metriken, Clustergrößen und markante Merkmalsprofile.", + zh="指标、聚类大小和代表性特征概况。", + ) + IMAGE_PREVIEW = "clustering_profile.png" + + SCHEMA = ClusteringProfileSchema + metadata: Dict[str, Any] = { + "allowed_types": [], + "allowed_dtypes": [], + "input_cardinality": {"exact": 0}, + } + + def __init__(self, **kwargs) -> None: + """Initialize ClusteringProfileExplorer. + + Parameters + ---------- + **kwargs + No schema parameters are defined for this explorer. + """ + super().__init__(**kwargs) + + def prepare_dataset( + self, loaded_dataset: "DashAIDataset", _columns: List[Dict[str, Any]] + ) -> "DashAIDataset": + """Return the dataset unchanged. + + This explorer reads entirely from the converter report so the dataset + is not used. The method is overridden to avoid the default + ``select_columns([])`` call that would occur when no columns are selected. + + Parameters + ---------- + loaded_dataset : DashAIDataset + The full dataset loaded from storage. + _columns : List[Dict[str, Any]] + Ignored — no columns are selected for this explorer. + + Returns + ------- + DashAIDataset + The dataset as received, unmodified. + """ + return loaded_dataset + + def launch_exploration( + self, _dataset: "DashAIDataset", _explorer_info: Explorer + ) -> Dict[str, Any]: + """Build a structured profile from the clustering converter report. + + Reads metrics, cluster sizes, and per-cluster feature profiles from + ``self.context["converter_report"]``. Standard metrics are annotated + with human-readable labels and interpretation hints. Inertia (KMeans + only) is extracted from ``fit_attributes`` into a separate section. + Each cluster's distinctive features are enriched with a direction + label (``"above average"`` / ``"below average"``) based on whether + the cluster mean is above or below the global mean. + + Parameters + ---------- + _dataset : DashAIDataset + Ignored — all data comes from the converter report. + _explorer_info : Explorer + Ignored — no per-instance configuration is needed. + + Returns + ------- + Dict[str, Any] + Dictionary with keys: ``algorithm``, ``cluster_column``, + ``n_clusters``, ``metrics``, ``algorithm_extras``, + ``noise_info``, ``cluster_sizes``, ``cluster_profiles``. + """ + cr = self.context.get("converter_report", {}) + fit_attributes = cr.get("fit_attributes", {}) + + metrics: Dict[str, Any] = {} + for key, value in cr.get("metrics", {}).items(): + entry: Dict[str, Any] = {"value": value} + if key in _METRIC_LABELS: + entry["label"] = _METRIC_LABELS[key] + entry["interpretation"] = _METRIC_BETTER[key] + metrics[key] = entry + + # Algorithm-specific extras from fit_attributes (inertia: KMeans only) + algorithm_extras: Dict[str, Any] = {} + if "inertia" in fit_attributes: + algorithm_extras["inertia"] = fit_attributes["inertia"] + + # Noise points count (DBSCAN / HDBSCAN only) + noise_info: Dict[str, Any] = {} + if "n_noise_points" in fit_attributes: + noise_info["n_noise_points"] = fit_attributes["n_noise_points"] + + # Enrich distinctive features with a direction label + raw_profiles = cr.get("cluster_profiles", []) + enriched_profiles = [] + for profile in raw_profiles: + enriched_features = [] + for feat in profile.get("distinctive_features", []): + enriched_feat = dict(feat) + enriched_feat["direction"] = ( + "above average" + if feat.get("cluster_mean", 0) > feat.get("global_mean", 0) + else "below average" + ) + enriched_features.append(enriched_feat) + enriched_profiles.append( + { + "cluster": profile["cluster"], + "size": profile.get("size", 0), + "feature_stats": profile.get("feature_stats", {}), + "distinctive_features": enriched_features, + } + ) + + return { + "algorithm": cr.get("algorithm", "unknown"), + "cluster_column": cr.get("cluster_column", "cluster"), + "n_clusters": cr.get("n_clusters"), + "metrics": metrics, + "algorithm_extras": algorithm_extras, + "noise_info": noise_info, + "cluster_sizes": cr.get("cluster_sizes", {}), + "cluster_profiles": enriched_profiles, + } + + def save_notebook( + self, + __notebook_info__: Notebook, + explorer_info: Explorer, + save_path: "Path", + result: Any, + ) -> str: + """Save the profile result to a JSON file on disk. + + Parameters + ---------- + __notebook_info__ : Notebook + The notebook database record (unused). + explorer_info : Explorer + The explorer record used for filename generation. + save_path : Path + Directory where the file will be saved. + result : Any + The dict returned by ``launch_exploration``. + + Returns + ------- + str + The path of the saved JSON file as a POSIX string. + """ + import os + from pathlib import Path as _Path + + filename = f"{explorer_info.id}.json" + path = _Path(os.path.join(save_path, filename)) + + with open(path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, default=str) + return path.as_posix() + + def get_results( + self, exploration_path: str, options: Dict[str, Any] + ) -> Dict[str, Any]: + """Load the saved profile and return it to the frontend. + + Parameters + ---------- + exploration_path : str + Path to the JSON file saved by ``save_notebook``. + options : Dict[str, Any] + Rendering options from the frontend (unused). + + Returns + ------- + Dict[str, Any] + Dictionary with keys ``"data"`` (the profile dict), + ``"type"`` (``"clustering_profile"``), and ``"config"`` (empty dict). + """ + resultType = "clustering_profile" + config = {} + + with open(exploration_path, "r", encoding="utf-8") as f: + result = json.load(f) + return {"data": result, "type": resultType, "config": config} diff --git a/DashAI/back/exploration/explorers/clustering_scatter.py b/DashAI/back/exploration/explorers/clustering_scatter.py new file mode 100644 index 000000000..39f34b37f --- /dev/null +++ b/DashAI/back/exploration/explorers/clustering_scatter.py @@ -0,0 +1,355 @@ +import os +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List + +from DashAI.back.core.schema_fields import enum_field, schema_field +from DashAI.back.core.utils import MultilingualString +from DashAI.back.dependencies.database.models import Explorer, Notebook +from DashAI.back.exploration.base_explorer import BaseExplorerSchema +from DashAI.back.exploration.clustering_explorer import ClusteringExplorer +from DashAI.back.types.value_types import Float, Integer + +if TYPE_CHECKING: + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class ClusteringScatterSchema(BaseExplorerSchema): + """Schema for ClusteringScatterExplorer.""" + + reduction_method: schema_field( + enum_field(["pca", "tsne"]), + "pca", + description=MultilingualString( + en=( + "Dimensionality reduction method used to project the selected " + "features onto two dimensions. 'pca' is fast and deterministic; " + "'tsne' can better reveal non-linear cluster structure but is " + "slower and stochastic." + ), + es=( + "Método de reducción de dimensionalidad para proyectar las columnas" + " seleccionadas en dos dimensiones. 'pca' es rápido y determinista;" + " 'tsne' puede revelar mejor la estructura no lineal pero es más " + "lento." + ), + pt=( + "Método de redução de dimensionalidade para projetar as colunas " + "selecionadas em duas dimensões. 'pca' é rápido e determinístico; " + "'tsne' pode revelar melhor a estrutura não linear, mas é mais " + "lento." + ), + de=( + "Dimensionsreduzierungsmethode zur Projektion der ausgewählten " + "Merkmale auf zwei Dimensionen. 'pca' ist schnell und " + "deterministisch; 'tsne' kann nicht-lineare Clusterstrukturen " + "besser aufdecken, ist aber langsamer." + ), + zh=( + "用于将所选特征投影到二维平面的降维方法。'pca'速度快且结果确定;" + "'tsne'能更好地揭示非线性聚类结构,但速度较慢且具有随机性。" + ), + ), + alias=MultilingualString( + en="Reduction method", + es="Método de reducción", + pt="Método de redução", + de="Reduktionsmethode", + zh="降维方法", + ), + ) # type: ignore + + +class ClusteringScatterExplorer(ClusteringExplorer): + """2-D scatter plot of clustering results coloured by cluster label. + + Projects the selected numeric feature columns onto a 2-D plane using either + PCA or t-SNE, then colours each point by its assigned cluster label. + + When the converter report contains cluster centres (K-Means, Faiss K-Means, + Gaussian Mixture), the centres are projected through the same PCA reducer + and rendered as distinct ``×`` markers — only available with PCA because + t-SNE does not support out-of-sample transforms. + + Noise points produced by density-based algorithms (DBSCAN / HDBSCAN, + label ``-1``) receive the dedicated label ``"Noise"`` and a neutral colour. + + Select at least two numeric columns that were used to fit the clustering + algorithm. The cluster label column is added automatically from the + converter report and must **not** be included in the selection. + """ + + DISPLAY_NAME = MultilingualString( + en="Clustering Scatter Plot", + es="Gráfico de Dispersión de Agrupamiento", + pt="Gráfico de Dispersão de Agrupamento", + de="Clustering-Streudiagramm", + zh="聚类散点图", + ) + DESCRIPTION = MultilingualString( + en=( + "Projects the selected numeric features onto 2D using PCA or t-SNE " + "and colours each point by its cluster assignment. Cluster centres are " + "overlaid when available (K-Means, GMM). Noise points are highlighted." + ), + es=( + "Proyecta las columnas numéricas seleccionadas en 2D usando PCA o t-SNE" + " y colorea cada punto según su clúster asignado. Superpone los centros " + "de clúster cuando están disponibles (K-Means, GMM). Los puntos de ruido" + " se destacan." + ), + pt=( + "Projeta as colunas numéricas selecionadas em 2D usando PCA ou t-SNE e " + "colore cada ponto pelo cluster atribuído. Centros de cluster são " + "sobrepostos quando disponíveis (K-Means, GMM). Pontos de ruído são " + "destacados." + ), + de=( + "Projiziert die ausgewählten numerischen Merkmale mit PCA oder t-SNE in" + " 2D und färbt jeden Punkt nach seiner Clusterzuordnung. Clusterzentren" + " werden überlagert, wenn verfügbar (K-Means, GMM). Rauschpunkte werden" + " hervorgehoben." + ), + zh=( + "使用PCA或t-SNE将所选数值特征投影到二维平面,并按聚类分配为每个点着色。" + "如有可用的聚类中心(K-Means、GMM)会一并叠加显示。噪声点会被高亮标出。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="2D projection of features coloured by cluster label.", + es="Proyección 2D de características coloreada por etiqueta de clúster.", + pt="Projeção 2D de características colorida por rótulo de cluster.", + de="2D-Projektion der Merkmale, eingefärbt nach Clusterzuordnung.", + zh="按聚类标签着色的特征二维投影。", + ) + IMAGE_PREVIEW = "clustering_scatter.png" + SCHEMA = ClusteringScatterSchema + metadata: Dict[str, Any] = { + "allowed_types": [Float, Integer], + "allowed_dtypes": [], + "input_cardinality": {"min": 2}, + "restricts_to_converter_columns": True, + } + + def __init__(self, **kwargs) -> None: + """Initialize the ClusteringScatterExplorer. + + Parameters + ---------- + **kwargs + Configuration keyword arguments. Recognized keys: + reduction_method (str, optional): ``"pca"`` for Principal Component + Analysis (fast, deterministic, supports centroid projection) or + ``"tsne"`` for t-SNE (better for non-linear structure, slower, + stochastic). Defaults to ``"pca"``. + """ + self.reduction_method: str = kwargs.get("reduction_method", "pca") + super().__init__(**kwargs) + + def prepare_dataset( + self, loaded_dataset: "DashAIDataset", columns: List[Dict[str, Any]] + ) -> "DashAIDataset": + """Extend column selection to include the cluster label column. + + Reads the cluster column name from the converter report and appends + it to the user's selection if not already present, so that + ``launch_exploration`` can colour points by cluster. + + Parameters + ---------- + loaded_dataset : DashAIDataset + The full dataset. + columns : List[Dict[str, Any]] + Feature column descriptors selected by the user (a subset of the + columns used during the last Clustering converter fit). + + Returns + ------- + DashAIDataset + Dataset restricted to the selected feature columns plus the + cluster label column. + """ + cr = self.context.get("converter_report", {}) + cluster_column = cr.get("cluster_column", "cluster") + column_names = {col["columnName"] for col in columns} + if cluster_column not in column_names: + columns = list(columns) + [{"columnName": cluster_column}] + return super().prepare_dataset(loaded_dataset, columns) + + def launch_exploration( + self, dataset: "DashAIDataset", explorer_info: Explorer + ) -> Any: + """Project features to 2D and build a scatter plot coloured by cluster. + + Applies PCA or t-SNE to the selected feature columns, then renders a + Plotly scatter plot where each point is coloured by its cluster label. + When ``reduction_method`` is ``"pca"`` and the converter report contains + cluster centres, the centres are projected through the same reducer and + overlaid as ``×`` markers. + + Parameters + ---------- + dataset : DashAIDataset + Dataset with the selected feature columns and the cluster label + column (injected by ``prepare_dataset``). + explorer_info : Explorer + Explorer record with column descriptors and optional custom title. + + Returns + ------- + plotly.graph_objects.Figure + An interactive 2-D scatter plot figure. + + Raises + ------ + ValueError + If fewer than two numeric feature columns are provided after + excluding the cluster label column. + """ + import numpy as np + import pandas as pd + import plotly.express as px + import plotly.graph_objects as go + from sklearn.decomposition import PCA + from sklearn.manifold import TSNE + + cr = self.context.get("converter_report", {}) + cluster_column = cr.get("cluster_column", "cluster") + algorithm = cr.get("algorithm", "unknown") + converter_feature_cols = set(cr.get("feature_columns", [])) + + feature_cols = [ + c["columnName"] + for c in explorer_info.columns + if c["columnName"] != cluster_column + ] + + if converter_feature_cols: + invalid = [c for c in feature_cols if c not in converter_feature_cols] + if invalid: + raise ValueError( + f"The following columns were not used by the Clustering converter " + f"and cannot be plotted: {invalid}. Select only columns from the " + f"converter scope: {sorted(converter_feature_cols)}." + ) + + if len(feature_cols) < 2: + raise ValueError( + "At least 2 feature columns are required for dimensionality reduction." + ) + + data = dataset.to_pandas() + + X = data[feature_cols].dropna() + cluster_labels = ( + data.loc[X.index, cluster_column].astype(str).replace({"-1": "Noise"}) + ) + + if self.reduction_method == "pca": + reducer = PCA(n_components=2, random_state=42) + coords = reducer.fit_transform(X) + x_label = f"PC1 ({reducer.explained_variance_ratio_[0]:.1%})" + y_label = f"PC2 ({reducer.explained_variance_ratio_[1]:.1%})" + else: + perplexity = min(30.0, max(5.0, len(X) / 5.0)) + reducer = TSNE(n_components=2, random_state=42, perplexity=perplexity) + coords = reducer.fit_transform(X) + x_label, y_label = "t-SNE 1", "t-SNE 2" + + plot_df = pd.DataFrame( + {x_label: coords[:, 0], y_label: coords[:, 1], "Cluster": cluster_labels} + ) + + fig = px.scatter( + plot_df, + x=x_label, + y=y_label, + color="Cluster", + title=f"Cluster Scatter ({self.reduction_method.upper()}) — {algorithm}", + color_discrete_sequence=px.colors.qualitative.Set1, + ) + + # Overlay cluster centres only when PCA is used (TSNE has no transform) + fit_attributes = cr.get("fit_attributes", {}) + if "cluster_centers" in fit_attributes and self.reduction_method == "pca": + centers = np.array(fit_attributes["cluster_centers"]) + if centers.shape[1] == len(feature_cols): + centers_2d = reducer.transform(centers) + for i, center in enumerate(centers_2d): + fig.add_trace( + go.Scatter( + x=[center[0]], + y=[center[1]], + mode="markers", + marker={ + "symbol": "x", + "size": 14, + "color": "black", + "line": {"width": 2}, + }, + name=f"Centroid {i}", + showlegend=True, + ) + ) + + if explorer_info.name: + fig.update_layout(title=explorer_info.name) + + return fig + + def save_notebook( + self, + __notebook_info__: Notebook, + explorer_info: Explorer, + save_path: "Path", + result: Any, + ) -> str: + """Save the scatter plot figure to disk as a JSON file. + + Parameters + ---------- + __notebook_info__ : Notebook + The notebook database record (unused). + explorer_info : Explorer + The explorer record used for filename generation. + save_path : Path + Directory where the file will be saved. + result : Any + The Plotly figure returned by ``launch_exploration``. + + Returns + ------- + str + The path of the saved JSON file as a POSIX string. + """ + from pathlib import Path + + filename = f"{explorer_info.id}.json" + path = Path(os.path.join(save_path, filename)) + + result.write_json(path.as_posix()) + return path.as_posix() + + def get_results( + self, exploration_path: str, options: Dict[str, Any] + ) -> Dict[str, Any]: + """Load the saved scatter plot and return it for the frontend. + + Parameters + ---------- + exploration_path : str + Path to the JSON file saved by ``save_notebook``. + options : Dict[str, Any] + Rendering options from the frontend (unused). + + Returns + ------- + Dict[str, Any] + Dictionary with keys ``"data"`` (JSON-serialised Plotly figure), + ``"type"`` (``"plotly_json"``), and ``"config"`` (empty dict). + """ + from plotly.io import read_json + + result = read_json(exploration_path) + result = result.to_json() + + return {"data": result, "type": "plotly_json", "config": {}} diff --git a/DashAI/back/exploration/explorers/dendrogram.py b/DashAI/back/exploration/explorers/dendrogram.py new file mode 100644 index 000000000..846895c0a --- /dev/null +++ b/DashAI/back/exploration/explorers/dendrogram.py @@ -0,0 +1,290 @@ +from typing import TYPE_CHECKING, Any, Dict, List + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.dependencies.database.models import Explorer, Notebook +from DashAI.back.exploration.base_explorer import BaseExplorerSchema +from DashAI.back.exploration.clustering_explorer import ClusteringExplorer + +if TYPE_CHECKING: + from pathlib import Path + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class DendrogramSchema(BaseExplorerSchema): + """Schema for DendrogramExplorer — no parameters needed.""" + + +class DendrogramExplorer(ClusteringExplorer): + """Hierarchical dendrogram for Agglomerative clustering. + + Reconstructs the merge history stored in the ``Clustering`` converter + report when run with the ``agglomerative`` algorithm and the + ``Compute distances`` option enabled. + + The dendrogram shows the full cluster hierarchy: leaves represent + individual samples, internal nodes represent merges, and the height of + each merge reflects the linkage distance at which two sub-trees were + joined. Reading the dendrogram reveals: + + - The natural number of clusters (large gaps between consecutive merge + heights suggest a good cut point). + - Relative similarity between groups. + - Potential outliers that merge last at very large distances. + + This explorer raises an informative error if the last ``Clustering`` + converter was **not** run with the Agglomerative algorithm and + ``Compute distances`` enabled. No column selection is required. + """ + + DISPLAY_NAME = MultilingualString( + en="Dendrogram", + es="Dendrograma", + pt="Dendrograma", + de="Dendrogramm", + zh="树状图", + ) + DESCRIPTION = MultilingualString( + en=( + "Renders the full merge hierarchy produced by Agglomerative clustering." + " The height of each join reflects the linkage distance, making it easy" + " to identify the natural number of clusters and detect outliers. " + "Only available after running Agglomerative clustering with " + "'Compute distances' enabled." + ), + es=( + "Renderiza la jerarquía completa de fusiones del agrupamiento " + "Aglomerativo. La altura de cada unión refleja la distancia de enlace, " + "facilitando identificar el número natural de clústeres y detectar " + "outliers. Solo disponible tras ejecutar agrupamiento Aglomerativo con " + "'Calcular distancias' activado." + ), + pt=( + "Renderiza a hierarquia completa de fusões do agrupamento Aglomerativo." + " A altura de cada junção reflete a distância de ligação, facilitando " + "identificar o número natural de clusters e detectar outliers. " + "Disponível apenas após executar o agrupamento aglomerativo com " + "'Calcular distâncias' habilitado." + ), + de=( + "Rendert die vollständige Fusionshierarchie des agglomerativen " + "Clusterings. Die Höhe jeder Verbindung spiegelt die Linkage-Distanz " + "wider und erleichtert die Identifizierung der natürlichen Clusteranzahl " + "sowie die Erkennung von Ausreißern. Nur verfügbar nach agglomerativem " + "Clustering mit aktivierter 'Distanzberechnung'." + ), + zh=( + "呈现层次聚类(Agglomerative)产生的完整合并层级。每次合并的高度反映" + "了链接距离,便于识别自然的聚类数量并检测离群点。仅在启用" + "'计算距离'运行层次聚类后可用。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="Full merge hierarchy for Agglomerative clustering.", + es="Jerarquía completa de fusiones para agrupamiento Aglomerativo.", + pt="Hierarquia completa de fusões para agrupamento aglomerativo.", + de="Vollständige Fusionshierarchie für agglomeratives Clustering.", + zh="层次聚类的完整合并层级。", + ) + IMAGE_PREVIEW = "dendrogram.png" + SCHEMA = DendrogramSchema + metadata: Dict[str, Any] = { + "allowed_types": [], + "allowed_dtypes": [], + "input_cardinality": {"exact": 0}, + "requires_algorithm": "agglomerative", + } + + def prepare_dataset( + self, loaded_dataset: "DashAIDataset", _columns: List[Dict[str, Any]] + ) -> "DashAIDataset": + """Return the dataset unchanged. + + No columns are needed — all data is read from the converter report's + ``fit_attributes``. + + Parameters + ---------- + loaded_dataset : DashAIDataset + The full dataset. + _columns : List[Dict[str, Any]] + Ignored. Present for API compatibility. + + Returns + ------- + DashAIDataset + The dataset as received, unmodified. + """ + return loaded_dataset + + def launch_exploration( + self, _dataset: "DashAIDataset", explorer_info: Explorer + ) -> Any: + """Build a Plotly dendrogram from the Agglomerative linkage data. + + Reads ``linkage_data`` from the converter report's ``fit_attributes``, + reconstructs the scipy-compatible linkage matrix, and draws the full + merge hierarchy as a horizontal dendrogram (x-axis = linkage distance, + y-axis = sample index). + + Parameters + ---------- + _dataset : DashAIDataset + Not used. All data is read from the converter report. + explorer_info : Explorer + Explorer record, used for the optional custom title. + + Returns + ------- + plotly.graph_objects.Figure + An interactive dendrogram figure. + + Raises + ------ + ValueError + If the converter report does not contain ``linkage_data``, + indicating the converter was not run with Agglomerative clustering + and ``Compute distances`` enabled. + """ + import numpy as np + import plotly.graph_objects as go + from scipy.cluster.hierarchy import dendrogram + + cr = self.context.get("converter_report", {}) + algorithm = cr.get("algorithm", "unknown") + fit_attributes = cr.get("fit_attributes", {}) + + if "linkage_data" not in fit_attributes: + raise ValueError( + f"Dendrogram requires Agglomerative clustering with 'Compute " + f"distances' enabled, but the last converter ran '{algorithm}' and " + f"produced no linkage data. Re-run the Clustering converter with the " + f"Agglomerative algorithm and enable the 'Compute distances' option." + ) + + linkage_data = fit_attributes["linkage_data"] + children = np.array(linkage_data["children"]) + distances = np.array(linkage_data["distances"], dtype=float) + n_leaves = int(linkage_data["n_leaves"]) + + Z = self._build_linkage_matrix(children, distances, n_leaves) + dendro = dendrogram(Z, no_plot=True, color_threshold=0) + + traces = [] + for xs, ys in zip(dendro["icoord"], dendro["dcoord"], strict=False): + traces.append( + go.Scatter( + x=ys, + y=xs, + mode="lines", + line={"color": "#4472C4", "width": 1}, + showlegend=False, + hoverinfo="skip", + ) + ) + + title = ( + explorer_info.name + if explorer_info.name + else f"Dendrogram — Agglomerative ({n_leaves} samples)" + ) + fig = go.Figure(traces) + fig.update_layout( + title=title, + xaxis_title="Linkage Distance", + yaxis_title="Sample Index", + height=600, + ) + + return fig + + @staticmethod + def _build_linkage_matrix(children: Any, distances: Any, n_leaves: int) -> Any: + """Convert sklearn ``children_`` + ``distances_`` into a scipy linkage matrix. + + Parameters + ---------- + children : np.ndarray, shape (n-1, 2) + Merge pairs from ``AgglomerativeClustering.children_``. + distances : np.ndarray, shape (n-1,) + Merge distances from ``AgglomerativeClustering.distances_``. + n_leaves : int + Number of original samples. + + Returns + ------- + np.ndarray, shape (n-1, 4) + Scipy-compatible linkage matrix [child_i, child_j, distance, count]. + """ + import numpy as np + + Z = np.zeros((n_leaves - 1, 4)) + cluster_sizes: Dict[int, int] = dict.fromkeys(range(n_leaves), 1) + + for i, (c1, c2) in enumerate(children): + new_node = n_leaves + i + Z[i, 0] = float(c1) + Z[i, 1] = float(c2) + Z[i, 2] = float(distances[i]) + size = cluster_sizes.get(int(c1), 1) + cluster_sizes.get(int(c2), 1) + Z[i, 3] = float(size) + cluster_sizes[new_node] = size + + return Z + + def save_notebook( + self, + __notebook_info__: Notebook, + explorer_info: Explorer, + save_path: "Path", + result: Any, + ) -> str: + """Save the dendrogram figure to disk as a JSON file. + + Parameters + ---------- + __notebook_info__ : Notebook + The notebook database record (unused). + explorer_info : Explorer + The explorer record used for filename generation. + save_path : Path + Directory where the file will be saved. + result : Any + The Plotly figure returned by ``launch_exploration``. + + Returns + ------- + str + The path of the saved JSON file as a POSIX string. + """ + import os + from pathlib import Path + + filename = f"{explorer_info.id}.json" + path = Path(os.path.join(save_path, filename)) + + result.write_json(path.as_posix()) + return path.as_posix() + + def get_results( + self, exploration_path: str, options: Dict[str, Any] + ) -> Dict[str, Any]: + """Load the saved dendrogram and return it for the frontend. + + Parameters + ---------- + exploration_path : str + Path to the JSON file saved by ``save_notebook``. + options : Dict[str, Any] + Rendering options from the frontend (unused). + + Returns + ------- + Dict[str, Any] + Dictionary with keys ``"data"`` (JSON-serialised Plotly figure), + ``"type"`` (``"plotly_json"``), and ``"config"`` (empty dict). + """ + from plotly.io import read_json + + fig = read_json(exploration_path) + return {"data": fig.to_json(), "type": "plotly_json", "config": {}} diff --git a/DashAI/back/exploration/explorers/silhouette_plot.py b/DashAI/back/exploration/explorers/silhouette_plot.py new file mode 100644 index 000000000..e4ed900f8 --- /dev/null +++ b/DashAI/back/exploration/explorers/silhouette_plot.py @@ -0,0 +1,348 @@ +from typing import TYPE_CHECKING, Any, Dict, List + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.dependencies.database.models import Explorer, Notebook +from DashAI.back.exploration.base_explorer import BaseExplorerSchema +from DashAI.back.exploration.clustering_explorer import ClusteringExplorer +from DashAI.back.types.value_types import Float, Integer + +if TYPE_CHECKING: + from pathlib import Path + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + +_CLUSTER_COLORS = [ + "#4472C4", + "#ED7D31", + "#A9D18E", + "#E00000", + "#7030A0", + "#00B0F0", + "#FFC000", + "#70AD47", + "#FF7F50", + "#9DC3E6", +] + + +class SilhouettePlotSchema(BaseExplorerSchema): + """Schema for SilhouettePlotExplorer. + + Select the same numeric feature columns that were passed to the clustering + algorithm. No additional parameters are required. + """ + + +class SilhouettePlotExplorer(ClusteringExplorer): + """Per-sample silhouette diagram coloured by cluster. + + Recomputes the silhouette coefficient for every sample using + ``sklearn.metrics.silhouette_samples`` and renders a horizontal silhouette + plot: each cluster occupies a vertical band, bars extend right (positive) + or left (negative) according to each sample's score, and a vertical dashed + line marks the dataset mean silhouette score. + + The plot reveals poorly separated clusters (many samples with low or + negative silhouette), uneven cluster sizes, and samples that may be + mis-assigned. + + Noise points produced by density-based algorithms (label ``-1``) are shown + as a separate "Noise" band but are excluded from the mean score. + + Requires at least two valid (non-noise) clusters. Select the same numeric + feature columns that were used to fit the clustering algorithm. + """ + + DISPLAY_NAME = MultilingualString( + en="Silhouette Plot", + es="Gráfico de Silueta", + pt="Gráfico de Silhueta", + de="Silhouette-Diagramm", + zh="轮廓图", + ) + DESCRIPTION = MultilingualString( + en=( + "Shows per-sample silhouette coefficients grouped by cluster. " + "Each bar represents one sample; bars to the right indicate good " + "cluster assignment, bars to the left indicate possible mis-assignment." + " The dashed line marks the dataset mean silhouette score." + ), + es=( + "Muestra los coeficientes de silueta por muestra agrupados por clúster." + " Cada barra representa una muestra; barras a la derecha indican buena " + "asignación al clúster, barras a la izquierda indican posible mala " + "asignación. La línea punteada marca la media del conjunto de datos." + ), + pt=( + "Mostra os coeficientes de silhueta por amostra agrupados por cluster. " + "Barras à direita indicam boa atribuição ao cluster; à esquerda, " + "possível atribuição incorreta. A linha tracejada marca a média do " + "conjunto de dados." + ), + de=( + "Zeigt Pro-Sample-Silhouette-Koeffizienten nach Cluster gruppiert. " + "Balken nach rechts zeigen gute Clusterzuordnung an; Balken nach links " + "mögliche Fehlzuordnung. Die gestrichelte Linie markiert den " + "Datensatzmittelwert." + ), + zh=( + "按聚类分组展示每个样本的轮廓系数。每根柱条代表一个样本;柱条向右表示" + "聚类分配良好,向左表示可能分配错误。虚线标记数据集的平均轮廓系数。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="Per-sample silhouette coefficients grouped by cluster.", + es="Coeficientes de silueta por muestra agrupados por clúster.", + pt="Coeficientes de silhueta por amostra agrupados por cluster.", + de="Pro-Sample-Silhouette-Koeffizienten nach Cluster.", + zh="按聚类分组的每个样本的轮廓系数。", + ) + IMAGE_PREVIEW = "silhouette_plot.png" + SCHEMA = SilhouettePlotSchema + metadata: Dict[str, Any] = { + "allowed_types": [Float, Integer], + "allowed_dtypes": [], + "input_cardinality": {"min": 1}, + "restricts_to_converter_columns": True, + } + + def prepare_dataset( + self, loaded_dataset: "DashAIDataset", columns: List[Dict[str, Any]] + ) -> "DashAIDataset": + """Extend column selection to include the cluster label column. + + Reads the cluster column name from the converter report and appends + it to the selection if not already present, so that + ``launch_exploration`` can group samples by cluster when recomputing + the silhouette scores. + + Parameters + ---------- + loaded_dataset : DashAIDataset + The full dataset. + columns : List[Dict[str, Any]] + Explicitly selected column descriptors (numeric feature columns + that were used to fit the clustering algorithm). + + Returns + ------- + DashAIDataset + Dataset restricted to the selected feature columns plus the + cluster label column. + """ + cluster_column = self.context.get("converter_report", {}).get( + "cluster_column", "cluster" + ) + column_names = {col["columnName"] for col in columns} + if cluster_column not in column_names: + columns = list(columns) + [{"columnName": cluster_column}] + return super().prepare_dataset(loaded_dataset, columns) + + def launch_exploration( + self, dataset: "DashAIDataset", explorer_info: Explorer + ) -> Any: + """Compute per-sample silhouette scores and render a horizontal silhouette plot. + + Recomputes silhouette coefficients from scratch using + ``sklearn.metrics.silhouette_samples``. Each cluster occupies a + vertical band; samples within a cluster are sorted by their score so + the filled area reflects the score distribution. Clusters are separated + by a small gap for readability. Noise points (label ``-1``) are shown + as a separate band but excluded from the global mean score. + + Parameters + ---------- + dataset : DashAIDataset + Dataset with the selected feature columns and the cluster label + column (injected by ``prepare_dataset``). The feature columns + should be the same ones used to fit the clustering algorithm. + explorer_info : Explorer + Explorer record with column descriptors and optional custom title. + + Returns + ------- + plotly.graph_objects.Figure + A horizontal silhouette diagram. + + Raises + ------ + ValueError + If fewer than two valid (non-noise) clusters are present in the + data after excluding noise points, or if a selected column was + not part of the feature set used to fit the clustering algorithm. + """ + import numpy as np + import plotly.graph_objects as go + from sklearn.metrics import silhouette_samples + + cr = self.context.get("converter_report", {}) + cluster_column = cr.get("cluster_column", "cluster") + algorithm = cr.get("algorithm", "unknown") + converter_feature_cols = set(cr.get("feature_columns", [])) + + data = dataset.to_pandas() + feature_cols = [ + c["columnName"] + for c in explorer_info.columns + if c["columnName"] != cluster_column + ] + + if converter_feature_cols: + invalid = [c for c in feature_cols if c not in converter_feature_cols] + if invalid: + raise ValueError( + f"The following columns were not used by the Clustering " + f"converter and cannot be used for the silhouette score: " + f"{invalid}. Select only columns from the converter scope: " + f"{sorted(converter_feature_cols)}." + ) + + X = data[feature_cols].dropna() + labels = data.loc[X.index, cluster_column].values + + unique_labels = np.unique(labels) + valid_labels = unique_labels[unique_labels != -1] + + if len(valid_labels) < 2: + raise ValueError( + f"Silhouette plot requires at least 2 valid clusters. " + f"Found {len(valid_labels)} valid cluster(s) after excluding noise." + ) + + sample_scores = silhouette_samples(X, labels) + valid_mask = labels != -1 + mean_score = float(np.mean(sample_scores[valid_mask])) + + fig = go.Figure() + y_offset = 0 + cluster_tick_vals: List[float] = [] + cluster_tick_text: List[str] = [] + + for _i, cluster_label in enumerate(sorted(valid_labels)): + mask = labels == cluster_label + cluster_scores = np.sort(sample_scores[mask]) + size = len(cluster_scores) + y_vals = np.arange(y_offset, y_offset + size, dtype=float) + color = _CLUSTER_COLORS[int(cluster_label) % len(_CLUSTER_COLORS)] + + fig.add_trace( + go.Scatter( + x=np.concatenate([[0.0], cluster_scores, [0.0]]), + y=np.concatenate([[y_vals[0]], y_vals, [y_vals[-1]]]), + mode="lines", + fill="tozerox", + fillcolor=color, + line={"color": color, "width": 0.5}, + name=f"Cluster {int(cluster_label)}", + showlegend=True, + ) + ) + cluster_tick_vals.append(y_offset + size / 2) + cluster_tick_text.append(f"Cluster {int(cluster_label)}") + y_offset += size + 15 + + # Noise band (if any) + if -1 in unique_labels: + noise_mask = labels == -1 + noise_scores = np.sort(sample_scores[noise_mask]) + size = len(noise_scores) + y_vals = np.arange(y_offset, y_offset + size, dtype=float) + fig.add_trace( + go.Scatter( + x=np.concatenate([[0.0], noise_scores, [0.0]]), + y=np.concatenate([[y_vals[0]], y_vals, [y_vals[-1]]]), + mode="lines", + fill="tozerox", + fillcolor="#AAAAAA", + line={"color": "#AAAAAA", "width": 0.5}, + name="Noise", + showlegend=True, + ) + ) + cluster_tick_vals.append(y_offset + size / 2) + cluster_tick_text.append("Noise") + y_offset += size + 15 + + fig.add_vline( + x=mean_score, + line_dash="dash", + line_color="red", + annotation_text=f"Mean = {mean_score:.3f}", + annotation_position="top right", + ) + + title = ( + explorer_info.name + if explorer_info.name + else f"Silhouette Plot — {algorithm}" + ) + fig.update_layout( + title=title, + xaxis_title="Silhouette Coefficient", + xaxis={"range": [-1.1, 1.1]}, + yaxis={ + "tickvals": cluster_tick_vals, + "ticktext": cluster_tick_text, + "showgrid": False, + }, + height=max(400, y_offset + 60), + ) + + return fig + + def save_notebook( + self, + __notebook_info__: Notebook, + explorer_info: Explorer, + save_path: "Path", + result: Any, + ) -> str: + """Save the silhouette figure to disk as a JSON file. + + Parameters + ---------- + __notebook_info__ : Notebook + The notebook database record (unused). + explorer_info : Explorer + The explorer record used for filename generation. + save_path : Path + Directory where the file will be saved. + result : Any + The Plotly figure returned by ``launch_exploration``. + + Returns + ------- + str + The path of the saved JSON file as a POSIX string. + """ + import os + from pathlib import Path + + filename = f"{explorer_info.id}.json" + path = Path(os.path.join(save_path, filename)) + + result.write_json(path.as_posix()) + return path.as_posix() + + def get_results( + self, exploration_path: str, options: Dict[str, Any] + ) -> Dict[str, Any]: + """Load the saved silhouette figure and return it for the frontend. + + Parameters + ---------- + exploration_path : str + Path to the JSON file saved by ``save_notebook``. + options : Dict[str, Any] + Rendering options from the frontend (unused). + + Returns + ------- + Dict[str, Any] + Dictionary with keys ``"data"`` (JSON-serialised Plotly figure), + ``"type"`` (``"plotly_json"``), and ``"config"`` (empty dict). + """ + from plotly.io import read_json + + fig = read_json(exploration_path) + return {"data": fig.to_json(), "type": "plotly_json", "config": {}} diff --git a/DashAI/back/initial_components.py b/DashAI/back/initial_components.py index 27bc9ed23..107279a78 100644 --- a/DashAI/back/initial_components.py +++ b/DashAI/back/initial_components.py @@ -1,5 +1,7 @@ import logging +from DashAI.back.converters.clustering.clustering import Clustering + # Hugging Face module from DashAI.back.converters.hugging_face.embedding import Embedding from DashAI.back.converters.hugging_face.image_embedding import ( @@ -131,8 +133,24 @@ # Explorers from DashAI.back.exploration.explorers.box_plot import BoxPlotExplorer +from DashAI.back.exploration.explorers.cluster_distribution import ( + ClusterDistributionExplorer, +) +from DashAI.back.exploration.explorers.cluster_stability import ( + ClusterStabilityExplorer, +) +from DashAI.back.exploration.explorers.clustering_heatmap import ( + ClusteringHeatmapExplorer, +) +from DashAI.back.exploration.explorers.clustering_profile import ( + ClusteringProfileExplorer, +) +from DashAI.back.exploration.explorers.clustering_scatter import ( + ClusteringScatterExplorer, +) from DashAI.back.exploration.explorers.corr_matrix import CorrelationMatrixExplorer from DashAI.back.exploration.explorers.cov_matrix import CovarianceMatrixExplorer +from DashAI.back.exploration.explorers.dendrogram import DendrogramExplorer from DashAI.back.exploration.explorers.density_heatmap import DensityHeatmapExplorer from DashAI.back.exploration.explorers.describe_explorer import DescribeExplorer from DashAI.back.exploration.explorers.ecdf_plot import ECDFPlotExplorer @@ -146,6 +164,7 @@ ) from DashAI.back.exploration.explorers.scatter_matrix import ScatterMatrixExplorer from DashAI.back.exploration.explorers.scatter_plot import ScatterPlotExplorer +from DashAI.back.exploration.explorers.silhouette_plot import SilhouettePlotExplorer from DashAI.back.exploration.explorers.time_series_plot import ( TimeSeriesPlotExplorer, ) @@ -175,6 +194,9 @@ from DashAI.back.metrics.classification.precision import Precision from DashAI.back.metrics.classification.recall import Recall from DashAI.back.metrics.classification.roc_auc import ROCAUC +from DashAI.back.metrics.clustering.calinski_harabasz import CalinskiHarabasz +from DashAI.back.metrics.clustering.davies_bouldin import DaviesBouldin +from DashAI.back.metrics.clustering.silhouette import Silhouette from DashAI.back.metrics.forecasting.mape import MAPE from DashAI.back.metrics.forecasting.smape import SMAPE from DashAI.back.metrics.regression.explained_variance import ExplainedVariance @@ -190,6 +212,10 @@ from DashAI.back.models.efficientnet_b0_image_classifier import ( EfficientNetB0ImageClassifier, ) + +# Models +from DashAI.back.models.faiss.faiss_dbscan_clustering import FaissDBSCANClustering +from DashAI.back.models.faiss.faiss_kmeans_clustering import FaissKMeansClustering from DashAI.back.models.forecasting.arima import ARIMA from DashAI.back.models.forecasting.exponential_smoothing import ( ExponentialSmoothing, @@ -198,8 +224,6 @@ from DashAI.back.models.forecasting.seasonal_naive import ( SeasonalNaiveForecaster, ) - -# Models from DashAI.back.models.hugging_face.albert_transformer import AlbertTransformer from DashAI.back.models.hugging_face.bert_transformer import BertTransformer from DashAI.back.models.hugging_face.bertin_transformer import BertinTransformer @@ -360,6 +384,9 @@ from DashAI.back.models.resnet50_image_classifier import ResNet50ImageClassifier from DashAI.back.models.scikit_learn.adaboost_classifier import AdaBoostClassifier from DashAI.back.models.scikit_learn.adaboost_regression import AdaBoostRegression +from DashAI.back.models.scikit_learn.agglomerative_clustering import ( + AgglomerativeClustering, +) from DashAI.back.models.scikit_learn.bagging_classifier import BaggingClassifier from DashAI.back.models.scikit_learn.bayesian_ridge_regression import ( BayesianRidgeRegression, @@ -367,6 +394,7 @@ from DashAI.back.models.scikit_learn.bow_text_classification_model import ( BagOfWordsTextClassificationModel, ) +from DashAI.back.models.scikit_learn.dbscan_clustering import DBSCANClustering from DashAI.back.models.scikit_learn.decision_tree_classifier import ( DecisionTreeClassifier, ) @@ -377,6 +405,9 @@ from DashAI.back.models.scikit_learn.elastic_net_regression import ElasticNetRegression from DashAI.back.models.scikit_learn.extra_trees_classifier import ExtraTreesClassifier from DashAI.back.models.scikit_learn.extra_trees_regression import ExtraTreesRegression +from DashAI.back.models.scikit_learn.gaussian_mixture_clustering import ( + GaussianMixtureClustering, +) from DashAI.back.models.scikit_learn.gaussian_nb import GaussianNB from DashAI.back.models.scikit_learn.gradient_boosting_classifier import ( GradientBoostingClassifier, @@ -384,6 +415,7 @@ from DashAI.back.models.scikit_learn.gradient_boosting_regression import ( GradientBoostingR, ) +from DashAI.back.models.scikit_learn.hdbscan_clustering import HDBSCANClustering from DashAI.back.models.scikit_learn.hist_gradient_boosting_classifier import ( HistGradientBoostingClassifier, ) @@ -392,6 +424,7 @@ ) from DashAI.back.models.scikit_learn.k_neighbors_classifier import KNeighborsClassifier from DashAI.back.models.scikit_learn.k_neighbors_regression import KNeighborsRegression +from DashAI.back.models.scikit_learn.kmeans_clustering import KMeansClustering from DashAI.back.models.scikit_learn.lasso_regression import LassoRegression from DashAI.back.models.scikit_learn.linear_regression import LinearRegression from DashAI.back.models.scikit_learn.linear_svc_classifier import LinearSVCClassifier @@ -407,6 +440,7 @@ ) from DashAI.back.models.scikit_learn.ridge_regression import RidgeRegression from DashAI.back.models.scikit_learn.sgd_classifier import SGDClassifier +from DashAI.back.models.scikit_learn.spectral_clustering import SpectralClustering from DashAI.back.models.scikit_learn.svc import SVC from DashAI.back.models.scikit_learn.svr import SVR from DashAI.back.models.scikit_learn.tfidf_logreg_text_classification_model import ( @@ -458,6 +492,9 @@ from DashAI.back.statistical_tests.wilcoxon_sr_test import ( WilcoxonSRTest, ) + +# Tasks +from DashAI.back.tasks.clustering_task import ClusteringTask from DashAI.back.tasks.controlnet_task import ControlNetTask from DashAI.back.tasks.forecasting_task import ForecastingTask from DashAI.back.tasks.image_classification_task import ImageClassificationTask @@ -502,7 +539,10 @@ def get_initial_components(): ControlNetTask, RAGTask, ImageClassificationTask, + ClusteringTask, # Models + FaissDBSCANClustering, + FaissKMeansClustering, AdaBoostClassifier, AlbertTransformer, AdaBoostRegression, @@ -516,6 +556,11 @@ def get_initial_components(): DebertaV3Transformer, DecisionTreeClassifier, DecisionTreeRegression, + AgglomerativeClustering, + DBSCANClustering, + GaussianMixtureClustering, + HDBSCANClustering, + SpectralClustering, DistilBertTransformer, DummyClassifier, ElasticNetRegression, @@ -530,6 +575,7 @@ def get_initial_components(): KNeighborsClassifier, RAGPipeline, KNeighborsRegression, + KMeansClustering, LassoRegression, LinearRegression, LinearSVCClassifier, @@ -616,6 +662,9 @@ def get_initial_components(): BalancedAccuracy, Precision, Recall, + Silhouette, + DaviesBouldin, + CalinskiHarabasz, Bleu, Ter, Chrf, @@ -676,8 +725,16 @@ def get_initial_components(): TimeSeriesPlotExplorer, ParallelCategoriesExplorer, ParallelCordinatesExplorer, + ClusteringProfileExplorer, + ClusteringScatterExplorer, + ClusteringHeatmapExplorer, + SilhouettePlotExplorer, + ClusterDistributionExplorer, + DendrogramExplorer, + ClusterStabilityExplorer, # Converters ColumnRemover, + Clustering, NanRemover, CharacterReplacer, ColumnArithmetic, diff --git a/DashAI/back/job/converter_job.py b/DashAI/back/job/converter_job.py index fcbfc4d68..7b320f179 100644 --- a/DashAI/back/job/converter_job.py +++ b/DashAI/back/job/converter_job.py @@ -5,6 +5,9 @@ from sqlalchemy import exc from DashAI.back.api.api_v1.schemas.converter_params import ConverterParams +from DashAI.back.converters.converter_report import ( + save_converter_report, +) from DashAI.back.dependencies.database.models import Converter from DashAI.back.dependencies.database.models import Dataset as DatasetModel from DashAI.back.job.base_job import BaseJob, JobError @@ -205,6 +208,7 @@ def run( session_factory = di["session_factory"] component_registry = di["component_registry"] + config = di["config"] def instantiate_converters( converter_name: str, @@ -407,6 +411,15 @@ def instantiate_converters( f"Error transforming data with {converter_name}: {e}" ) from e + report = converter_instance.get_report() + if report is not None: + save_converter_report( + notebook_path=config["NOTEBOOK_PATH"] + / str(converter.notebook.id), + converter_id=converter.id, + report=report, + ) + if type(converter_instance).CHANGES_ROW_COUNT: loaded_dataset = transformed_dataset else: diff --git a/DashAI/back/job/explorer_job.py b/DashAI/back/job/explorer_job.py index beb261e95..b10e5480e 100644 --- a/DashAI/back/job/explorer_job.py +++ b/DashAI/back/job/explorer_job.py @@ -4,7 +4,9 @@ from kink import inject from sqlalchemy import exc -from DashAI.back.dependencies.database.models import Explorer, Notebook +from DashAI.back.converters.converter_report import load_converter_report +from DashAI.back.core.enums.status import ConverterStatus +from DashAI.back.dependencies.database.models import Converter, Explorer, Notebook from DashAI.back.exploration.base_explorer import BaseExplorer from DashAI.back.job.base_job import BaseJob, JobError @@ -15,6 +17,88 @@ log = logging.getLogger(__name__) +def _build_explorer_context( + db, + notebook_info: Notebook, + explorer_instance: BaseExplorer, + config: dict, +) -> dict: + """Build optional runtime context for explorers. + + Explorers keep receiving the current notebook dataset as their main input. + A converter report is loaded only when the explorer explicitly requires it + via ``metadata["requires_converter_report"] = True``. + + When the explorer also declares ``metadata["requires_converter_class"]``, + the most recently finished converter of any type in the notebook must be + of that class. This guarantees the referenced report describes exactly + the current dataset state: nothing could have run afterwards to alter the + columns the report depends on. If a different converter ran more recently, + the explorer is refused instead of silently reusing a report that may no + longer match the live dataset. + """ + explorer_metadata = explorer_instance.get_metadata() + if not explorer_metadata.get("requires_converter_report", False): + return {} + + required_class = explorer_metadata.get("requires_converter_class") + latest_converter = ( + db.query(Converter) + .filter(Converter.notebook_id == notebook_info.id) + .filter(Converter.status == ConverterStatus.FINISHED) + .order_by(Converter.created.desc()) + .first() + ) + + if latest_converter is None: + class_hint = f" of type '{required_class}'" if required_class else "" + raise JobError( + f"This explorer requires a converter report, but the notebook has " + f"no finished converters{class_hint}." + ) + + if required_class and latest_converter.converter != required_class: + raise JobError( + f"This explorer requires a report from the most recently finished " + f"converter in the notebook, but the last converter was " + f"'{latest_converter.converter}', not '{required_class}'. Re-run " + f"the '{required_class}' converter before creating this explorer " + f"so its report reflects the current dataset." + ) + + notebook_output_path = config["NOTEBOOK_PATH"] / str(notebook_info.id) + converter_report = load_converter_report( + notebook_output_path, + latest_converter.id, + ) + + if converter_report is None: + class_hint = f" '{required_class}'" if required_class else "" + raise JobError( + f"This explorer requires a converter report, but the latest " + f"finished{class_hint} converter did not produce one." + ) + + required_algorithm = explorer_metadata.get("requires_algorithm") + if required_algorithm: + used_algorithm = converter_report.get("algorithm_key", "").lower() + if used_algorithm != required_algorithm.lower(): + raise JobError( + f"This explorer requires the '{required_algorithm}' clustering " + f"algorithm, but the last Clustering converter ran '{used_algorithm}'" + f". Re-run the Clustering converter selecting the " + f"'{required_algorithm}' algorithm." + ) + + return { + "converter_report": converter_report, + "converter_report_source": { + "converter_id": latest_converter.id, + "converter": latest_converter.converter, + }, + } + + class ExplorerJob(BaseJob): """ExplorerJob class to launch explorations.""" @@ -164,6 +248,27 @@ def run( f"Error instancing the explorer {explorer_info.exploration_type}." ) from e + try: + explorer_context = _build_explorer_context( + db, + notebook_info, + explorer_instance, + config, + ) + explorer_instance.set_context(explorer_context) + except JobError: + explorer_info.set_status_as_error() + db.commit() + raise + except Exception as e: + log.exception(e) + explorer_info.set_status_as_error() + db.commit() + raise JobError( + "Error loading context for explorer " + f"{explorer_info.exploration_type}." + ) from e + # prepare the dataset try: prepared_dataset = explorer_instance.prepare_dataset( @@ -185,6 +290,10 @@ def run( result = explorer_instance.launch_exploration( prepared_dataset, explorer_info ) + except (JobError, ValueError) as e: + explorer_info.set_status_as_error() + db.commit() + raise JobError(str(e)) from e except Exception as e: log.exception(e) explorer_info.set_status_as_error() diff --git a/DashAI/back/job/model_job.py b/DashAI/back/job/model_job.py index 7ae8528c7..788ed52f0 100644 --- a/DashAI/back/job/model_job.py +++ b/DashAI/back/job/model_job.py @@ -20,6 +20,7 @@ from sqlalchemy.orm import sessionmaker from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + from DashAI.back.models.base_model import BaseModel logging.basicConfig(level=logging.DEBUG) log = logging.getLogger(__name__) @@ -122,23 +123,26 @@ def run( f"Error preparing dataset and components for run {run_id}: {e}", ) from e - try: - # Get splits from the splitter - splitter: BaseSplitter = preparation_results["splitter"] - # Get the dataset splits between input columns and output column - X, Y = preparation_results["X"], preparation_results["Y"] + requires_target = preparation_results["requires_target"] - # Get x,y but now splitted with train, validation and test indexes - # each one, and the indexes used for the splits - x, y, splits = splitter.split(X, Y) + if requires_target: + try: + # Get splits from the splitter + splitter: BaseSplitter = preparation_results["splitter"] + # Get the dataset splits between input and output columns + X, Y = preparation_results["X"], preparation_results["Y"] - # save the obtained splits into the database - run.split_indexes = json.dumps(splits) - except Exception as e: - log.exception(e) - raise JobError( - f"Error splitting the dataset for run {run_id}: {e}", - ) from e + # Get x,y but now splitted with train, validation and test + # indexes each one, and the indexes used for the splits + x, y, splits = splitter.split(X, Y) + + # save the obtained splits into the database + run.split_indexes = json.dumps(splits) + except Exception as e: + log.exception(e) + raise JobError( + f"Error splitting the dataset for run {run_id}: {e}", + ) from e try: run.set_status_as_started() @@ -154,17 +158,22 @@ def run( # Hyperparameter Tunning plot_paths = [] - evaluation_estrategy: BaseEvaluationStrategy = preparation_results[ - "evaluation_strategy" - ] - - evaluation_estrategy.set_progress_reporter(self.report_progress) - model, plot_paths = evaluation_estrategy.execute( - x=x, - y=y, - run=run, - db=db, - ) + if requires_target: + evaluation_estrategy: BaseEvaluationStrategy = ( + preparation_results["evaluation_strategy"] + ) + + evaluation_estrategy.set_progress_reporter(self.report_progress) + model, plot_paths = evaluation_estrategy.execute( + x=x, + y=y, + run=run, + db=db, + ) + else: + # No evaluation strategy: nothing was held out, so the + # model is fitted once and scored over the whole dataset. + model = self._train_without_target(preparation_results, run, db) except Exception as e: log.exception(e) raise JobError( @@ -301,6 +310,25 @@ def _prepare_dataset_and_components( ), ) from e + # A task that declares no target (clustering) trains on the whole dataset: + # there is nothing to hold out, nothing to optimise against and no + # y_true to score predictions with, so it skips the splitter, the + # optimiser and the evaluation strategy entirely. + # + # This branch is deliberately a flag rather than a task-specific + # executor: the units stack (PRs #791/#792, reverted from develop on + # 2026-08-17) is where per-task steps belong, and building a parallel + # abstraction here would only have to be deleted when it comes back. + if not getattr(task, "REQUIRES_TARGET", True): + return self._prepare_without_target( + run=run, + model_session=model_session, + dataset=dataset, + loaded_dataset=loaded_dataset, + task=task, + component_registry=component_registry, + ) + try: # Prepare dataset for the task and get number of labels of the task prepared_dataset = task.prepare_for_task( @@ -461,8 +489,280 @@ def _prepare_dataset_and_components( ) from e return { + "requires_target": True, "X": X, "Y": Y, "splitter": splitter, "evaluation_strategy": evaluation_strategy, } + + def _prepare_without_target( + self, + run: Run, + model_session: ModelSession, + dataset: Dataset, + loaded_dataset: "DashAIDataset", + task: BaseTask, + component_registry, + ) -> Dict[str, Any]: + """Prepare the dataset and the model for a task with no target column. + + The counterpart of :meth:`_prepare_dataset_and_components` for tasks + whose ``REQUIRES_TARGET`` is False. It resolves only what such a task + can use: the input columns and the model. There is no splitter, no + optimiser and no evaluation strategy, because none of them mean + anything without held-out rows to evaluate against. + + Parameters + ---------- + run : Run + The run being trained. + model_session : ModelSession + The session holding the column selection and the task name. + dataset : Dataset + The dataset record, used for error messages. + loaded_dataset : DashAIDataset + The dataset already loaded from disk. + task : BaseTask + The instantiated task. + component_registry : object + Registry used to resolve the model and the task's metrics. + + Returns + ------- + dict + ``{"requires_target": False, "X", "factory", "metrics"}``. + + Raises + ------ + JobError + If the dataset cannot be prepared or the model cannot be built. + """ + from DashAI.back.dataloaders.classes.dashai_dataset import select_columns + + try: + # No metric picker of its own: every Metric registered for the task + # is computed, since there is no split to choose between. + metric_names = [ + component["name"] + for component in component_registry.get_related_components( + model_session.task_name + ) + if component.get("type") == "Metric" + ] + metrics: List[BaseMetric] = [ + component_registry[name]["class"] for name in metric_names + ] + except Exception as e: + log.exception(e) + raise JobError( + "Unable to find metrics associated with " + f"Task {model_session.task_name} in registry", + ) from e + + try: + prepared_dataset = task.prepare_for_task( + dataset=loaded_dataset, + input_columns=model_session.input_columns, + output_columns=[], + ) + X, _ = select_columns(prepared_dataset, model_session.input_columns, []) + X = self._standardise_features(X) + except Exception as e: + log.exception(e) + raise JobError( + f"""Can not prepare Dataset {dataset.id} + for Task {model_session.task_name}""", + ) from e + + try: + run_model_class = component_registry[run.model_name]["class"] + except Exception as e: + log.exception(e) + raise JobError( + f"Unable to find Model with name {run.model_name} in registry.", + ) from e + + if getattr(run_model_class, "REQUIRES_DOWNLOAD", False) and not ( + run_model_class.is_downloaded() + ): + raise JobError( + f"Model {run.model_name} is not downloaded. " + "Download it before training." + ) + nested_missing = missing_downloads(run.parameters, component_registry) + if nested_missing: + names = ", ".join(m["name"] for m in nested_missing) + raise JobError( + "These components are not downloaded. " + f"Download them before training: {names}." + ) + + try: + factory = ModelFactory( + model=run_model_class, + params=run.parameters, + run_id=run.id, + n_labels=None, + ) + except Exception as e: + log.exception(e) + raise JobError( + f"Unable to instantiate model using run {run.id}", + ) from e + + return { + "requires_target": False, + "X": X, + "factory": factory, + "metrics": metrics, + } + + @staticmethod + def _standardise_features(x: "DashAIDataset") -> "DashAIDataset": + """Centre and scale the input columns of a target free dataset. + + Every clustering algorithm DashAI ships measures distances, so a column + expressed in a wider unit dominates the ones next to it. On a dataset + holding scores from 0 to 100 beside hours from 1 to 11, DBSCAN's default + eps of 0.5 labels every row as noise and Spectral's RBF affinity + underflows to an empty graph, which is why this runs before the model + sees the data. + + Both the model and the metrics are handed the result, so cluster + quality is measured in the space the clusters were found in rather than + the raw one. + + This is the model session path, which has no converter step of its own. + A notebook that already applied the StandardScaler converter reaches the + models through a different route and is not touched here. + + Parameters + ---------- + x : DashAIDataset + Input features, restricted to the session's input columns. + + Returns + ------- + DashAIDataset + The same dataset with its numeric columns standardised. Columns with + no variance are left alone, since dividing them by a zero standard + deviation is what produces the NaNs the models then reject. + """ + from sklearn.preprocessing import StandardScaler # local import + + from DashAI.back.dataloaders.classes.dashai_dataset import to_dashai_dataset + + frame = x.to_pandas() + numeric = frame.select_dtypes(include=["number"]).columns + movable = [c for c in numeric if frame[c].std(ddof=0) > 0] + if not movable: + return x + + frame = frame.copy() + frame[movable] = StandardScaler().fit_transform(frame[movable]) + return to_dashai_dataset(frame) + + def _train_without_target( + self, preparation_results: Dict[str, Any], run: Run, db + ) -> "BaseModel": + """Fit a model with no target column and score it over the whole dataset. + + Parameters + ---------- + preparation_results : dict + The dict returned by :meth:`_prepare_without_target`. + run : Run + The run being trained, used as the metrics' correlation id. + db : object + Open database session the metrics are written through. + + Returns + ------- + BaseModel + The fitted model, for the job's common saving path. + + Raises + ------ + JobError + If training or metric computation fails. + """ + from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum + from DashAI.back.dependencies.database.models import Metric + + x = preparation_results["X"] + model = preparation_results["factory"].model + + try: + model.train(x) + labels = model.get_cluster_labels(x) + except Exception as e: + log.exception(e) + raise JobError(f"Model training failed {e}") from e + + # Internal validity indices are only defined over two or more clusters, + # which is why prepare_to_metric answers None outside that range. Left + # alone, a density based model that sends every sample to noise finishes + # the run green with an empty metric table and nothing pointing at the + # parameters that caused it, so the degenerate outcome is raised here. + import numpy as np # local import + + label_values = np.asarray(labels) + clustered = label_values[label_values != -1] + n_noise = int(label_values.size - clustered.size) + n_clusters = int(np.unique(clustered).size) + if n_clusters < 2 or n_clusters >= clustered.size: + raise JobError( + f"{type(model).__name__} produced {n_clusters} cluster(s) over " + f"{label_values.size} samples, {n_noise} of them labelled as " + "noise. Clustering metrics need at least two clusters, so this " + "run has no result to report. Adjust the model parameters, for " + "instance a larger eps or a smaller min_samples for DBSCAN." + ) + + try: + results = {} + for metric in preparation_results["metrics"]: + score = metric.score(x, labels) + if score is not None: + results[metric.__name__] = score + except Exception as e: + log.exception(e) + raise JobError(f"Metric calculation failed {e}") from e + + # Written straight here rather than through BaseModel.calculate_metrics: + # that path compares y_true against predictions, which is precisely what + # this kind of task does not have. One row per metric, over the full + # dataset, upserted so a re-train replaces the previous values. + try: + for name, value in results.items(): + existing = ( + db.query(Metric) + .filter_by( + run_id=run.id, + split=SplitEnum.FULL, + level=LevelEnum.LAST, + name=name, + ) + .first() + ) + if existing: + existing.value = value + existing.step = 0 + else: + db.add( + Metric( + run_id=run.id, + split=SplitEnum.FULL, + level=LevelEnum.LAST, + name=name, + value=value, + step=0, + ) + ) + db.commit() + except Exception as e: + log.exception(e) + raise JobError(f"Metric saving failed {e}") from e + + return model diff --git a/DashAI/back/metrics/base_metric.py b/DashAI/back/metrics/base_metric.py index a7b0b7fbc..840754308 100644 --- a/DashAI/back/metrics/base_metric.py +++ b/DashAI/back/metrics/base_metric.py @@ -7,9 +7,10 @@ class BaseMetric: """Abstract base class for all DashAI evaluation metrics. Every concrete metric must subclass ``BaseMetric`` (or one of its - category subclasses) and implement a static ``score`` method that - accepts the true labels/values and model predictions and returns a - scalar float. + category subclasses) and implement a static ``score`` method. The concrete + method signature depends on the evaluation family: supervised metrics use + true outputs and predictions, while clustering metrics use feature data and + discovered labels. Class attributes ---------------- diff --git a/DashAI/back/metrics/clustering/__init__.py b/DashAI/back/metrics/clustering/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/DashAI/back/metrics/clustering/calinski_harabasz.py b/DashAI/back/metrics/clustering/calinski_harabasz.py new file mode 100644 index 000000000..116a81f9c --- /dev/null +++ b/DashAI/back/metrics/clustering/calinski_harabasz.py @@ -0,0 +1,68 @@ +"""Calinski-Harabasz clustering metric.""" + +from typing import TYPE_CHECKING + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.metrics.clustering_metric import ( + ClusteringMetric, + prepare_to_metric, +) + +if TYPE_CHECKING: + import numpy as np + import pandas as pd + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class CalinskiHarabasz(ClusteringMetric): + """Ratio between inter-cluster and intra-cluster dispersion. + + Calinski-Harabasz evaluates how dense and well-separated clusters are by + comparing dispersion between clusters with dispersion within clusters. + Higher values generally indicate better-defined clusters. + + Range: [0, +inf), higher is better (``MAXIMIZE = True``). + + References + ---------- + - [1] Calinski, T. & Harabasz, J. (1974). "A dendrite method for cluster + analysis." Communications in Statistics, 3(1), 1-27. + - [2] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.calinski_harabasz_score.html + """ + + MAXIMIZE = True + DESCRIPTION = MultilingualString( + en="Higher values indicate dense and well-separated clusters.", + es="Valores mayores indican clusters densos y bien separados.", + pt="Valores mais altos indicam clusters densos e bem separados.", + de="Höhere Werte deuten auf dichte und gut getrennte Cluster hin.", + zh="值越高表示聚类越密集、分离度越好。", + ) + + @staticmethod + def score( + x: "DashAIDataset | pd.DataFrame", + labels: "np.ndarray | list", + ) -> float | None: + """Calculate the Calinski-Harabasz score for clustering assignments. + + Parameters + ---------- + x : DashAIDataset or pandas.DataFrame + Feature data used by the clustering model. + labels : np.ndarray or list + Cluster labels assigned to each sample. + + Returns + ------- + float | None + Calinski-Harabasz score, or ``None`` when the score is not defined + for the current label distribution. + """ + from sklearn.metrics import calinski_harabasz_score + + x_values, label_values = prepare_to_metric(x, labels) + if x_values is None: + return None + return float(calinski_harabasz_score(x_values, label_values)) diff --git a/DashAI/back/metrics/clustering/davies_bouldin.py b/DashAI/back/metrics/clustering/davies_bouldin.py new file mode 100644 index 000000000..05b6184c3 --- /dev/null +++ b/DashAI/back/metrics/clustering/davies_bouldin.py @@ -0,0 +1,69 @@ +"""Davies-Bouldin clustering metric.""" + +from typing import TYPE_CHECKING + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.metrics.clustering_metric import ( + ClusteringMetric, + prepare_to_metric, +) + +if TYPE_CHECKING: + import numpy as np + import pandas as pd + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class DaviesBouldin(ClusteringMetric): + """Average similarity between each cluster and its closest cluster. + + Davies-Bouldin compares within-cluster dispersion with separation between + clusters. Lower values indicate that clusters are compact and far from one + another. A value of 0 is ideal, though uncommon in real datasets. + + Range: [0, +inf), lower is better (``MAXIMIZE = False``). + + References + ---------- + - [1] Davies, D. L. & Bouldin, D. W. (1979). "A Cluster Separation + Measure." IEEE Transactions on Pattern Analysis and Machine + Intelligence, PAMI-1(2), 224-227. + - [2] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.davies_bouldin_score.html + """ + + MAXIMIZE = False + DESCRIPTION = MultilingualString( + en="Lower values indicate clusters that are more compact and separated.", + es="Valores menores indican clusters mas compactos y separados.", + pt="Valores mais baixos indicam clusters mais compactos e separados.", + de="Niedrigere Werte deuten auf kompaktere und besser getrennte Cluster hin.", + zh="值越低表示聚类越紧密、分离度越好。", + ) + + @staticmethod + def score( + x: "DashAIDataset | pd.DataFrame", + labels: "np.ndarray | list", + ) -> float | None: + """Calculate the Davies-Bouldin score for clustering assignments. + + Parameters + ---------- + x : DashAIDataset or pandas.DataFrame + Feature data used by the clustering model. + labels : np.ndarray or list + Cluster labels assigned to each sample. + + Returns + ------- + float | None + Davies-Bouldin score, or ``None`` when the score is not defined for + the current label distribution. + """ + from sklearn.metrics import davies_bouldin_score + + x_values, label_values = prepare_to_metric(x, labels) + if x_values is None: + return None + return float(davies_bouldin_score(x_values, label_values)) diff --git a/DashAI/back/metrics/clustering/silhouette.py b/DashAI/back/metrics/clustering/silhouette.py new file mode 100644 index 000000000..c0c56c277 --- /dev/null +++ b/DashAI/back/metrics/clustering/silhouette.py @@ -0,0 +1,71 @@ +"""Silhouette clustering metric.""" + +from typing import TYPE_CHECKING + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.metrics.clustering_metric import ( + ClusteringMetric, + prepare_to_metric, +) + +if TYPE_CHECKING: + import numpy as np + import pandas as pd + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class Silhouette(ClusteringMetric): + """Mean Silhouette Coefficient over all clustered samples. + + Silhouette measures how similar each sample is to samples in its own + cluster compared with samples in the nearest different cluster. Values + close to 1 indicate compact and well-separated clusters, values near 0 + indicate overlapping clusters, and negative values suggest that samples + may have been assigned to the wrong cluster. + + Range: [-1, 1], higher is better (``MAXIMIZE = True``). + + References + ---------- + - [1] Rousseeuw, P. J. (1987). "Silhouettes: A graphical aid to the + interpretation and validation of cluster analysis." Journal of + Computational and Applied Mathematics, 20, 53-65. + - [2] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.silhouette_score.html + """ + + MAXIMIZE = True + DESCRIPTION = MultilingualString( + en="Measures how well samples fit within their assigned clusters.", + es="Mide que tan bien las muestras calzan dentro de sus clusters asignados.", + pt="Mede o quão bem as amostras se encaixam em seus clusters atribuídos.", + de="Misst, wie gut die Stichproben in ihre zugewiesenen Cluster passen.", + zh="衡量样本与其所分配聚类的契合程度。", + ) + + @staticmethod + def score( + x: "DashAIDataset | pd.DataFrame", + labels: "np.ndarray | list", + ) -> float | None: + """Calculate the Silhouette score for clustering assignments. + + Parameters + ---------- + x : DashAIDataset or pandas.DataFrame + Feature data used by the clustering model. + labels : np.ndarray or list + Cluster labels assigned to each sample. + + Returns + ------- + float | None + Silhouette score, or ``None`` when the score is not defined for the + current label distribution. + """ + from sklearn.metrics import silhouette_score + + x_values, label_values = prepare_to_metric(x, labels) + if x_values is None: + return None + return float(silhouette_score(x_values, label_values)) diff --git a/DashAI/back/metrics/clustering_metric.py b/DashAI/back/metrics/clustering_metric.py new file mode 100644 index 000000000..62cacb754 --- /dev/null +++ b/DashAI/back/metrics/clustering_metric.py @@ -0,0 +1,129 @@ +from typing import TYPE_CHECKING, Dict, Type + +from DashAI.back.metrics.base_metric import BaseMetric + +if TYPE_CHECKING: + import numpy as np + import pandas as pd + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class ClusteringMetric(BaseMetric): + """Base class for all clustering evaluation metrics. + + Clustering metrics evaluate the quality of groups discovered by an + unsupervised model. Unlike supervised metrics, they do not receive + ground-truth targets. Concrete metrics operate on the input feature + matrix and the labels assigned by the clustering model. + + This base class does not define a shared optimization direction because + clustering validity indices differ: some metrics are maximized + (Silhouette, Calinski-Harabasz), while others are minimized + (Davies-Bouldin). Concrete metric classes must set ``MAXIMIZE`` according + to their mathematical interpretation. + """ + + COMPATIBLE_COMPONENTS = ["ClusteringTask"] + + @classmethod + def get_registry(cls) -> Dict[str, Type["ClusteringMetric"]]: + """Return concrete clustering metric classes keyed by class name. + + Any subclass that defines its own ``score`` method is treated as a + concrete metric and included automatically. New metrics are picked up + as soon as their module is imported — no manual registration needed. + """ + result: Dict[str, Type["ClusteringMetric"]] = {} + + def _collect(base: Type["ClusteringMetric"]) -> None: + for sub in base.__subclasses__(): + if "score" in sub.__dict__: + result[sub.__name__] = sub + _collect(sub) + + _collect(cls) + return result + + +def validate_inputs( + x_values: "pd.DataFrame", + label_values: "np.ndarray", +) -> None: + """Validate feature rows and cluster labels before scoring. + + Parameters + ---------- + x_values : pandas.DataFrame + Feature matrix used to fit or evaluate the clustering model. + label_values : np.ndarray + Cluster assignment for each row in ``x_values``. + + Raises + ------ + ValueError + If the number of samples and labels is different. + """ + if len(x_values) != len(label_values): + raise ValueError("Feature rows and cluster labels must have the same length.") + + +def prepare_to_metric( + x: "DashAIDataset | pd.DataFrame", + labels: "np.ndarray | list", +) -> tuple["pd.DataFrame | None", "np.ndarray | None"]: + """Prepare feature data and labels for internal clustering metrics. + + Converts DashAI datasets or pandas data frames into the numeric feature + matrix accepted by sklearn clustering metrics. Noise labels (``-1``), used + by density-based algorithms such as DBSCAN, are excluded because internal + validity indices are defined over clusters. + + Parameters + ---------- + x : DashAIDataset or pandas.DataFrame + Feature data selected. + labels : np.ndarray or list + Cluster labels produced by the model. + + Returns + ------- + tuple[pandas.DataFrame | None, np.ndarray | None] + Prepared feature matrix and labels. Returns ``(None, None)`` when the + score is not mathematically defined, for example when fewer than two + clusters remain after filtering noise. + + Raises + ------ + ValueError + If there are no numeric feature columns or the number of rows and labels + does not match. + """ + import numpy as np + import pandas as pd + + x_values = x.to_pandas() if hasattr(x, "to_pandas") else x + if isinstance(x_values, pd.DataFrame): + x_values = x_values.select_dtypes(include=["number"]) + if x_values.shape[1] == 0: + raise ValueError("Clustering metrics require at least one numeric column.") + else: + raise TypeError( + "Clustering metrics expect a DashAIDataset or pandas DataFrame." + ) + + label_values = np.asarray(labels) + validate_inputs(x_values, label_values) + + valid_mask = label_values != -1 + if hasattr(x_values, "iloc"): + x_values = x_values.iloc[valid_mask] + else: + x_values = x_values[valid_mask] + label_values = label_values[valid_mask] + + unique_labels = np.unique(label_values) + if len(unique_labels) < 2 or len(unique_labels) >= len(label_values): + return None, None + + return x_values, label_values diff --git a/DashAI/back/models/base_model.py b/DashAI/back/models/base_model.py index f53be2c6d..eae1dd051 100644 --- a/DashAI/back/models/base_model.py +++ b/DashAI/back/models/base_model.py @@ -1,15 +1,10 @@ """Base Model abstract class.""" import logging -import math from abc import ABCMeta, abstractmethod -from typing import TYPE_CHECKING, Any, Dict, Final, final - -from kink import di +from typing import TYPE_CHECKING, Any, Dict, Final from DashAI.back.config_object import ConfigObject -from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum -from DashAI.back.dependencies.database.models import Metric if TYPE_CHECKING: from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset @@ -21,8 +16,8 @@ class BaseModel(ConfigObject, metaclass=ABCMeta): """Abstract base class for all machine learning models in DashAI. All models must extend this class and implement the abstract methods - `save`, `load`, and `train`. The `calculate_metrics` and - `prepare_dataset` methods provide optional hooks for subclasses. + `save`, `load`, and `train`. Evaluation is owned by task-specific + executors or mixins, not by the universal model contract. """ TYPE: Final[str] = "Model" @@ -92,25 +87,11 @@ def load(self, filename: str) -> Any: @abstractmethod def train( self, - x_train: "DashAIDataset", - y_train: "DashAIDataset", - x_validation: "DashAIDataset" = None, - y_validation: "DashAIDataset" = None, + *args, + **kwargs, ) -> "BaseModel": - """Train the model with the provided data. - - Parameters - ---------- - x_train : DashAIDataset - The input features for training. - y_train : DashAIDataset - The target labels for training. - x_validation : DashAIDataset, optional - Input features for - validation. Defaults to None. - y_validation : DashAIDataset, optional - Target labels for - validation. Defaults to None. + """Train the model with the data required by its task executor. + The concrete signature depends on the modeling problem. Returns ------- @@ -119,281 +100,6 @@ def train( """ raise NotImplementedError - @final - def _save_metrics( - self, - split: SplitEnum, - level: LevelEnum, - results: Dict[str, float], - log_index: int = None, - fold_index: int = None, - inner_fold_index: int = None, - ): - """Persist computed metric values to the database. - - Handles step-index computation and upsert logic for LAST-level metrics. - Called internally by `calculate_metrics` after scores are computed. - - Parameters - ---------- - split : SplitEnum - The data split the metrics belong to (TRAIN, - VALIDATION, or TEST). - level : LevelEnum - The granularity level (LAST, TRIAL, STEP, or - BATCH). LAST-level entries are upserted; others are inserted. - results : Dict[str, float] - Mapping of metric name to score value. - log_index : int, optional - Explicit step index for the entries. - If None, the next index is derived from existing database - entries. Defaults to None. - """ - with di["session_factory"]() as db: - # Initialize tracking dict if not exists - if not hasattr(self, "_metric_step_counters"): - self._metric_step_counters = {} - - # Create a unique key for this run/split/level combination - counter_key = (self.run_id, split, level) - - # 1. Determine log_index - if counter_key not in self._metric_step_counters: - steps = ( - db.query(Metric.step) - .filter_by(run_id=self.run_id, split=split, level=level) - .order_by(Metric.step.desc()) - .limit(2) - .all() - ) - - if not steps: - current, previous = 0, 0 - elif len(steps) == 1: - current, previous = steps[0][0], 0 - else: - current, previous = steps[0][0], steps[1][0] - - self._metric_step_counters[counter_key] = { - "current": current, - "previous": previous, - } - - counter = self._metric_step_counters[counter_key] - - current_max = counter["current"] - previous_max = counter["previous"] - - # Compute delta (preserve spacing) - delta = current_max - previous_max - if delta <= 0: - delta = 1 - - # Case 1: no log_index -> advance naturally - if log_index is None or log_index <= current_max: - log_index = current_max + delta - - # Update the in-memory tracker - counter["previous"] = current_max - counter["current"] = log_index - - # 2. Handle 'LAST' level replacement logic - if level == LevelEnum.LAST: - for name, value in results.items(): - existing = ( - db.query(Metric) - .filter_by( - run_id=self.run_id, split=split, level=level, name=name - ) - .first() - ) - - if existing: - existing.value = value - existing.step = log_index - else: - db.add( - Metric( - run_id=self.run_id, - split=split, - level=level, - name=name, - value=value, - step=log_index, - ) - ) - - # 3. Standard logging (STEP, BATCH, TRIAL) - just insert - else: - metric_entries = [ - Metric( - run_id=self.run_id, - split=split, - level=level, - name=name, - value=score, - step=log_index, - fold_index=fold_index, - inner_fold_index=inner_fold_index, - ) - for name, score in results.items() - ] - db.add_all(metric_entries) - - db.commit() - - @final - def calculate_metrics( - self, - split: SplitEnum = SplitEnum.VALIDATION, - level: LevelEnum = LevelEnum.LAST, - log_index: int = None, - x_data: "DashAIDataset" = None, - y_data: "DashAIDataset" = None, - fold_index: int = None, - inner_fold_index: int = None, - ): - """Calculate and save metrics for a given data split and level. - - Parameters - ---------- - split : SplitEnum - The data split to evaluate (TRAIN, VALIDATION, - or TEST). Defaults to SplitEnum.VALIDATION. - level : LevelEnum - The metric granularity level (LAST, TRIAL, - STEP, or BATCH). Defaults to LevelEnum.LAST. - log_index : int, optional - Explicit step index for the metric - entry. If None, the next step index is computed automatically. - Defaults to None. - x_data : DashAIDataset, optional - Input features. If None, the - dataset stored in the model for the given split is used. - Defaults to None. - y_data : DashAIDataset, optional - Target labels. If None, the - labels stored in the model for the given split are used. - Defaults to None. - """ - # Get the appropriate metrics based on split - metrics_attr = f"{split.value}_metrics" - metrics = getattr(self, metrics_attr, None) - - # If no metrics or run_id, skip calculation - if not metrics or not self.run_id: - return - - # Load data if not provided - if x_data is None or y_data is None: - if self.x_data is None or self.y_data is None: - return - x_data = self.x_data[split.value] - y_data = self.y_data[split.value] - - # If data is empty after retrieval, skip calculation - if x_data is None or y_data is None: - return - - # Make predictions and transform outputs - y_pred = self.predict(x_data) - y_transformed = self.prepare_output(y_data, is_fit=False) - - # Calculate metric scores - results = {} - for metric in metrics: - score = metric.score(y_transformed, y_pred) - if not math.isfinite(score): - logger.warning( - "Metric %s returned a non-finite value (%s) for split %s " - "(e.g. only one class present in the split). Skipping.", - metric.__name__, - score, - split, - ) - continue - results[metric.__name__] = score - - # Save to database - self._save_metrics( - split=split, - level=level, - results=results, - log_index=log_index, - fold_index=fold_index, - inner_fold_index=inner_fold_index, - ) - - # Report the epoch to whoever is watching, AFTER persisting: the reporter - # is allowed to raise (Optuna prunes that way), and the metrics of the - # epoch that triggered the stop should survive it. - if ( - self._epoch_reporter is not None - and level is LevelEnum.EPOCH - and split is SplitEnum.VALIDATION - ): - self._epoch_reporter(results, log_index) - - # Create a function similar to calculate_metrics that returns the scores - # instead of saving them to the database, to be used in the CV evaluation loop - def compute_metrics( - self, - split: SplitEnum = SplitEnum.TEST, - x_data: "DashAIDataset" = None, - y_data: "DashAIDataset" = None, - ) -> Dict[str, float]: - """Calculate and return metric scores for a given data split. - - Parameters - ---------- - split : SplitEnum - The data split to evaluate (TRAIN, VALIDATION, - or TEST). Defaults to SplitEnum.VALIDATION. - x_data : DashAIDataset, optional - Input features. If None, the - dataset stored in the model for the given split is used. - Defaults to None. - y_data : DashAIDataset, optional - Target labels. If None, the - labels stored in the model for the given split are used. - Defaults to None. - - Returns - ------- - Dict[str, float] - A dictionary mapping metric names to their computed scores. - """ - # Get the appropriate metrics based on split - metrics_attr = f"{split.value}_metrics" - metrics = getattr(self, metrics_attr, None) - - # If no metrics, return empty dict - if not metrics: - return {} - - # Load data if not provided - if x_data is None or y_data is None: - if self.x_data is None or self.y_data is None: - return {} - x_data = self.x_data[split.value] - y_data = self.y_data[split.value] - - # If data is empty after retrieval, return empty dict - if x_data is None or y_data is None: - return {} - - # Make predictions and transform outputs - y_pred = self.predict(x_data) - y_transformed = self.prepare_output(y_data, is_fit=False) - - # Calculate metric scores - results = {} - for metric in metrics: - score = metric.score(y_transformed, y_pred) - results[metric.__name__] = score - - return results - def prepare_dataset( self, dataset: "DashAIDataset", is_fit: bool = False ) -> "DashAIDataset": @@ -487,6 +193,9 @@ def prepare_output( ) -> "DashAIDataset": """Hook for model-specific preprocessing of output targets. + This default exists for backward compatibility with supervised models that + preprocess targets. Unsupervised models are not required to use it. + By default, delegates to `prepare_dataset`. Override in subclasses that need separate input and output preprocessing logic. diff --git a/DashAI/back/models/base_torchvision_image_classifier.py b/DashAI/back/models/base_torchvision_image_classifier.py index 700cd5f24..92bdb908c 100644 --- a/DashAI/back/models/base_torchvision_image_classifier.py +++ b/DashAI/back/models/base_torchvision_image_classifier.py @@ -13,8 +13,8 @@ schema_field, ) from DashAI.back.core.utils import MultilingualString -from DashAI.back.models.base_model import BaseModel from DashAI.back.models.image_explainable_model import GradCamCompatibleModel +from DashAI.back.models.supervised_model import SupervisedModel from DashAI.back.models.utils import DEVICE_ENUM, DEVICE_PLACEHOLDER, DEVICE_TO_IDX @@ -338,7 +338,7 @@ def __getitem__(self, idx): return _ImageDataset(x_dataset, y_dataset, image_size) -class TorchvisionImageClassifier(BaseModel, GradCamCompatibleModel, abc.ABC): +class TorchvisionImageClassifier(SupervisedModel, GradCamCompatibleModel, abc.ABC): """Abstract base for torchvision image classifiers. Subclasses must implement: diff --git a/DashAI/back/models/clustering_model.py b/DashAI/back/models/clustering_model.py new file mode 100644 index 000000000..1057c2d2d --- /dev/null +++ b/DashAI/back/models/clustering_model.py @@ -0,0 +1,102 @@ +from abc import abstractmethod +from typing import TYPE_CHECKING, Any, Dict, Sequence, Type + +from DashAI.back.models.base_model import BaseModel + +if TYPE_CHECKING: + from numpy import ndarray + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class ClusteringModel(BaseModel): + """Base contract for models that perform clustering tasks. + + Concrete clustering models receive input features and produce discovered + cluster assignments. They are evaluated by clustering executors with + metrics that use ``X`` and labels, not ``y_true`` and ``y_pred``. Backend + adapters such as sklearn or FAISS should implement this contract once so + concrete clustering algorithms can remain small. + """ + + COMPATIBLE_COMPONENTS = ["ClusteringTask"] + + @classmethod + def get_registry(cls) -> Dict[str, Type["ClusteringModel"]]: + """Return concrete clustering algorithm classes keyed by class name. + + Any subclass that declares its own ``SCHEMA`` is treated as a concrete + algorithm and included automatically. New algorithms become available + to the ``Clustering`` converter as soon as their module is imported — + no manual registration step is required. + """ + result: Dict[str, Type["ClusteringModel"]] = {} + + def _collect(base: Type["ClusteringModel"]) -> None: + for sub in base.__subclasses__(): + if "SCHEMA" in sub.__dict__: + result[sub.__name__] = sub + _collect(sub) + + _collect(cls) + return result + + def get_fit_attributes(self) -> Dict[str, Any]: + """Return algorithm-specific post-fit attributes for the converter report. + + Override in concrete subclasses to expose attributes that are + specific to the clustering algorithm (e.g. cluster centres for K-Means, + linkage data for Agglomerative). The converter calls this after ``train`` + and includes the result in the execution report consumed by explorers. + + Returns + ------- + Dict[str, Any] + JSON-serializable dict of algorithm-specific attributes. + """ + return {} + + @abstractmethod + def train(self, x: "DashAIDataset") -> "ClusteringModel": + """Fit the clustering model using input features only. + + Clustering models do not receive target columns. Implementations should + store the labels discovered during fitting when the backend exposes + them only for the training data. + + Parameters + ---------- + x : DashAIDataset + Input feature matrix used to fit the clustering model. + + Returns + ------- + ClusteringModel + The fitted clustering model instance. + """ + raise NotImplementedError + + @abstractmethod + def get_cluster_labels( + self, x: "DashAIDataset" = None + ) -> "ndarray | Sequence[int]": + """Return cluster labels for fitted data or provided samples. + + Algorithms that support assigning labels to new samples may use ``x``. + Algorithms that only expose labels for the fitted dataset should return + those stored labels when ``x`` is omitted. + + Parameters + ---------- + x : DashAIDataset, optional + Input samples to assign to clusters. If omitted, the method should + return labels discovered during fitting when the backend supports + only fitted-data labels. + + Returns + ------- + array-like + Cluster label assigned to each sample. Noise points may be encoded + with backend-specific labels such as ``-1``. + """ + raise NotImplementedError diff --git a/DashAI/back/models/cnn_image_classifier.py b/DashAI/back/models/cnn_image_classifier.py index acbc81cdb..63dc6c55b 100644 --- a/DashAI/back/models/cnn_image_classifier.py +++ b/DashAI/back/models/cnn_image_classifier.py @@ -10,8 +10,8 @@ schema_field, ) from DashAI.back.core.utils import MultilingualString -from DashAI.back.models.base_model import BaseModel from DashAI.back.models.image_explainable_model import GradCamCompatibleModel +from DashAI.back.models.supervised_model import SupervisedModel from DashAI.back.models.utils import DEVICE_ENUM, DEVICE_PLACEHOLDER, DEVICE_TO_IDX @@ -403,7 +403,7 @@ def forward(self, x): ) -class CNNImageClassifier(BaseModel, GradCamCompatibleModel): +class CNNImageClassifier(SupervisedModel, GradCamCompatibleModel): """CNN-based image classifier. A convolutional neural network with configurable depth and width that diff --git a/DashAI/back/models/faiss/__init__.py b/DashAI/back/models/faiss/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/DashAI/back/models/faiss/faiss_base_model.py b/DashAI/back/models/faiss/faiss_base_model.py new file mode 100644 index 000000000..d4ba84dbb --- /dev/null +++ b/DashAI/back/models/faiss/faiss_base_model.py @@ -0,0 +1,153 @@ +"""Infrastructure mixin for FAISS-backed DashAI models. + +This mixin is not tied to any specific task type (clustering, regression, etc.). +Any future DashAI model backed by a FAISS index — regardless of task — can +inherit from this class to get float32 conversion, save/load, and pickle support +for FAISS indexes at no extra cost. +""" + +from typing import TYPE_CHECKING, Any + +import numpy as np + +if TYPE_CHECKING: + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class FaissBaseModel: + """Mixin providing FAISS infrastructure for DashAI models. + + Handles the three concerns shared by every FAISS-backed model: + + 1. **Data conversion** — DashAIDataset → contiguous float32 numpy array, + which is the only format FAISS accepts. + 2. **Persistence** — ``save`` / ``load`` via joblib, with a custom pickle + protocol so FAISS index objects (which are not natively picklable) are + serialised as raw bytes using ``faiss.serialize_index``. + 3. **Extensibility** — any future model type (classification, regression, + retrieval) can reuse this mixin without coupling to the clustering + contract. + """ + + def _to_float32(self, x_dataset: "DashAIDataset") -> np.ndarray: + """Convert a DashAIDataset to a contiguous float32 numpy array. + + Only numeric columns are retained. FAISS requires a C-contiguous + float32 matrix; ``np.ascontiguousarray`` guarantees that even after + column selection. + + Parameters + ---------- + x_dataset : DashAIDataset + Input dataset to convert. + + Returns + ------- + numpy.ndarray of shape (n_samples, n_features), dtype float32 + + Raises + ------ + ValueError + If no numeric columns are present in the dataset. + """ + x_pandas = x_dataset.to_pandas() + numeric = x_pandas.select_dtypes(include=["number"]) + if numeric.empty: + raise ValueError( + f"{self.__class__.__name__} requires at least one numeric feature." + ) + return np.ascontiguousarray(numeric.values, dtype=np.float32) + + def save(self, filename: str) -> None: + """Save the fitted model to disk using joblib. + + The custom ``__getstate__`` protocol serialises any FAISS index + stored in ``self._index`` before joblib pickles the object. + + Parameters + ---------- + filename : str + Destination path for the serialised model. + """ + import joblib + + joblib.dump(self, filename) + + @staticmethod + def load(filename: str) -> Any: + """Restore a fitted model from disk. + + Declared static to match every other model adapter: the job layer + reloads a run through ``model_cls.load(path)``, so an instance method + here would bind the path to ``self``. + + Parameters + ---------- + filename : str + Path to a previously saved model file. + + Returns + ------- + Any + The deserialised model instance. + """ + import joblib + + return joblib.load(filename) + + @staticmethod + def _import_faiss(): + """Import FAISS with its thread pool pinned to one thread on macOS. + + FAISS ships its own OpenMP runtime and so does torch, which any process + running DashAI has already loaded. Two OpenMP runtimes in one process + deadlock on macOS the first time FAISS opens a parallel region: a CI run + hung for six hours inside ``FaissKMeansClustering.train`` while the six + scikit-learn clusterers beside it finished in under a second, on the + same commit where Linux and Windows passed in four minutes. + + Pinning FAISS to a single thread keeps it out of that parallel region. + Only macOS is constrained, so the acceleration this adapter exists for + is untouched everywhere else. + + Returns + ------- + module + The imported ``faiss`` module. + + Raises + ------ + ImportError + If the ``faiss`` package is not installed. + """ + import sys + + try: + import faiss + except ImportError as exc: + raise ImportError( + "FAISS is required. Install it with: pip install faiss-cpu" + ) from exc + + if sys.platform == "darwin": + faiss.omp_set_num_threads(1) + return faiss + + def __getstate__(self) -> dict: + """Serialise the FAISS index as raw bytes before pickling.""" + state = self.__dict__.copy() + if state.get("_index") is not None: + faiss = self._import_faiss() + + state["_index"] = faiss.serialize_index(state["_index"]).tobytes() + state["_index_serialized"] = True + return state + + def __setstate__(self, state: dict) -> None: + """Restore the FAISS index from raw bytes after unpickling.""" + if state.pop("_index_serialized", False): + faiss = self._import_faiss() + + index_bytes = np.frombuffer(state["_index"], dtype=np.uint8) + state["_index"] = faiss.deserialize_index(index_bytes) + self.__dict__.update(state) diff --git a/DashAI/back/models/faiss/faiss_dbscan_clustering.py b/DashAI/back/models/faiss/faiss_dbscan_clustering.py new file mode 100644 index 000000000..569399994 --- /dev/null +++ b/DashAI/back/models/faiss/faiss_dbscan_clustering.py @@ -0,0 +1,281 @@ +"""DashAI FAISS-accelerated DBSCAN clustering model. + +This module demonstrates FAISS as an accelerator for a sklearn-style algorithm. + +Standard DBSCAN (sklearn) is O(n²) for the epsilon-neighbourhood search: it +compares every point against every other point to find all neighbours within +distance eps. On a dataset of 100 000 samples that is 10 billion comparisons. + +FAISS-DBSCAN replaces that bottleneck with an efficient range search on a FAISS +index. ``IndexFlatL2.range_search(x, eps²)`` retrieves, for each point, all +vectors within squared-L2 distance eps² in a fraction of the time. The DBSCAN +cluster-labelling logic (core points, BFS expansion, noise assignment) is then +applied to the resulting neighbour lists — exactly the same as the original +algorithm, just built on top of a much faster distance oracle. + +Install FAISS with: pip install faiss-cpu (or faiss-gpu for GPU support) +""" + +import numpy as np + +from DashAI.back.core.schema_fields import ( + float_field, + int_field, + schema_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.faiss.faiss_like_clusterer import FaissLikeClusterer + +if False: # TYPE_CHECKING + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class FaissDBSCANClusteringSchema(BaseSchema): + """Schema that configures the FAISS-accelerated DBSCAN model. + + Parameters are identical to sklearn's DBSCAN. The only difference is that + neighbourhood search is delegated to a FAISS IndexFlatL2 (Euclidean metric), + making the algorithm practical on large datasets where sklearn's brute-force + O(n²) search becomes a bottleneck. + """ + + eps: schema_field( + float_field(gt=0.0), + placeholder=0.5, + description=MultilingualString( + en=( + "Maximum Euclidean distance between two samples to be " + "considered neighbours." + ), + es=( + "Distancia euclidiana máxima entre dos muestras para " + "considerarlas vecinas." + ), + pt=( + "Distância euclidiana máxima entre duas amostras para " + "serem consideradas vizinhas." + ), + de=( + "Maximaler euklidischer Abstand zwischen zwei Stichproben, um " + "als Nachbarn zu gelten." + ), + zh="两个样本被视为邻居的最大欧氏距离。", + ), + alias=MultilingualString(en="Eps", es="Eps", pt="Eps", de="Eps", zh="Eps"), + ) # type: ignore + min_samples: schema_field( + int_field(ge=1), + placeholder=5, + description=MultilingualString( + en="Minimum samples in a neighbourhood for a point to be a core point " + "(including the point itself).", + es="Mínimo de muestras en una vecindad para que un punto sea central " + "(incluido el propio punto).", + pt="Mínimo de amostras em uma vizinhança para que um ponto seja " + "central (incluindo o próprio ponto).", + de="Mindestanzahl an Stichproben in einer Nachbarschaft, damit ein " + "Punkt als Kernpunkt gilt (einschließlich des Punktes selbst).", + zh="一个点成为核心点所需邻域内的最小样本数(含该点本身)。", + ), + alias=MultilingualString( + en="Min samples", + es="Mínimo de muestras", + pt="Mínimo de amostras", + de="Min. Stichproben", + zh="最小样本数", + ), + ) # type: ignore + + +class FaissDBSCANClustering(FaissLikeClusterer): + """FAISS-accelerated DBSCAN clustering model for the Models module. + + Replaces sklearn DBSCAN's O(n²) epsilon-neighbourhood search with FAISS + ``IndexFlatL2.range_search``, which uses BLAS-optimised distance + computation. The cluster-labelling step (core-point detection, BFS + expansion, noise assignment) is identical to the original algorithm and + is implemented in pure Python/NumPy. + + Noise samples receive label ``-1``, the same convention as sklearn DBSCAN + and sklearn HDBSCAN. The algorithm does not require specifying the number + of clusters in advance. + + Key hyperparameters: ``eps`` (neighbourhood radius), ``min_samples`` + (core-point threshold). Only the Euclidean (L2) metric is supported + because FAISS ``IndexFlatL2`` computes squared L2 distances. + + References + ---------- + - [1] Ester, M., Kriegel, H.-P., Sander, J., & Xu, X. (1996). + "A density-based algorithm for discovering clusters in large + spatial databases with noise." + - [2] Johnson, J., Douze, M., & Jégou, H. (2019). "Billion-scale + similarity search with GPUs." + - [3] https://github.com/facebookresearch/faiss + """ + + SCHEMA = FaissDBSCANClusteringSchema + DISPLAY_NAME = MultilingualString( + en="FAISS-DBSCAN", + es="FAISS-DBSCAN", + pt="FAISS-DBSCAN", + de="FAISS-DBSCAN", + zh="FAISS-DBSCAN", + ) + DESCRIPTION = MultilingualString( + en="DBSCAN with FAISS-accelerated neighbourhood search for large datasets.", + es="DBSCAN con búsqueda de vecindad acelerada por FAISS para datasets grandes.", + pt="DBSCAN com busca de vizinhança acelerada por FAISS para datasets grandes.", + de="DBSCAN mit FAISS-beschleunigter Nachbarschaftssuche für große Datensätze.", + zh="使用FAISS加速邻域搜索的DBSCAN,适用于大型数据集。", + ) + COLOR = "#FFA726" + ICON = "Bolt" + + def __init__( + self, + eps: float = 0.5, + min_samples: int = 5, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.eps = eps + self.min_samples = min_samples + + def get_fit_attributes(self) -> dict: + """Return FAISS-DBSCAN post-fit attributes for the converter report.""" + n_noise = int(np.sum(self._labels == -1)) + return {"n_noise_points": n_noise} if n_noise > 0 else {} + + def train(self, x_train: "DashAIDataset") -> "FaissDBSCANClustering": + """Fit FAISS-DBSCAN: build index, run range search, label clusters. + + The neighbourhood search uses ``IndexFlatL2.range_search(x, eps²)``. + FAISS computes squared Euclidean distances, so the threshold passed + to ``range_search`` is ``eps ** 2``, not ``eps``. + + The query point itself is always returned as its own neighbour + (distance 0), so ``min_samples=5`` means a point needs 4 additional + neighbours within ``eps`` to become a core point — matching sklearn's + convention exactly. + + Parameters + ---------- + x_train : DashAIDataset + Input feature matrix. + + Returns + ------- + FaissDBSCANClustering + The fitted model instance. + + Raises + ------ + ImportError + If the ``faiss`` package is not installed. + """ + faiss = self._import_faiss() + + x = self._to_float32(x_train) + n, d = x.shape + + # Build exact L2 index and add all points + self._index = faiss.IndexFlatL2(d) + self._index.add(x) + + # range_search returns (lims, distances, indices) + # threshold is squared L2 because IndexFlatL2 computes ||a-b||² + lims, _D, indices = self._index.range_search(x, self.eps**2) + + # Convert FAISS compact output to per-point neighbour lists + neighbors = [indices[lims[i] : lims[i + 1]].tolist() for i in range(n)] + + self._labels = self._label_clusters(neighbors, n, self.min_samples) + return self + + def get_cluster_labels(self, x: "DashAIDataset | None" = None) -> np.ndarray: + """Return cluster labels from the fitted model. + + DBSCAN does not support assigning labels to unseen samples (it is a + non-parametric algorithm with no predict step). Passing a dataset with + a different number of rows than the training set raises an error. + + Parameters + ---------- + x : DashAIDataset, optional + If provided, must have the same number of rows as the training set. + + Returns + ------- + numpy.ndarray + Cluster label per sample. Noise points are labelled ``-1``. + """ + if self._labels is None: + raise ValueError( + f"{self.__class__.__name__} must be fitted before returning labels." + ) + if x is not None and len(x) != len(self._labels): + raise ValueError( + f"{self.__class__.__name__} cannot assign labels to unseen samples." + ) + return self._labels + + @staticmethod + def _label_clusters( + neighbors: list, + n: int, + min_samples: int, + ) -> np.ndarray: + """Apply DBSCAN cluster-labelling to precomputed neighbour lists. + + Core points are those with at least ``min_samples`` neighbours + (including themselves). Clusters are grown via BFS from unvisited core + points. Border points (non-core points reachable from a core point) + receive the cluster label but do not expand. All remaining unvisited + points are labelled -1 (noise). + + Parameters + ---------- + neighbors : list of lists + ``neighbors[i]`` contains the indices of all points within ``eps`` + of point ``i`` (including ``i`` itself). + n : int + Total number of samples. + min_samples : int + Minimum neighbourhood size for a point to be a core point. + + Returns + ------- + numpy.ndarray of shape (n,), dtype int64 + Cluster label per sample. ``-1`` means noise. + """ + is_core = np.array( + [len(neighbors[i]) >= min_samples for i in range(n)], dtype=bool + ) + labels = np.full(n, -1, dtype=np.int64) + visited = np.zeros(n, dtype=bool) + cluster_id = 0 + + for point in range(n): + if visited[point] or not is_core[point]: + continue + + # Start a new cluster from this unvisited core point + visited[point] = True + labels[point] = cluster_id + queue = list(neighbors[point]) + + while queue: + q = queue.pop(0) + if visited[q]: + continue + visited[q] = True + labels[q] = cluster_id + # Only core points expand the frontier + if is_core[q]: + queue.extend(neighbors[q]) + + cluster_id += 1 + + return labels diff --git a/DashAI/back/models/faiss/faiss_kmeans_clustering.py b/DashAI/back/models/faiss/faiss_kmeans_clustering.py new file mode 100644 index 000000000..e1685e8cc --- /dev/null +++ b/DashAI/back/models/faiss/faiss_kmeans_clustering.py @@ -0,0 +1,262 @@ +"""DashAI FAISS K-Means clustering model. + +FAISS (Facebook AI Similarity Search) provides a highly optimised K-Means +implementation written in C++ that outperforms sklearn's on large datasets. +It uses the same Lloyd's algorithm but benefits from BLAS-level vectorisation. +GPU acceleration is available via faiss-gpu but requires additional setup; +the default installation (faiss-cpu) uses CPU only. + +Install FAISS with: pip install faiss-cpu (or faiss-gpu for GPU support) +""" + +import numpy as np + +from DashAI.back.core.schema_fields import ( + int_field, + schema_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.faiss.faiss_like_clusterer import FaissLikeClusterer + +if False: # TYPE_CHECKING + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class FaissKMeansClusteringSchema(BaseSchema): + """Schema that configures the FAISS K-Means clustering model.""" + + n_clusters: schema_field( + int_field(ge=2), + placeholder=8, + description=MultilingualString( + en="Number of clusters to form.", + es="Número de clusters a formar.", + pt="Número de clusters a formar.", + de="Anzahl der zu bildenden Cluster.", + zh="要形成的聚类数量。", + ), + alias=MultilingualString( + en="Clusters", es="Clusters", pt="Clusters", de="Cluster", zh="聚类数" + ), + ) # type: ignore + max_iter: schema_field( + int_field(ge=1), + placeholder=300, + description=MultilingualString( + en="Maximum number of Lloyd's algorithm iterations.", + es="Número máximo de iteraciones del algoritmo de Lloyd.", + pt="Número máximo de iterações do algoritmo de Lloyd.", + de="Maximale Anzahl an Iterationen des Lloyd-Algorithmus.", + zh="Lloyd算法的最大迭代次数。", + ), + alias=MultilingualString( + en="Max iterations", + es="Iteraciones máximas", + pt="Iterações máximas", + de="Max. Iterationen", + zh="最大迭代次数", + ), + ) # type: ignore + random_state: schema_field( + int_field(ge=0), + placeholder=0, + description=MultilingualString( + en="Random seed for centroid initialisation.", + es="Semilla aleatoria para la inicialización de centroides.", + pt="Semente aleatória para a inicialização dos centroides.", + de="Zufallsstartwert für die Zentroiden-Initialisierung.", + zh="用于质心初始化的随机种子。", + ), + alias=MultilingualString( + en="Random state", + es="Estado aleatorio", + pt="Estado aleatório", + de="Zufallszustand", + zh="随机状态", + ), + ) # type: ignore + + +class FaissKMeansClustering(FaissLikeClusterer): + """FAISS K-Means clustering model for the Models module. + + FAISS K-Means applies Lloyd's algorithm in C++ with BLAS vectorisation and + optional GPU acceleration. It converges substantially faster than sklearn's + K-Means on large datasets (tens of thousands of samples or more) while + producing equivalent cluster quality. + + Key hyperparameters: ``n_clusters`` (groups), ``max_iter`` (convergence + budget), ``random_state`` (seed). Requires ``faiss-cpu`` or ``faiss-gpu``. + + References + ---------- + - [1] Johnson, J., Douze, M., & Jégou, H. (2019). "Billion-scale similarity + search with GPUs." + - [2] https://github.com/facebookresearch/faiss + """ + + SCHEMA = FaissKMeansClusteringSchema + DISPLAY_NAME = MultilingualString( + en="FAISS K-Means", + es="FAISS K-Means", + pt="FAISS K-Means", + de="FAISS K-Means", + zh="FAISS K-均值", + ) + DESCRIPTION = MultilingualString( + en=( + "K-Means via FAISS — C++ vectorised and faster than sklearn " + "on large datasets." + ), + es=( + "K-Means vía FAISS — vectorización C++, más rápido que sklearn " + "en datasets grandes." + ), + pt=( + "K-Means via FAISS — vetorizado em C++, mais rápido que o sklearn " + "em datasets grandes." + ), + de=( + "K-Means über FAISS — C++-vektorisiert und schneller als sklearn " + "bei großen Datensätzen." + ), + zh="通过FAISS实现的K-Means——C++向量化,在大型数据集上比sklearn更快。", + ) + COLOR = "#66BB6A" + ICON = "FlashOn" + + def __init__( + self, + n_clusters: int = 8, + max_iter: int = 300, + random_state: int = 0, + **kwargs, + ) -> None: + """Initialise FAISS K-Means. + + Unlike sklearn-based models, the parameters must be received explicitly + here because there is no underlying sklearn estimator class in the MRO + that would capture and store them from **kwargs. FaissLikeClusterer, + FaissLikeModel, ClusteringModel and BaseModel know nothing about + n_clusters, max_iter or random_state — so this class must own them. + + Parameters + ---------- + n_clusters : int + Number of clusters. Must be >= 2. + max_iter : int + Maximum Lloyd's iterations. Must be >= 1. + random_state : int + Seed for centroid initialisation. + **kwargs : dict + Forwarded to parent classes (FaissLikeClusterer initialises + self._labels and self._index from here). + """ + super().__init__(**kwargs) + self.n_clusters = n_clusters + self.max_iter = max_iter + self.random_state = random_state + # Fitted state — populated by train(), used by properties. + # The faiss.Kmeans object itself is NOT stored on self because it is a + # C++ object that is not picklable. We extract what we need right after + # training and discard it. + self._centroids: "np.ndarray | None" = None + self._final_inertia: "float | None" = None + + def train(self, x_train: "DashAIDataset") -> "FaissKMeansClustering": + """Fit FAISS K-Means and store cluster assignments. + + The faiss.Kmeans object is used only during this method. After training + the fitted centroids and final SSE are extracted as plain numpy values, + and the FAISS index (self._index) is retained for future label queries. + The faiss.Kmeans object itself goes out of scope immediately so there + is nothing non-picklable left on self. + + Parameters + ---------- + x_train : DashAIDataset + Input feature matrix. + + Returns + ------- + FaissKMeansClustering + The fitted model instance. + + Raises + ------ + ImportError + If the ``faiss`` package is not installed. + """ + faiss = self._import_faiss() + + x = self._to_float32(x_train) + d = x.shape[1] + + kmeans = faiss.Kmeans( + d, + self.n_clusters, + niter=self.max_iter, + seed=self.random_state, + verbose=False, + ) + kmeans.train(x) + + # Extract everything we need before kmeans goes out of scope. + self._index = kmeans.index + self._centroids = kmeans.centroids.copy() + self._final_inertia = float(kmeans.obj[-1]) + + _, assignments = self._index.search(x, 1) + self._labels = assignments.flatten().astype(np.int64) + return self + + def get_cluster_labels(self, x: "DashAIDataset | None" = None) -> np.ndarray: + """Return fitted labels or assign new samples to nearest centroid. + + Parameters + ---------- + x : DashAIDataset, optional + New samples to assign. If omitted, returns labels from training. + + Returns + ------- + numpy.ndarray + Cluster label per sample. + """ + if x is not None and self._index is not None: + x_arr = self._to_float32(x) + _, assignments = self._index.search(x_arr, 1) + return assignments.flatten().astype(np.int64) + + if self._labels is None: + raise ValueError( + f"{self.__class__.__name__} must be fitted before returning labels." + ) + return self._labels + + def get_fit_attributes(self) -> dict: + """Return FAISS K-Means post-fit attributes for the converter report.""" + return { + "cluster_centers": self._centroids.tolist(), + "inertia": self._final_inertia, + } + + @property + def cluster_centers_(self) -> np.ndarray: + """Fitted cluster centroids, shape (n_clusters, n_features). + + Raises AttributeError (not ValueError) so that hasattr() returns False + before fitting — the clustering converter checks hasattr() to decide + whether to export centroids in the run metadata. + """ + if self._centroids is None: + raise AttributeError(f"{self.__class__.__name__} has not been fitted.") + return self._centroids + + @property + def inertia_(self) -> float: + """Final SSE objective value from the FAISS training run.""" + if self._final_inertia is None: + raise AttributeError(f"{self.__class__.__name__} has not been fitted.") + return self._final_inertia diff --git a/DashAI/back/models/faiss/faiss_like_clusterer.py b/DashAI/back/models/faiss/faiss_like_clusterer.py new file mode 100644 index 000000000..3949ed4b9 --- /dev/null +++ b/DashAI/back/models/faiss/faiss_like_clusterer.py @@ -0,0 +1,48 @@ +"""Abstract adapter combining FAISS infrastructure with the clustering contract. + +This module sits between the generic FAISS mixin (FaissBaseModel) and concrete +clustering algorithms. It does not implement train() or get_cluster_labels() — +those are deliberately left abstract so each algorithm can use the FAISS API +that best fits its needs (index.search for K-Means, range_search for DBSCAN, +etc.). + +Inheritance hierarchy for FAISS clustering models: + + FaissBaseModel ← infrastructure mixin (not tied to clustering) + ↓ + FaissLikeClusterer ← this class: FAISS infra + clustering contract + ↓ ↓ + FaissKMeans FaissDBSCAN ← concrete algorithms +""" + +from DashAI.back.models.clustering_model import ClusteringModel +from DashAI.back.models.faiss.faiss_base_model import FaissBaseModel + + +class FaissLikeClusterer(FaissBaseModel, ClusteringModel): + """Abstract base for FAISS-backed clustering algorithms. + + Combines the FAISS infrastructure mixin (``FaissBaseModel``) with DashAI's + clustering contract (``ClusteringModel``). Subclasses receive float32 + conversion, save/load, and FAISS index pickle support for free, and only + need to implement the two abstract methods from ``ClusteringModel``: + + - ``train(x_train)`` — fit the FAISS index and store cluster labels. + - ``get_cluster_labels(x=None)`` — return stored or predicted labels. + + Every FAISS clustering model stores its FAISS index in ``self._index`` so + that the inherited ``__getstate__`` / ``__setstate__`` serialises it + correctly. + """ + + def __init__(self, **kwargs) -> None: + """Initialise shared FAISS clustering state. + + Parameters + ---------- + **kwargs : dict + Forwarded to parent classes via cooperative multiple inheritance. + """ + super().__init__(**kwargs) + self._labels = None + self._index = None diff --git a/DashAI/back/models/forecasting/base_forecasting_model.py b/DashAI/back/models/forecasting/base_forecasting_model.py index d4e9019e0..60f9f6822 100644 --- a/DashAI/back/models/forecasting/base_forecasting_model.py +++ b/DashAI/back/models/forecasting/base_forecasting_model.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, List -from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.supervised_model import SupervisedModel if TYPE_CHECKING: import numpy as np @@ -11,7 +11,7 @@ from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset -class ForecastingModel(BaseModel): +class ForecastingModel(SupervisedModel): """Base class for models that predict the future of a single series. These models are unusual among DashAI models in that they do not learn a diff --git a/DashAI/back/models/lenet5_image_classifier.py b/DashAI/back/models/lenet5_image_classifier.py index 66011ca03..fae2c7a12 100644 --- a/DashAI/back/models/lenet5_image_classifier.py +++ b/DashAI/back/models/lenet5_image_classifier.py @@ -10,8 +10,8 @@ schema_field, ) from DashAI.back.core.utils import MultilingualString -from DashAI.back.models.base_model import BaseModel from DashAI.back.models.image_explainable_model import GradCamCompatibleModel +from DashAI.back.models.supervised_model import SupervisedModel from DashAI.back.models.utils import DEVICE_ENUM, DEVICE_PLACEHOLDER, DEVICE_TO_IDX @@ -319,7 +319,7 @@ def forward(self, x): return _LeNet5(input_channels, input_size, num_classes, dropout_rate) -class LeNet5ImageClassifier(BaseModel, GradCamCompatibleModel): +class LeNet5ImageClassifier(SupervisedModel, GradCamCompatibleModel): """LeNet-5 image classifier (LeCun et al., 1998). The original convolutional neural network architecture, featuring two diff --git a/DashAI/back/models/mlp_image_classifier.py b/DashAI/back/models/mlp_image_classifier.py index cc2d32e0a..b91edcbe4 100644 --- a/DashAI/back/models/mlp_image_classifier.py +++ b/DashAI/back/models/mlp_image_classifier.py @@ -11,8 +11,8 @@ schema_field, ) from DashAI.back.core.utils import MultilingualString -from DashAI.back.models.base_model import BaseModel from DashAI.back.models.image_explainable_model import OcclusionSaliencyCompatibleModel +from DashAI.back.models.supervised_model import SupervisedModel from DashAI.back.models.utils import DEVICE_ENUM, DEVICE_PLACEHOLDER, DEVICE_TO_IDX @@ -351,7 +351,7 @@ def forward(self, x): return _MLP(input_dim, output_dim, hidden_dims, dropout_rate) -class MLPImageClassifier(BaseModel, OcclusionSaliencyCompatibleModel): +class MLPImageClassifier(SupervisedModel, OcclusionSaliencyCompatibleModel): """MLP-based image classifier. A feed-forward neural network that flattens image pixels and passes them diff --git a/DashAI/back/models/model_factory.py b/DashAI/back/models/model_factory.py index d7770c221..572bb56be 100644 --- a/DashAI/back/models/model_factory.py +++ b/DashAI/back/models/model_factory.py @@ -275,9 +275,15 @@ def recursive_update(params, param_name, new_value): return updated_parameters def evaluate(self, x, y, metrics): - """ + """Legacy supervised evaluation helper. + Computes metrics only if the model is fitted. + This method assumes ``x`` and ``y`` are split dictionaries and metrics + follow the ``y_true`` versus ``y_pred`` contract. New task executors + should prefer task-specific evaluation logic instead of extending this + method to non-supervised workflows. + Parameters ---------- x : dict diff --git a/DashAI/back/models/regression_model.py b/DashAI/back/models/regression_model.py index 05cb38e0a..60c57afde 100644 --- a/DashAI/back/models/regression_model.py +++ b/DashAI/back/models/regression_model.py @@ -1,7 +1,7 @@ -from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.supervised_model import SupervisedModel -class RegressionModel(BaseModel): +class RegressionModel(SupervisedModel): """Base class for models that perform regression tasks. Concrete regression models must extend this class and implement ``save``, diff --git a/DashAI/back/models/scikit_learn/agglomerative_clustering.py b/DashAI/back/models/scikit_learn/agglomerative_clustering.py new file mode 100644 index 000000000..805805dc5 --- /dev/null +++ b/DashAI/back/models/scikit_learn/agglomerative_clustering.py @@ -0,0 +1,196 @@ +"""DashAI Agglomerative (Hierarchical) clustering model.""" + +from sklearn.cluster import AgglomerativeClustering as _AgglomerativeClustering + +from DashAI.back.core.schema_fields import ( + bool_field, + enum_field, + int_field, + schema_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.scikit_learn.sklearn_like_clusterer import ( + SklearnLikeClusterer, +) + + +class AgglomerativeClusteringSchema(BaseSchema): + """Schema that configures the Agglomerative clustering model. + + Agglomerative clustering builds a hierarchy by iteratively merging the + pair of clusters that minimises the chosen linkage criterion. It does not + require specifying the number of clusters in advance when a dendrogram is + analysed, but exposing ``n_clusters`` lets the user cut the hierarchy at a + desired level. + """ + + n_clusters: schema_field( + int_field(ge=2), + placeholder=2, + description=MultilingualString( + en="Number of clusters to find.", + es="Número de clusters a encontrar.", + pt="Número de clusters a encontrar.", + de="Anzahl der zu findenden Cluster.", + zh="要查找的聚类数量。", + ), + alias=MultilingualString( + en="Clusters", es="Clusters", pt="Clusters", de="Cluster", zh="聚类数" + ), + ) # type: ignore + linkage: schema_field( + enum_field(["ward", "complete", "average", "single"]), + "ward", + description=MultilingualString( + en=( + "Linkage criterion determining which distances to use between " + "clusters. 'ward' minimises variance and only supports " + "'euclidean' metric." + ), + es=( + "Criterio de enlace que determina la distancia entre clusters. " + "'ward' minimiza la varianza y solo admite la métrica 'euclidean'." + ), + pt=( + "Critério de ligação que determina a distância entre clusters. " + "'ward' minimiza a variância e admite apenas a métrica 'euclidean'." + ), + de=( + "Linkage-Kriterium, das die Distanz zwischen Clustern bestimmt. " + "'ward' minimiert die Varianz und unterstützt nur die " + "'euclidean'-Metrik." + ), + zh="决定聚类间使用哪种距离的链接准则。'ward'最小化方差,仅支持'euclidean'度量。", + ), + alias=MultilingualString( + en="Linkage", es="Enlace", pt="Ligação", de="Linkage", zh="链接方式" + ), + ) # type: ignore + metric: schema_field( + enum_field(["euclidean", "manhattan", "cosine"]), + "euclidean", + description=MultilingualString( + en=( + "Distance metric used to compute linkage. " + "'ward' linkage only supports 'euclidean'." + ), + es=( + "Métrica de distancia usada para el enlace. " + "El enlace 'ward' solo admite 'euclidean'." + ), + pt=( + "Métrica de distância usada para a ligação. " + "A ligação 'ward' admite apenas 'euclidean'." + ), + de=( + "Distanzmetrik zur Berechnung des Linkage. " + "'ward'-Linkage unterstützt nur 'euclidean'." + ), + zh="用于计算链接的距离度量。'ward'链接仅支持'euclidean'。", + ), + alias=MultilingualString( + en="Metric", es="Métrica", pt="Métrica", de="Metrik", zh="度量" + ), + ) # type: ignore + compute_distances: schema_field( + bool_field(), + True, + description=MultilingualString( + en=( + "Whether to compute and store merge distances during fitting. " + "Required to render a dendrogram. Has a small memory cost " + "proportional to the number of samples." + ), + es=( + "Si se calculan y almacenan las distancias de fusión durante el " + "ajuste. Necesario para renderizar el dendrograma. Tiene un " + "pequeño costo de memoria proporcional al número de muestras." + ), + pt=( + "Se as distâncias de fusão são calculadas e armazenadas durante o " + "ajuste. Necessário para renderizar o dendrograma. Tem um pequeno " + "custo de memória proporcional ao número de amostras." + ), + de=( + "Ob Fusionsdistanzen während der Anpassung berechnet und " + "gespeichert werden. Erforderlich zur Darstellung eines " + "Dendrogramms. Verursacht geringe, zur Stichprobenzahl " + "proportionale Speicherkosten." + ), + zh="是否在拟合过程中计算并存储合并距离。渲染树状图时需要此项,会带来与样本数成正比的少量内存开销。", + ), + alias=MultilingualString( + en="Compute distances", + es="Calcular distancias", + pt="Calcular distâncias", + de="Distanzen berechnen", + zh="计算距离", + ), + ) # type: ignore + + +class AgglomerativeClustering(SklearnLikeClusterer, _AgglomerativeClustering): + """Agglomerative hierarchical clustering model for the Models module. + + Agglomerative clustering is a bottom-up hierarchical algorithm. Each sample + starts as its own cluster; clusters are merged successively according to the + chosen ``linkage`` criterion until ``n_clusters`` groups remain. The + algorithm exposes cluster labels for comparison metrics such as Silhouette, + Davies-Bouldin, and Calinski-Harabasz. + + Key hyperparameters include ``n_clusters`` (cut level of the hierarchy), + ``linkage`` (merge strategy), and ``metric`` (distance function). The + implementation wraps scikit-learn's ``AgglomerativeClustering`` estimator. + + References + ---------- + - [1] Ward, J. H. (1963). "Hierarchical grouping to optimize an objective + function." + - [2] https://scikit-learn.org/stable/modules/generated/ + sklearn.cluster.AgglomerativeClustering.html + """ + + SCHEMA = AgglomerativeClusteringSchema + DISPLAY_NAME = MultilingualString( + en="Agglomerative", + es="Aglomerativo", + pt="Aglomerativo", + de="Agglomerativ", + zh="层次聚类", + ) + DESCRIPTION = MultilingualString( + en="Hierarchical clustering by progressively merging groups.", + es="Clustering jerárquico que fusiona grupos progresivamente.", + pt="Clustering hierárquico que funde grupos progressivamente.", + de="Hierarchisches Clustering durch schrittweises Zusammenführen von Gruppen.", + zh="通过逐步合并分组实现层次聚类。", + ) + COLOR = "#42A5F5" + ICON = "AccountTree" + + def __init__(self, **kwargs) -> None: + """Initialise the model by forwarding all kwargs to the parent class. + + Parameters + ---------- + **kwargs : dict + Hyperparameter values forwarded to the parent sklearn wrapper. See + the associated schema class for available keys and their defaults. + """ + super().__init__(**kwargs) + + def get_fit_attributes(self) -> dict: + """Return Agglomerative post-fit attributes for the converter report.""" + distances = ( + self.distances_.tolist() + if hasattr(self, "distances_") + else list(range(len(self.children_))) + ) + return { + "linkage_data": { + "children": self.children_.tolist(), + "distances": distances, + "n_leaves": len(self._labels), + } + } diff --git a/DashAI/back/models/scikit_learn/dbscan_clustering.py b/DashAI/back/models/scikit_learn/dbscan_clustering.py new file mode 100644 index 000000000..bfb65b459 --- /dev/null +++ b/DashAI/back/models/scikit_learn/dbscan_clustering.py @@ -0,0 +1,128 @@ +"""DashAI DBSCAN clustering model.""" + +from sklearn.cluster import DBSCAN as _DBSCAN + +from DashAI.back.core.schema_fields import ( + enum_field, + float_field, + int_field, + schema_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.scikit_learn.sklearn_like_clusterer import ( + SklearnLikeClusterer, +) + + +class DBSCANClusteringSchema(BaseSchema): + """Schema that configures the DBSCAN clustering model. + + DBSCAN groups samples by density. A point becomes a core point when at + least ``min_samples`` points fall within distance ``eps`` under the selected + metric. Dense regions become clusters and sparse points can be marked as + noise. + """ + + eps: schema_field( + float_field(gt=0.0), + placeholder=0.5, + description=MultilingualString( + en="Maximum distance between two samples to be considered neighbours.", + es="Distancia máxima entre dos muestras para considerarlas vecinas.", + pt="Distância máxima entre duas amostras para serem consideradas vizinhas.", + de=( + "Maximaler Abstand zwischen zwei Stichproben, um als Nachbarn " + "zu gelten." + ), + zh="两个样本被视为邻居的最大距离。", + ), + alias=MultilingualString(en="Eps", es="Eps", pt="Eps", de="Eps", zh="Eps"), + ) # type: ignore + min_samples: schema_field( + int_field(ge=1), + placeholder=5, + description=MultilingualString( + en="Minimum samples in a neighbourhood for a point to be core.", + es="Mínimo de muestras en una vecindad para que un punto sea central.", + pt="Mínimo de amostras em uma vizinhança para que um ponto seja central.", + de="Mindestanzahl an Stichproben in einer Nachbarschaft, damit ein Punkt " + "als Kernpunkt gilt.", + zh="一个点成为核心点所需邻域内的最小样本数。", + ), + alias=MultilingualString( + en="Min samples", + es="Mínimo de muestras", + pt="Mínimo de amostras", + de="Min. Stichproben", + zh="最小样本数", + ), + ) # type: ignore + metric: schema_field( + enum_field(["euclidean", "manhattan", "cosine"]), + "euclidean", + description=MultilingualString( + en="Distance metric used by DBSCAN.", + es="Métrica de distancia usada por DBSCAN.", + pt="Métrica de distância usada pelo DBSCAN.", + de="Von DBSCAN verwendete Distanzmetrik.", + zh="DBSCAN使用的距离度量。", + ), + alias=MultilingualString( + en="Metric", es="Métrica", pt="Métrica", de="Metrik", zh="度量" + ), + ) # type: ignore + + +class DBSCANClustering(SklearnLikeClusterer, _DBSCAN): + """DBSCAN clustering model for the Models module. + + DBSCAN is a density-based unsupervised algorithm that discovers clusters + without requiring the number of clusters in advance. It is useful when + groups have irregular shapes and when noise or outliers should be identified + explicitly. Noise samples are labelled as ``-1`` by scikit-learn. + + Key hyperparameters include ``eps`` (the neighbourhood radius), + ``min_samples`` (minimum points required to form a dense region), and + ``metric`` (distance function). The implementation wraps scikit-learn's + ``DBSCAN`` estimator. + + References + ---------- + - [1] Ester, M., Kriegel, H.-P., Sander, J., & Xu, X. (1996). + "A density-based algorithm for discovering clusters in large spatial + databases with noise." + - [2] https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html + """ + + SCHEMA = DBSCANClusteringSchema + DISPLAY_NAME = MultilingualString( + en="DBSCAN", es="DBSCAN", pt="DBSCAN", de="DBSCAN", zh="DBSCAN" + ) + DESCRIPTION = MultilingualString( + en="Density-based clustering that can identify noise points.", + es="Clustering basado en densidad que puede identificar puntos de ruido.", + pt="Clustering baseado em densidade que pode identificar pontos de ruído.", + de="Dichtebasiertes Clustering, das Rauschpunkte identifizieren kann.", + zh="基于密度的聚类方法,能够识别噪声点。", + ) + COLOR = "#7E57C2" + ICON = "Radar" + + def __init__(self, **kwargs) -> None: + """Initialise the model by forwarding all kwargs to the parent class. + + Parameters + ---------- + **kwargs : dict + Hyperparameter values forwarded to the parent sklearn wrapper. See + the associated schema class for available keys and their defaults. + """ + super().__init__(**kwargs) + + def get_fit_attributes(self) -> dict: + """Return DBSCAN post-fit attributes for the converter report.""" + import numpy as np + + n_noise = int(np.sum(self._labels == -1)) + return {"n_noise_points": n_noise} diff --git a/DashAI/back/models/scikit_learn/gaussian_mixture_clustering.py b/DashAI/back/models/scikit_learn/gaussian_mixture_clustering.py new file mode 100644 index 000000000..5e7ba6722 --- /dev/null +++ b/DashAI/back/models/scikit_learn/gaussian_mixture_clustering.py @@ -0,0 +1,162 @@ +"""DashAI Gaussian Mixture Model (GMM) clustering model.""" + +from sklearn.mixture import GaussianMixture as _GaussianMixture + +from DashAI.back.core.schema_fields import ( + enum_field, + int_field, + schema_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.scikit_learn.sklearn_like_clusterer import ( + SklearnLikeClusterer, +) + + +class GaussianMixtureClusteringSchema(BaseSchema): + """Schema that configures the Gaussian Mixture Model clustering model. + + GMM models the data as a weighted sum of Gaussian distributions. Each + sample is assigned a soft membership probability for every component and is + labelled with the highest-probability component. Unlike centroid-based + algorithms, GMM supports elliptical cluster shapes and exposes BIC/AIC for + model selection. + """ + + n_components: schema_field( + int_field(ge=1), + placeholder=2, + description=MultilingualString( + en="Number of Gaussian components (clusters) to fit.", + es="Número de componentes gaussianas (clusters) a ajustar.", + pt="Número de componentes gaussianas (clusters) a ajustar.", + de="Anzahl der anzupassenden Gauß-Komponenten (Cluster).", + zh="要拟合的高斯成分(聚类)数量。", + ), + alias=MultilingualString( + en="Components", + es="Componentes", + pt="Componentes", + de="Komponenten", + zh="成分数", + ), + ) # type: ignore + covariance_type: schema_field( + enum_field(["full", "tied", "diag", "spherical"]), + "full", + description=MultilingualString( + en=( + "Shape of covariance matrices. 'full' is most flexible; " + "'spherical' is most constrained." + ), + es=( + "Forma de las matrices de covarianza. 'full' es la más flexible; " + "'spherical' es la más restringida." + ), + pt=( + "Forma das matrizes de covariância. 'full' é a mais flexível; " + "'spherical' é a mais restrita." + ), + de=( + "Form der Kovarianzmatrizen. 'full' ist am flexibelsten; " + "'spherical' ist am stärksten eingeschränkt." + ), + zh="协方差矩阵的形状。'full'最灵活;'spherical'限制最多。", + ), + alias=MultilingualString( + en="Covariance type", + es="Tipo de covarianza", + pt="Tipo de covariância", + de="Kovarianztyp", + zh="协方差类型", + ), + ) # type: ignore + max_iter: schema_field( + int_field(ge=1), + placeholder=100, + description=MultilingualString( + en="Maximum number of EM algorithm iterations.", + es="Número máximo de iteraciones del algoritmo EM.", + pt="Número máximo de iterações do algoritmo EM.", + de="Maximale Anzahl an Iterationen des EM-Algorithmus.", + zh="EM算法的最大迭代次数。", + ), + alias=MultilingualString( + en="Max iterations", + es="Iteraciones máximas", + pt="Iterações máximas", + de="Max. Iterationen", + zh="最大迭代次数", + ), + ) # type: ignore + random_state: schema_field( + int_field(ge=0), + placeholder=0, + description=MultilingualString( + en="Random seed used for initialisation.", + es="Semilla aleatoria usada para la inicialización.", + pt="Semente aleatória usada para a inicialização.", + de="Zufallsstartwert für die Initialisierung.", + zh="用于初始化的随机种子。", + ), + alias=MultilingualString( + en="Random state", + es="Estado aleatorio", + pt="Estado aleatório", + de="Zufallszustand", + zh="随机状态", + ), + ) # type: ignore + + +class GaussianMixtureClustering(SklearnLikeClusterer, _GaussianMixture): + """Gaussian Mixture Model clustering model for the Models module. + + GMM is a probabilistic unsupervised algorithm. It fits a mixture of + Gaussian distributions to the data using the Expectation-Maximisation + algorithm. Each sample is assigned to the component with the highest + posterior probability, producing soft cluster memberships that enable + richer analyses than hard assignments. + + Key hyperparameters include ``n_components`` (number of clusters), + ``covariance_type`` (shape of covariance matrices), ``max_iter`` + (EM convergence budget), and ``random_state`` (seed). The implementation + wraps scikit-learn's ``GaussianMixture`` estimator. + + References + ---------- + - [1] Dempster, A. P., Laird, N. M., & Rubin, D. B. (1977). "Maximum + likelihood from incomplete data via the EM algorithm." + - [2] https://scikit-learn.org/stable/modules/generated/ + sklearn.mixture.GaussianMixture.html + """ + + SCHEMA = GaussianMixtureClusteringSchema + DISPLAY_NAME = MultilingualString( + en="Gaussian Mixture", + es="Mezcla Gaussiana", + pt="Mistura Gaussiana", + de="Gauß-Mischmodell", + zh="高斯混合模型", + ) + DESCRIPTION = MultilingualString( + en="Probabilistic clustering using mixtures of Gaussian distributions.", + es="Clustering probabilístico usando mezclas de distribuciones gaussianas.", + pt="Clustering probabilístico usando misturas de distribuições gaussianas.", + de="Probabilistisches Clustering mittels Mischungen von Gauß-Verteilungen.", + zh="使用高斯分布混合的概率聚类方法。", + ) + COLOR = "#FF7043" + ICON = "BubbleChart" + + def __init__(self, **kwargs) -> None: + """Initialise the model by forwarding all kwargs to the parent class. + + Parameters + ---------- + **kwargs : dict + Hyperparameter values forwarded to the parent sklearn wrapper. See + the associated schema class for available keys and their defaults. + """ + super().__init__(**kwargs) diff --git a/DashAI/back/models/scikit_learn/hdbscan_clustering.py b/DashAI/back/models/scikit_learn/hdbscan_clustering.py new file mode 100644 index 000000000..d754778af --- /dev/null +++ b/DashAI/back/models/scikit_learn/hdbscan_clustering.py @@ -0,0 +1,165 @@ +"""DashAI HDBSCAN clustering model.""" + +from sklearn.cluster import HDBSCAN as _HDBSCAN + +from DashAI.back.core.schema_fields import ( + enum_field, + int_field, + schema_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.scikit_learn.sklearn_like_clusterer import ( + SklearnLikeClusterer, +) + + +class HDBSCANClusteringSchema(BaseSchema): + """Schema that configures the HDBSCAN clustering model. + + HDBSCAN extends DBSCAN by running it at multiple density thresholds and + extracting the most stable clusters via a condensed cluster tree. It handles + clusters of varying density, eliminates the manual ``eps`` parameter, and + assigns soft cluster membership scores (``probabilities_``). + """ + + min_cluster_size: schema_field( + int_field(ge=2), + placeholder=5, + description=MultilingualString( + en="Minimum number of samples required to form a cluster.", + es="Mínimo de muestras requeridas para formar un cluster.", + pt="Número mínimo de amostras necessárias para formar um cluster.", + de="Mindestanzahl an Stichproben, die zur Bildung eines Clusters " + "erforderlich sind.", + zh="形成一个聚类所需的最小样本数。", + ), + alias=MultilingualString( + en="Min cluster size", + es="Tamaño mínimo de cluster", + pt="Tamanho mínimo do cluster", + de="Min. Clustergröße", + zh="最小聚类大小", + ), + ) # type: ignore + min_samples: schema_field( + int_field(ge=1), + placeholder=5, + description=MultilingualString( + en=( + "Number of samples in a neighbourhood for a point " + "to be considered a core point." + ), + es=( + "Número de muestras en una vecindad para que un punto " + "sea considerado central." + ), + pt=( + "Número de amostras em uma vizinhança para que um ponto " + "seja considerado central." + ), + de=( + "Anzahl der Stichproben in einer Nachbarschaft, damit ein Punkt " + "als Kernpunkt gilt." + ), + zh="使一个点被视为核心点所需邻域内的样本数。", + ), + alias=MultilingualString( + en="Min samples", + es="Mínimo de muestras", + pt="Mínimo de amostras", + de="Min. Stichproben", + zh="最小样本数", + ), + ) # type: ignore + metric: schema_field( + enum_field(["euclidean", "manhattan", "cosine"]), + "euclidean", + description=MultilingualString( + en="Distance metric used to compute core distances.", + es="Métrica de distancia usada para calcular distancias centrales.", + pt="Métrica de distância usada para calcular distâncias centrais.", + de="Distanzmetrik zur Berechnung der Kernabstände.", + zh="用于计算核心距离的距离度量。", + ), + alias=MultilingualString( + en="Metric", es="Métrica", pt="Métrica", de="Metrik", zh="度量" + ), + ) # type: ignore + + +class HDBSCANClustering(SklearnLikeClusterer, _HDBSCAN): + """HDBSCAN clustering model for the Models module. + + HDBSCAN is a hierarchical density-based unsupervised algorithm. It builds a + minimum spanning tree over core distances, converts it to a cluster + hierarchy, and selects the most persistent clusters. Noise samples are + labelled ``-1``. Unlike DBSCAN, it supports clusters of varying density + without requiring a global ``eps`` parameter. + + Key hyperparameters include ``min_cluster_size`` (smallest group accepted), + ``min_samples`` (core-point threshold), and ``metric`` (distance function). + Requires scikit-learn >= 1.3. The implementation wraps scikit-learn's + ``HDBSCAN`` estimator. + + References + ---------- + - [1] Campello, R. J. G. B., Moulavi, D., & Sander, J. (2013). + "Density-Based Clustering Based on Hierarchical Density Estimates." + - [2] https://scikit-learn.org/stable/modules/generated/ + sklearn.cluster.HDBSCAN.html + """ + + SCHEMA = HDBSCANClusteringSchema + DISPLAY_NAME = MultilingualString( + en="HDBSCAN", es="HDBSCAN", pt="HDBSCAN", de="HDBSCAN", zh="HDBSCAN" + ) + DESCRIPTION = MultilingualString( + en="Hierarchical density-based clustering with variable density support.", + es="Clustering jerárquico basado en densidad con soporte de densidad variable.", + pt="Clustering hierárquico baseado em densidade com suporte a densidade " + "variável.", + de="Hierarchisches dichtebasiertes Clustering mit Unterstützung " + "variabler Dichte.", + zh="支持可变密度的层次化基于密度的聚类方法。", + ) + COLOR = "#26C6DA" + ICON = "Layers" + + def __init__(self, **kwargs) -> None: + """Initialise the model by forwarding all kwargs to the parent class. + + Parameters + ---------- + **kwargs : dict + Hyperparameter values forwarded to the parent sklearn wrapper. See + the associated schema class for available keys and their defaults. + """ + super().__init__(**kwargs) + + def get_fit_attributes(self) -> dict: + """Return HDBSCAN post-fit attributes for the converter report. + + sklearn's HDBSCAN does not expose ``cluster_persistence_``. Stability + is approximated as the mean membership probability across samples + assigned to each cluster (``probabilities_`` from sklearn). + """ + import numpy as np + + attrs = {} + labels = self._labels + probabilities = getattr(self, "probabilities_", None) + + if labels is not None and probabilities is not None: + unique_clusters = np.unique(labels[labels != -1]) + if len(unique_clusters) > 0: + attrs["cluster_persistence"] = [ + float(np.mean(probabilities[labels == lbl])) + for lbl in unique_clusters + ] + + n_noise = int(np.sum(labels == -1)) if labels is not None else 0 + if n_noise > 0: + attrs["n_noise_points"] = n_noise + + return attrs diff --git a/DashAI/back/models/scikit_learn/kmeans_clustering.py b/DashAI/back/models/scikit_learn/kmeans_clustering.py new file mode 100644 index 000000000..fce96dafe --- /dev/null +++ b/DashAI/back/models/scikit_learn/kmeans_clustering.py @@ -0,0 +1,161 @@ +"""DashAI K-Means clustering model.""" + +from sklearn.cluster import KMeans as _KMeans + +from DashAI.back.core.schema_fields import ( + enum_field, + int_field, + schema_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.scikit_learn.sklearn_like_clusterer import ( + SklearnLikeClusterer, +) + + +class KMeansClusteringSchema(BaseSchema): + """Schema that configures the K-Means clustering model. + + K-Means partitions the dataset into a fixed number of clusters by + iteratively assigning samples to the nearest centroid and updating each + centroid as the mean of its assigned samples. It is appropriate when the + expected number of compact, centroid-shaped groups is known in advance. + """ + + n_clusters: schema_field( + int_field(ge=2), + placeholder=8, + description=MultilingualString( + en="Number of clusters to form.", + es="Número de clusters a formar.", + pt="Número de clusters a formar.", + de="Anzahl der zu bildenden Cluster.", + zh="要形成的聚类数量。", + ), + alias=MultilingualString( + en="Clusters", es="Clusters", pt="Clusters", de="Cluster", zh="聚类数" + ), + ) # type: ignore + init: schema_field( + enum_field(["k-means++", "random"]), + "k-means++", + description=MultilingualString( + en=( + "Centroid initialisation method. 'k-means++' selects initial centroids " + "to speed up convergence; 'random' picks them uniformly at random." + ), + es=( + "Método de inicialización de centroides. " + "'k-means++' selecciona centroides iniciales " + "para acelerar la convergencia; 'random' los elige al azar." + ), + pt=( + "Método de inicialização de centroides. 'k-means++' seleciona " + "centroides iniciais para acelerar a convergência; 'random' os " + "escolhe uniformemente ao acaso." + ), + de=( + "Methode zur Initialisierung der Zentroiden. 'k-means++' wählt " + "Startzentroiden zur schnelleren Konvergenz aus; 'random' wählt " + "sie gleichverteilt zufällig." + ), + zh="质心初始化方法。'k-means++'选择初始质心以加快收敛;'random'均匀随机选取。", + ), + alias=MultilingualString( + en="Init method", + es="Método de inicio", + pt="Método de inicialização", + de="Init-Methode", + zh="初始化方法", + ), + ) # type: ignore + max_iter: schema_field( + int_field(ge=1), + placeholder=300, + description=MultilingualString( + en="Maximum number of iterations for a single run.", + es="Número máximo de iteraciones por ejecución.", + pt="Número máximo de iterações por execução.", + de="Maximale Anzahl an Iterationen pro Durchlauf.", + zh="单次运行的最大迭代次数。", + ), + alias=MultilingualString( + en="Max iterations", + es="Iteraciones máximas", + pt="Iterações máximas", + de="Max. Iterationen", + zh="最大迭代次数", + ), + ) # type: ignore + random_state: schema_field( + int_field(ge=0), + placeholder=0, + description=MultilingualString( + en="Random seed used by K-Means.", + es="Semilla aleatoria usada por K-Means.", + pt="Semente aleatória usada pelo K-Means.", + de="Von K-Means verwendeter Zufallsstartwert.", + zh="K-Means使用的随机种子。", + ), + alias=MultilingualString( + en="Random state", + es="Estado aleatorio", + pt="Estado aleatório", + de="Zufallszustand", + zh="随机状态", + ), + ) # type: ignore + + +class KMeansClustering(SklearnLikeClusterer, _KMeans): + """K-Means clustering model for the Models module. + + K-Means is a centroid-based unsupervised algorithm. It fits ``n_clusters`` + centroids and assigns each sample to the closest centroid according to the + Euclidean distance used by scikit-learn's implementation. The model exposes + cluster labels for comparison metrics such as Silhouette, + Davies-Bouldin, and Calinski-Harabasz. + + Key hyperparameters include ``n_clusters`` (the number of groups to form) + and ``random_state`` (seed used for centroid initialisation). The + implementation wraps scikit-learn's ``KMeans`` estimator. + + References + ---------- + - [1] MacQueen, J. (1967). "Some methods for classification and analysis + of multivariate observations." + - [2] https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html + """ + + SCHEMA = KMeansClusteringSchema + DISPLAY_NAME = MultilingualString( + en="K-Means", es="K-Means", pt="K-Means", de="K-Means", zh="K-均值" + ) + DESCRIPTION = MultilingualString( + en="Partitions samples into a fixed number of clusters.", + es="Agrupa muestras en un número fijo de clusters.", + pt="Particiona amostras em um número fixo de clusters.", + de="Teilt Stichproben in eine feste Anzahl von Clustern auf.", + zh="将样本划分为固定数量的聚类。", + ) + COLOR = "#26A69A" + ICON = "Hub" + + def __init__(self, **kwargs) -> None: + """Initialise the model by forwarding all kwargs to the parent class. + + Parameters + ---------- + **kwargs : dict + Hyperparameter values forwarded to the parent sklearn wrapper. See + the associated schema class for available keys and their defaults. + """ + super().__init__(**kwargs) + + def get_fit_attributes(self) -> dict: + """Return K-Means post-fit attributes for the converter report.""" + return { + "cluster_centers": self.cluster_centers_.tolist(), + "inertia": float(self.inertia_), + } diff --git a/DashAI/back/models/scikit_learn/sklearn_like_clusterer.py b/DashAI/back/models/scikit_learn/sklearn_like_clusterer.py new file mode 100644 index 000000000..8bc969e28 --- /dev/null +++ b/DashAI/back/models/scikit_learn/sklearn_like_clusterer.py @@ -0,0 +1,168 @@ +"""Base adapter for scikit-learn clustering models.""" + +from typing import TYPE_CHECKING, Any + +from DashAI.back.models.clustering_model import ClusteringModel + +if TYPE_CHECKING: + from numpy import ndarray + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class SklearnLikeClusterer(ClusteringModel): + """Shared behaviour for scikit-learn-style clustering algorithms. + + Concrete subclasses also inherit from the actual sklearn estimator, for + example ``KMeans`` or ``DBSCAN``. This adapter maps DashAI's clustering + contract to the sklearn API: models are fitted with input features only and + cluster assignments are exposed through ``get_cluster_labels``. + + Clustering is intentionally restricted to numeric features by + ``ClusteringTask`` and the clustering converter. This adapter therefore + assumes it receives a dataset that has already been validated for the + clustering task. + """ + + def __init__(self, *args, **kwargs) -> None: + """Initialize the sklearn clustering adapter. + + Parameters + ---------- + *args : tuple + Positional arguments forwarded to the sklearn estimator. + **kwargs : dict + Keyword arguments forwarded to the sklearn estimator. + """ + super().__init__(*args, **kwargs) + self._labels: "ndarray | None" = None + self._fitted_feature_names: list[str] = [] + + def save(self, filename: str) -> None: + """Serialise the model to disk using joblib. + + Parameters + ---------- + filename : str + Destination file path where the model will be written. + """ + import joblib + + joblib.dump(self, filename) + + @staticmethod + def load(filename: str) -> Any: + """Deserialise a model from disk using joblib. + + Parameters + ---------- + filename : str + Path to the file previously written by :meth:`save`. + + Returns + ------- + Any + The loaded model instance. + """ + import joblib + + model = joblib.load(filename) + return model + + def _prepare_features(self, x: "DashAIDataset", is_fit: bool = False): + """Return the feature matrix used by sklearn clusterers. + + During fitting, the incoming columns are remembered. During label + assignment for new samples, the same columns are selected in the same + order. + """ + x_pandas = x.to_pandas() + + if is_fit: + self._fitted_feature_names = list(x_pandas.columns) + return x_pandas + + missing_columns = [ + name for name in self._fitted_feature_names if name not in x_pandas.columns + ] + if missing_columns: + raise ValueError( + f"Missing fitted feature columns in dataset: {missing_columns}." + ) + return x_pandas[self._fitted_feature_names] + + def train(self, x_train: "DashAIDataset") -> "SklearnLikeClusterer": + """Fit the clustering model using input features only. + + Parameters + ---------- + x_train : DashAIDataset + Dataset containing the input columns selected for clustering. + + Returns + ------- + SklearnLikeClusterer + The fitted clustering adapter. + + Raises + ------ + ValueError + If the wrapped sklearn estimator does not expose labels after + fitting. + """ + x_processed = self._prepare_features(x_train, is_fit=True) + + if hasattr(self, "fit_predict"): + self._labels = self.fit_predict(x_processed) + else: + self.fit(x_processed) + self._labels = getattr(self, "labels_", None) + + if self._labels is None: + raise ValueError( + f"{self.__class__.__name__} did not produce cluster labels." + ) + + return self + + def get_cluster_labels(self, x: "DashAIDataset | None" = None) -> "ndarray": + """Return fitted labels or assign labels to new samples when supported. + + Some sklearn clusterers, such as K-Means, implement ``predict`` and can + assign labels to new samples. Others, such as DBSCAN, only expose labels + for the fitted dataset through ``labels_``. This method supports both + cases. + + Parameters + ---------- + x : DashAIDataset, optional + Samples to assign to clusters. If omitted, or if the wrapped + estimator does not support ``predict``, the labels discovered during + fitting are returned. + + Returns + ------- + numpy.ndarray + Cluster label assigned to each sample. + + Raises + ------ + ValueError + If the model has not been fitted, or if labels are requested for + unseen samples from an estimator that cannot predict new labels. + """ + if x is not None and hasattr(self, "predict"): + x_processed = self._prepare_features(x, is_fit=False) + return self.predict(x_processed) + + if self._labels is None: + raise ValueError( + f"{self.__class__.__name__} must be fitted before returning labels." + ) + + if x is not None and len(x) != len(self._labels): + raise ValueError( + f"{self.__class__.__name__} cannot assign labels to unseen samples." + ) + + return self._labels diff --git a/DashAI/back/models/scikit_learn/sklearn_like_model.py b/DashAI/back/models/scikit_learn/sklearn_like_model.py index 786d721e0..c1cde7872 100644 --- a/DashAI/back/models/scikit_learn/sklearn_like_model.py +++ b/DashAI/back/models/scikit_learn/sklearn_like_model.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING -from DashAI.back.models.base_model import BaseModel from DashAI.back.models.categorical_encoder_mixin import CategoricalEncoderMixin +from DashAI.back.models.supervised_model import SupervisedModel if TYPE_CHECKING: from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset -class SklearnLikeModel(CategoricalEncoderMixin, BaseModel): +class SklearnLikeModel(CategoricalEncoderMixin, SupervisedModel): """Abstract base class for scikit-learn-compatible DashAI models. Provides ``save`` / ``load`` via joblib and inherits the categorical @@ -15,6 +15,11 @@ class SklearnLikeModel(CategoricalEncoderMixin, BaseModel): ``CategoricalEncoderMixin``. Concrete subclasses (classifiers and regressors) supply ``train`` and ``predict`` implementations backed by scikit-learn estimators. + + Inherits directly from ``SupervisedModel`` rather than ``BaseModel``. + Its ``train`` signature requires labeled data (``y_train``), which is + the defining property of supervised learning, so every model built on + this class is supervised by construction. """ def __init__(self, *args, **kwargs): diff --git a/DashAI/back/models/scikit_learn/spectral_clustering.py b/DashAI/back/models/scikit_learn/spectral_clustering.py new file mode 100644 index 000000000..ccfaa4df4 --- /dev/null +++ b/DashAI/back/models/scikit_learn/spectral_clustering.py @@ -0,0 +1,169 @@ +"""DashAI Spectral clustering model.""" + +from sklearn.cluster import SpectralClustering as _SpectralClustering + +from DashAI.back.core.schema_fields import ( + enum_field, + float_field, + int_field, + schema_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.scikit_learn.sklearn_like_clusterer import ( + SklearnLikeClusterer, +) + + +class SpectralClusteringSchema(BaseSchema): + """Schema that configures the Spectral clustering model. + + Spectral clustering constructs an affinity graph from the data, computes + its leading eigenvectors, and partitions the resulting low-dimensional + representation. It excels at finding clusters with non-convex shapes that + defeat centroid-based algorithms. + """ + + n_clusters: schema_field( + int_field(ge=2), + placeholder=8, + description=MultilingualString( + en="Number of clusters to form.", + es="Número de clusters a formar.", + pt="Número de clusters a formar.", + de="Anzahl der zu bildenden Cluster.", + zh="要形成的聚类数量。", + ), + alias=MultilingualString( + en="Clusters", es="Clusters", pt="Clusters", de="Cluster", zh="聚类数" + ), + ) # type: ignore + affinity: schema_field( + enum_field(["rbf", "nearest_neighbors", "cosine"]), + "rbf", + description=MultilingualString( + en=( + "Similarity measure used to construct the affinity graph. " + "'rbf' uses a Gaussian kernel controlled by 'gamma'." + ), + es=( + "Medida de similitud para construir el grafo de afinidad. " + "'rbf' usa un kernel gaussiano controlado por 'gamma'." + ), + pt=( + "Medida de similaridade usada para construir o grafo de afinidade. " + "'rbf' usa um kernel gaussiano controlado por 'gamma'." + ), + de=( + "Ähnlichkeitsmaß zur Konstruktion des Affinitätsgraphen. " + "'rbf' verwendet einen Gauß-Kernel, gesteuert über 'gamma'." + ), + zh="用于构建亲和图的相似性度量。'rbf'使用由'gamma'控制的高斯核。", + ), + alias=MultilingualString( + en="Affinity", es="Afinidad", pt="Afinidade", de="Affinität", zh="亲和度" + ), + ) # type: ignore + gamma: schema_field( + float_field(gt=0.0), + placeholder=1.0, + description=MultilingualString( + en=( + "Kernel coefficient for the 'rbf' affinity. " + "Ignored for other affinities." + ), + es=( + "Coeficiente del kernel para la afinidad 'rbf'. " + "Se ignora para otras afinidades." + ), + pt=( + "Coeficiente do kernel para a afinidade 'rbf'. " + "Ignorado para outras afinidades." + ), + de=( + "Kernel-Koeffizient für die 'rbf'-Affinität. " + "Wird für andere Affinitäten ignoriert." + ), + zh="'rbf'亲和度的核系数。对其他亲和度类型无效。", + ), + alias=MultilingualString( + en="Gamma", es="Gamma", pt="Gamma", de="Gamma", zh="Gamma" + ), + ) # type: ignore + random_state: schema_field( + int_field(ge=0), + placeholder=0, + description=MultilingualString( + en=( + "Random seed for eigenvector decomposition and k-means initialisation." + ), + es=( + "Semilla aleatoria para la descomposición de " + "eigenvectores e inicialización de k-means." + ), + pt=( + "Semente aleatória para a decomposição de autovetores " + "e inicialização do k-means." + ), + de=( + "Zufallsstartwert für die Eigenvektorzerlegung und die " + "K-Means-Initialisierung." + ), + zh="用于特征向量分解和k-means初始化的随机种子。", + ), + alias=MultilingualString( + en="Random state", + es="Estado aleatorio", + pt="Estado aleatório", + de="Zufallszustand", + zh="随机状态", + ), + ) # type: ignore + + +class SpectralClustering(SklearnLikeClusterer, _SpectralClustering): + """Spectral clustering model for the Models module. + + Spectral clustering applies eigenvalue decomposition to an affinity matrix + derived from the input data. It can detect clusters of arbitrary shape by + leveraging graph-theoretic connectivity. Because it does not expose a + ``predict`` method, cluster labels for new samples cannot be inferred + without re-fitting. + + Key hyperparameters include ``n_clusters`` (partitions of the spectral + embedding), ``affinity`` (graph construction method), ``gamma`` (RBF kernel + width), and ``random_state`` (seed). The implementation wraps scikit-learn's + ``SpectralClustering`` estimator. + + References + ---------- + - [1] Ng, A. Y., Jordan, M. I., & Weiss, Y. (2002). "On spectral + clustering: Analysis and an algorithm." + - [2] https://scikit-learn.org/stable/modules/generated/ + sklearn.cluster.SpectralClustering.html + """ + + SCHEMA = SpectralClusteringSchema + DISPLAY_NAME = MultilingualString( + en="Spectral", es="Espectral", pt="Espectral", de="Spektral", zh="谱聚类" + ) + DESCRIPTION = MultilingualString( + en="Graph-based clustering via eigenvalue decomposition.", + es="Clustering basado en grafos mediante descomposición de eigenvalores.", + pt="Clustering baseado em grafos por meio de decomposição de autovalores.", + de="Graphbasiertes Clustering mittels Eigenwertzerlegung.", + zh="通过特征值分解实现的基于图的聚类方法。", + ) + COLOR = "#EC407A" + ICON = "Grain" + + def __init__(self, **kwargs) -> None: + """Initialise the model by forwarding all kwargs to the parent class. + + Parameters + ---------- + **kwargs : dict + Hyperparameter values forwarded to the parent sklearn wrapper. See + the associated schema class for available keys and their defaults. + """ + super().__init__(**kwargs) diff --git a/DashAI/back/models/supervised_model.py b/DashAI/back/models/supervised_model.py new file mode 100644 index 000000000..dc3a23228 --- /dev/null +++ b/DashAI/back/models/supervised_model.py @@ -0,0 +1,333 @@ +"""Base class for models trained and evaluated with target columns.""" + +import logging +import math +from abc import abstractmethod +from typing import TYPE_CHECKING, Dict, final + +from kink import di + +from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum +from DashAI.back.dependencies.database.models import Metric +from DashAI.back.models.base_model import BaseModel + +if TYPE_CHECKING: + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + +logger = logging.getLogger(__name__) + + +class SupervisedModel(BaseModel): + """Base contract for supervised models in DashAI. + + Supervised models are trained with input features and target columns, and + their metrics are computed by comparing ground-truth values against model + predictions. The metric persistence helpers live here instead of + ``BaseModel`` because they assume ``y_true``/``y_pred`` evaluation and + train/validation/test splits. + """ + + @abstractmethod + def train( + self, + x_train: "DashAIDataset", + y_train: "DashAIDataset", + x_validation: "DashAIDataset" = None, + y_validation: "DashAIDataset" = None, + ) -> "BaseModel": + """Train the model with supervised input features and targets. + + Parameters + ---------- + x_train : DashAIDataset + The input features for training. + y_train : DashAIDataset + The target labels for training. + x_validation : DashAIDataset, optional + Input features for + validation. Defaults to None. + y_validation : DashAIDataset, optional + Target labels for + validation. Defaults to None. + + Returns + ------- + BaseModel + The trained model instance. + """ + raise NotImplementedError + + @final + def _save_metrics( + self, + split: SplitEnum, + level: LevelEnum, + results: Dict[str, float], + log_index: int = None, + fold_index: int = None, + inner_fold_index: int = None, + ): + """Persist computed metric values to the database. + + Handles step-index computation and upsert logic for LAST-level metrics. + Called internally by `calculate_metrics` after scores are computed. + + Parameters + ---------- + split : SplitEnum + The data split the metrics belong to (TRAIN, + VALIDATION, or TEST). + level : LevelEnum + The granularity level (LAST, TRIAL, STEP, or + BATCH). LAST-level entries are upserted; others are inserted. + results : Dict[str, float] + Mapping of metric name to score value. + log_index : int, optional + Explicit step index for the entries. + If None, the next index is derived from existing database + entries. Defaults to None. + """ + with di["session_factory"]() as db: + # Initialize tracking dict if not exists + if not hasattr(self, "_metric_step_counters"): + self._metric_step_counters = {} + + # Create a unique key for this run/split/level combination + counter_key = (self.run_id, split, level) + + # 1. Determine log_index + if counter_key not in self._metric_step_counters: + steps = ( + db.query(Metric.step) + .filter_by(run_id=self.run_id, split=split, level=level) + .order_by(Metric.step.desc()) + .limit(2) + .all() + ) + + if not steps: + current, previous = 0, 0 + elif len(steps) == 1: + current, previous = steps[0][0], 0 + else: + current, previous = steps[0][0], steps[1][0] + + self._metric_step_counters[counter_key] = { + "current": current, + "previous": previous, + } + + counter = self._metric_step_counters[counter_key] + + current_max = counter["current"] + previous_max = counter["previous"] + + # Compute delta (preserve spacing) + delta = current_max - previous_max + if delta <= 0: + delta = 1 + + # Case 1: no log_index -> advance naturally + if log_index is None or log_index <= current_max: + log_index = current_max + delta + + # Update the in-memory tracker + counter["previous"] = current_max + counter["current"] = log_index + + # 2. Handle 'LAST' level replacement logic + if level == LevelEnum.LAST: + for name, value in results.items(): + existing = ( + db.query(Metric) + .filter_by( + run_id=self.run_id, split=split, level=level, name=name + ) + .first() + ) + + if existing: + existing.value = value + existing.step = log_index + else: + db.add( + Metric( + run_id=self.run_id, + split=split, + level=level, + name=name, + value=value, + step=log_index, + ) + ) + + # 3. Standard logging (STEP, BATCH, TRIAL) - just insert + else: + metric_entries = [ + Metric( + run_id=self.run_id, + split=split, + level=level, + name=name, + value=score, + step=log_index, + fold_index=fold_index, + inner_fold_index=inner_fold_index, + ) + for name, score in results.items() + ] + db.add_all(metric_entries) + + db.commit() + + @final + def calculate_metrics( + self, + split: SplitEnum = SplitEnum.VALIDATION, + level: LevelEnum = LevelEnum.LAST, + log_index: int = None, + x_data: "DashAIDataset" = None, + y_data: "DashAIDataset" = None, + fold_index: int = None, + inner_fold_index: int = None, + ): + """Calculate and save metrics for a given data split and level. + + Parameters + ---------- + split : SplitEnum + The data split to evaluate (TRAIN, VALIDATION, + or TEST). Defaults to SplitEnum.VALIDATION. + level : LevelEnum + The metric granularity level (LAST, TRIAL, + STEP, or BATCH). Defaults to LevelEnum.LAST. + log_index : int, optional + Explicit step index for the metric + entry. If None, the next step index is computed automatically. + Defaults to None. + x_data : DashAIDataset, optional + Input features. If None, the + dataset stored in the model for the given split is used. + Defaults to None. + y_data : DashAIDataset, optional + Target labels. If None, the + labels stored in the model for the given split are used. + Defaults to None. + """ + # Get the appropriate metrics based on split + metrics_attr = f"{split.value}_metrics" + metrics = getattr(self, metrics_attr, None) + + # If no metrics or run_id, skip calculation + if not metrics or not self.run_id: + return + + # Load data if not provided + if x_data is None or y_data is None: + if self.x_data is None or self.y_data is None: + return + x_data = self.x_data[split.value] + y_data = self.y_data[split.value] + + # If data is empty after retrieval, skip calculation + if x_data is None or y_data is None: + return + + # Make predictions and transform outputs + y_pred = self.predict(x_data) + y_transformed = self.prepare_output(y_data, is_fit=False) + + # Calculate metric scores + results = {} + for metric in metrics: + score = metric.score(y_transformed, y_pred) + if not math.isfinite(score): + logger.warning( + "Metric %s returned a non-finite value (%s) for split %s " + "(e.g. only one class present in the split). Skipping.", + metric.__name__, + score, + split, + ) + continue + results[metric.__name__] = score + + # Save to database + self._save_metrics( + split=split, + level=level, + results=results, + log_index=log_index, + fold_index=fold_index, + inner_fold_index=inner_fold_index, + ) + + # Report the epoch to whoever is watching, AFTER persisting: the reporter + # is allowed to raise (Optuna prunes that way), and the metrics of the + # epoch that triggered the stop should survive it. + if ( + self._epoch_reporter is not None + and level is LevelEnum.EPOCH + and split is SplitEnum.VALIDATION + ): + self._epoch_reporter(results, log_index) + + # Create a function similar to calculate_metrics that returns the scores + # instead of saving them to the database, to be used in the CV evaluation loop + def compute_metrics( + self, + split: SplitEnum = SplitEnum.TEST, + x_data: "DashAIDataset" = None, + y_data: "DashAIDataset" = None, + ) -> Dict[str, float]: + """Calculate and return metric scores for a given data split. + + Parameters + ---------- + split : SplitEnum + The data split to evaluate (TRAIN, VALIDATION, + or TEST). Defaults to SplitEnum.VALIDATION. + x_data : DashAIDataset, optional + Input features. If None, the + dataset stored in the model for the given split is used. + Defaults to None. + y_data : DashAIDataset, optional + Target labels. If None, the + labels stored in the model for the given split are used. + Defaults to None. + + Returns + ------- + Dict[str, float] + A dictionary mapping metric names to their computed scores. + """ + # Get the appropriate metrics based on split + metrics_attr = f"{split.value}_metrics" + metrics = getattr(self, metrics_attr, None) + + # If no metrics, return empty dict + if not metrics: + return {} + + # Load data if not provided + if x_data is None or y_data is None: + if self.x_data is None or self.y_data is None: + return {} + x_data = self.x_data[split.value] + y_data = self.y_data[split.value] + + # If data is empty after retrieval, return empty dict + if x_data is None or y_data is None: + return {} + + # Make predictions and transform outputs + y_pred = self.predict(x_data) + y_transformed = self.prepare_output(y_data, is_fit=False) + + # Calculate metric scores + results = {} + for metric in metrics: + score = metric.score(y_transformed, y_pred) + results[metric.__name__] = score + + return results diff --git a/DashAI/back/models/tabular_classification_model.py b/DashAI/back/models/tabular_classification_model.py index a97134ad7..534fae615 100644 --- a/DashAI/back/models/tabular_classification_model.py +++ b/DashAI/back/models/tabular_classification_model.py @@ -1,4 +1,7 @@ -class TabularClassificationModel: +from DashAI.back.models.supervised_model import SupervisedModel + + +class TabularClassificationModel(SupervisedModel): """Base mixin for models that perform tabular classification tasks. Concrete tabular classification models should extend this class alongside diff --git a/DashAI/back/models/text_classification_model.py b/DashAI/back/models/text_classification_model.py index 0d06c4815..db6a38d8b 100644 --- a/DashAI/back/models/text_classification_model.py +++ b/DashAI/back/models/text_classification_model.py @@ -1,10 +1,10 @@ from abc import abstractmethod from typing import Any -from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.supervised_model import SupervisedModel -class TextClassificationModel(BaseModel): +class TextClassificationModel(SupervisedModel): """Base class for models that perform text classification tasks. Concrete text classification models must extend this class and implement diff --git a/DashAI/back/models/translation_model.py b/DashAI/back/models/translation_model.py index 78060bcd9..a97ad094f 100644 --- a/DashAI/back/models/translation_model.py +++ b/DashAI/back/models/translation_model.py @@ -1,7 +1,7 @@ -from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.supervised_model import SupervisedModel -class TranslationModel(BaseModel): +class TranslationModel(SupervisedModel): """Base class for models that perform text translation tasks. Concrete translation models must extend this class and implement ``save``, diff --git a/DashAI/back/static/images/clustering.png b/DashAI/back/static/images/clustering.png new file mode 100644 index 000000000..2175ab405 Binary files /dev/null and b/DashAI/back/static/images/clustering.png differ diff --git a/DashAI/back/tasks/base_task.py b/DashAI/back/tasks/base_task.py index 45f9b511b..8c575c2f1 100644 --- a/DashAI/back/tasks/base_task.py +++ b/DashAI/back/tasks/base_task.py @@ -1,4 +1,3 @@ -from abc import abstractmethod from typing import TYPE_CHECKING, Any, Dict, Final, List, Union from starlette.datastructures import UploadFile @@ -15,29 +14,24 @@ class BaseTask: """Base class for DashAI compatible tasks.""" TYPE: Final[str] = "Task" + REQUIRES_TARGET: bool = True + SESSION_CONFIG_SCHEMA: Dict[str, Any] = {} @property - @abstractmethod def schema(self) -> Dict[str, Any]: - """Return the schema of components compatible with this task. + """Return optional task-specific configuration schema. - Concrete subclasses must implement this property to return a mapping - that describes which models, metrics, and other components are compatible - with the task. + Most modeling tasks do not need an additional schema beyond their + metadata and compatible components, so the default is an empty mapping. + Concrete tasks can override this property when they require extra + task-level parameters. Returns ------- Dict[str, Any] - A dictionary whose keys are component category names (e.g. - ``"models"``, ``"metrics"``) and whose values are lists or - mappings of the compatible component classes or identifiers. - - Raises - ------ - NotImplementedError - If the subclass does not provide an implementation. + Task-specific schema, or an empty dictionary when not applicable. """ - raise NotImplementedError + return {} @classmethod def get_metadata(cls) -> Dict[str, Any]: @@ -79,6 +73,8 @@ def _name(dashai_type) -> str: "outputs_types": outputs_types, "inputs_cardinality": metadata["inputs_cardinality"], "outputs_cardinality": metadata["outputs_cardinality"], + "requires_target": cls.REQUIRES_TARGET, + "session_config_schema": cls.SESSION_CONFIG_SCHEMA, } return parsed_metadata @@ -87,7 +83,7 @@ def validate_dataset_for_task( dataset: "DashAIDataset", dataset_name: str, input_columns: List[str], - output_columns: List[str], + output_columns: List[str] | None, ) -> None: """Validate a dataset for the current task. @@ -104,6 +100,8 @@ def validate_dataset_for_task( inputs_cardinality = metadata["inputs_cardinality"] outputs_cardinality = metadata["outputs_cardinality"] types = dataset._types + output_columns = output_columns or [] + # Check input types for input_col in input_columns: input_col_type = types[input_col] @@ -141,7 +139,7 @@ def prepare_for_task( self, dataset: Union["DatasetDict", "DashAIDataset"], input_columns: List[str], - output_columns: List[str], + output_columns: List[str] | None = None, ) -> "DashAIDataset": """Prepare and validate a dataset for this task. @@ -156,8 +154,9 @@ def prepare_for_task( converted to ``DashAIDataset`` automatically. input_columns : list of str Names of columns to use as model inputs. - output_columns : list of str - Names of columns to use as model outputs/targets. + output_columns : list of str, optional + Names of columns to use as model outputs/targets. Tasks without + targets, such as clustering, can omit this argument. Returns ------- @@ -175,6 +174,7 @@ def prepare_for_task( from DashAI.back.dataloaders.classes.dashai_dataset import to_dashai_dataset dashai_dataset = to_dashai_dataset(dataset) + output_columns = output_columns or [] self.validate_dataset_for_task( dashai_dataset, dataset_name=getattr(dashai_dataset, "name", "dataset"), @@ -183,24 +183,6 @@ def prepare_for_task( ) return dashai_dataset - @abstractmethod - def num_labels(self, dataset: "DashAIDataset", output_column: str) -> int | None: - """Get the number of unique labels in the output column. - - Parameters - ---------- - dataset : DashAIDataset - Dataset used for training - output_column : str - Output column - - Returns - ------- - int | None - Number of unique labels or None if not applicable - """ - raise NotImplementedError - def _validate_and_normalize_value( self, value: Any, diff --git a/DashAI/back/tasks/classification_task.py b/DashAI/back/tasks/classification_task.py index b12c3a17f..c6e2d4cde 100644 --- a/DashAI/back/tasks/classification_task.py +++ b/DashAI/back/tasks/classification_task.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from DashAI.back.tasks.base_task import BaseTask +from DashAI.back.tasks.supervised_task import SupervisedTask from DashAI.back.types.categorical import Categorical from DashAI.back.types.dashai_value import DashAIValue @@ -10,7 +10,7 @@ from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset -class ClassificationTask(BaseTask): +class ClassificationTask(SupervisedTask): """Abstract base task for all classification problems in DashAI. Classification tasks map input features to a finite set of discrete class @@ -69,45 +69,6 @@ def process_predictions( return np.array([output_type.int2str(idx) for idx in predictions]) return np.array(predictions) - def prepare_for_task( - self, - dataset: "DashAIDataset", - input_columns: List[str], - output_columns: List[str], - ) -> "DashAIDataset": - """Prepare a dataset for a classification task. - - Delegates to the base class for type validation and conversion, then - verifies that every output column carries a ``Categorical`` type, which - is required for classification targets. - - Parameters - ---------- - dataset : DashAIDataset - The dataset to prepare. Can be a plain ``DatasetDict`` or an - already-converted ``DashAIDataset``. - input_columns : List[str] - Names of the columns to be used as model inputs. - output_columns : List[str] - Names of the columns to be used as classification targets. Each - must have a ``Categorical`` type. - - Returns - ------- - DashAIDataset - The validated and type-checked dataset, ready for training or - inference. - """ - dashai_dataset = super().prepare_for_task( - dataset, input_columns, output_columns - ) - - for column in output_columns: - column_type = dashai_dataset.types.get(column) - if isinstance(column_type, Categorical): - continue - return dashai_dataset - def num_labels(self, dataset: "DashAIDataset", output_column: str) -> int | None: """Get the number of unique labels in the output column. diff --git a/DashAI/back/tasks/clustering_task.py b/DashAI/back/tasks/clustering_task.py new file mode 100644 index 000000000..84603ce3e --- /dev/null +++ b/DashAI/back/tasks/clustering_task.py @@ -0,0 +1,32 @@ +from DashAI.back.core.utils import MultilingualString +from DashAI.back.tasks.unsupervised_task import UnsupervisedTask +from DashAI.back.types.value_types import Float, Integer + + +class ClusteringTask(UnsupervisedTask): + """Task for grouping samples into clusters without target labels. + + Clustering tasks discover groups directly from numeric input features. + Unlike supervised tasks, they do not require output columns and are + evaluated with internal clustering metrics computed from the feature matrix + and the cluster labels produced by the model. + """ + + DESCRIPTION: str = MultilingualString( + en="Group similar samples from numeric features without target labels.", + es="Agrupa muestras similares desde variables numericas sin etiquetas.", + pt="Agrupa amostras semelhantes a partir de variáveis numéricas sem rótulos.", + de="Gruppiert ähnliche Stichproben anhand numerischer Merkmale ohne " + "Zielbezeichnungen.", + zh="根据数值特征对相似样本进行分组,无需目标标签。", + ) + DISPLAY_NAME: str = MultilingualString( + en="Clustering", es="Agrupamiento", pt="Agrupamento", de="Clustering", zh="聚类" + ) + + metadata: dict = { + "inputs_types": [Float, Integer], + "outputs_types": [], + "inputs_cardinality": "n", + "outputs_cardinality": 0, + } diff --git a/DashAI/back/tasks/regression_task.py b/DashAI/back/tasks/regression_task.py index 9eefedae0..477c21510 100644 --- a/DashAI/back/tasks/regression_task.py +++ b/DashAI/back/tasks/regression_task.py @@ -1,18 +1,10 @@ -from typing import TYPE_CHECKING, List, Union - from DashAI.back.core.utils import MultilingualString -from DashAI.back.tasks.base_task import BaseTask +from DashAI.back.tasks.supervised_task import SupervisedTask from DashAI.back.types.categorical import Categorical from DashAI.back.types.value_types import Float, Integer -if TYPE_CHECKING: - from datasets import DatasetDict - from numpy import ndarray - - from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset - -class RegressionTask(BaseTask): +class RegressionTask(SupervisedTask): """Abstract base task for continuous-output (regression) problems in DashAI. Regression tasks predict one or more continuous numeric values from input @@ -39,66 +31,3 @@ class RegressionTask(BaseTask): "inputs_cardinality": "n", "outputs_cardinality": 1, } - - def prepare_for_task( - self, - dataset: Union["DatasetDict", "DashAIDataset"], - input_columns: List[str], - output_columns: List[str], - ) -> "DashAIDataset": - """Convert the dataset to DashAIDataset and validate types. - - - A copy of the dataset is created. - - Parameters - ---------- - datasetdict : DatasetDict - Dataset to be changed - - Returns - ------- - DashAIDataset - Dataset with validated types - """ - dashai_dataset = super().prepare_for_task( - dataset, input_columns, output_columns - ) - return dashai_dataset - - def process_predictions( - self, dataset: "DashAIDataset", predictions: "ndarray", output_column: str - ): - """Process the predictions - - Parameters - ---------- - dataset : DashAIDataset - Dataset used for training - predictions : np.ndarray - Predictions from the model - output_column : str - Output column - - Returns - ------- - Processed predictions - """ - return predictions - - def num_labels(self, dataset: "DashAIDataset", output_column: str) -> int | None: - """Get the number of unique labels in the output column. - - Parameters - ---------- - dataset : DashAIDataset - Dataset used for training - output_column : str - Output column - - Returns - ------- - int | None - Number of unique labels or None if not applicable - """ - return None diff --git a/DashAI/back/tasks/supervised_task.py b/DashAI/back/tasks/supervised_task.py new file mode 100644 index 000000000..81c38ccbe --- /dev/null +++ b/DashAI/back/tasks/supervised_task.py @@ -0,0 +1,59 @@ +"""Base task for supervised model.""" + +from typing import TYPE_CHECKING + +from numpy import ndarray + +from DashAI.back.tasks.base_task import BaseTask + +if TYPE_CHECKING: + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class SupervisedTask(BaseTask): + """Base class for tasks trained with input and target columns.""" + + REQUIRES_TARGET = True + SESSION_CONFIG_SCHEMA = { + "split_strategy": "random", + "supports_shuffle": True, + "supports_stratify": True, + } + + def num_labels(self, dataset: "DashAIDataset", output_column: str) -> int | None: + """Return the number of unique labels in the output column for supervised tasks. + + Parameters + ---------- + dataset : DashAIDataset + Dataset used for training + output_column : str + Output column + + Returns + ------- + int | None + Number of unique labels or None if not applicable + """ + return None + + def process_predictions( + self, dataset: "DashAIDataset", predictions: "ndarray", output_column: str + ): + """Process the predictions. + Return predictions unchanged unless a supervised task specializes them. + + Parameters + ---------- + dataset : DashAIDataset + Dataset used for training + predictions : np.ndarray + Predictions from the model + output_column : str + Output column + + Returns + ------- + Processed predictions + """ + return predictions diff --git a/DashAI/back/tasks/tabular_classification_task.py b/DashAI/back/tasks/tabular_classification_task.py index 6839db912..12567de58 100644 --- a/DashAI/back/tasks/tabular_classification_task.py +++ b/DashAI/back/tasks/tabular_classification_task.py @@ -1,15 +1,8 @@ -from typing import TYPE_CHECKING, List, Union - from DashAI.back.core.utils import MultilingualString from DashAI.back.tasks.classification_task import ClassificationTask from DashAI.back.types.categorical import Categorical from DashAI.back.types.value_types import Float, Integer -if TYPE_CHECKING: - from datasets import DatasetDict - - from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset - class TabularClassificationTask(ClassificationTask): """Task for classifying structured tabular data into discrete categories. @@ -49,28 +42,3 @@ class TabularClassificationTask(ClassificationTask): "inputs_cardinality": "n", "outputs_cardinality": 1, } - - def prepare_for_task( - self, - dataset: Union["DatasetDict", "DashAIDataset"], - input_columns: List[str], - output_columns: List[str], - ) -> "DashAIDataset": - """Convert the dataset to DashAIDataset and check the columns types - - A copy of the dataset is created. - - Parameters - ---------- - dataset : Union[DatasetDict, DashAIDataset] - Dataset to be changed - - Returns - ------- - DashAIDataset - Dataset with the new types - """ - dashai_dataset = super().prepare_for_task( - dataset, input_columns, output_columns - ) - return dashai_dataset diff --git a/DashAI/back/tasks/text_classification_task.py b/DashAI/back/tasks/text_classification_task.py index 9ea82bd92..8966ef1de 100644 --- a/DashAI/back/tasks/text_classification_task.py +++ b/DashAI/back/tasks/text_classification_task.py @@ -1,15 +1,8 @@ -from typing import TYPE_CHECKING, List, Union - from DashAI.back.core.utils import MultilingualString from DashAI.back.tasks.classification_task import ClassificationTask from DashAI.back.types.categorical import Categorical from DashAI.back.types.value_types import Text -if TYPE_CHECKING: - from datasets import DatasetDict - - from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset - class TextClassificationTask(ClassificationTask): """Task for classifying a single text column into discrete categories. @@ -55,28 +48,3 @@ class TextClassificationTask(ClassificationTask): de="Textklassifikation", zh="文本分类", ) - - def prepare_for_task( - self, - dataset: Union["DatasetDict", "DashAIDataset"], - input_columns: List[str], - output_columns: List[str], - ) -> "DashAIDataset": - """Convert the dataset to DashAIDataset and check the columns types - - A copy of the dataset is created. - - Parameters - ---------- - dataset : Union[DatasetDict, DashAIDataset] - Dataset to be changed - - Returns - ------- - DashAIDataset - Dataset with the new types - """ - dashai_dataset = super().prepare_for_task( - dataset, input_columns, output_columns - ) - return dashai_dataset diff --git a/DashAI/back/tasks/translation_task.py b/DashAI/back/tasks/translation_task.py index 467b2f349..a9347caaf 100644 --- a/DashAI/back/tasks/translation_task.py +++ b/DashAI/back/tasks/translation_task.py @@ -1,19 +1,11 @@ """DashAI Translation Task.""" -from typing import TYPE_CHECKING, List, Union - from DashAI.back.core.utils import MultilingualString -from DashAI.back.tasks.base_task import BaseTask +from DashAI.back.tasks.supervised_task import SupervisedTask from DashAI.back.types.value_types import Text -if TYPE_CHECKING: - from datasets import DatasetDict - from numpy import ndarray - - from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset - -class TranslationTask(BaseTask): +class TranslationTask(SupervisedTask): """Task for sequence-to-sequence machine translation between languages. Translation tasks take a single ``Text`` input column (source language) and @@ -44,65 +36,3 @@ class TranslationTask(BaseTask): DISPLAY_NAME: str = MultilingualString( en="Translation", es="Traducción", pt="Tradução", de="Übersetzung", zh="翻译" ) - - def prepare_for_task( - self, - dataset: Union["DatasetDict", "DashAIDataset"], - input_columns: List[str], - output_columns: List[str], - ) -> "DashAIDataset": - """Convert the dataset to DashAIDataset and check the columns types - - A copy of the dataset is created. - - Parameters - ---------- - dataset : Union[DatasetDict, DashAIDataset] - Dataset to be changed - - Returns - ------- - DashAIDataset - Dataset with the new types - """ - dashai_dataset = super().prepare_for_task( - dataset, input_columns, output_columns - ) - return dashai_dataset - - def process_predictions( - self, dataset: "DashAIDataset", predictions: "ndarray", output_column: str - ): - """Process the predictions - - Parameters - ---------- - dataset : DashAIDataset - Dataset used for training - predictions : np.ndarray - Predictions from the model - output_column : str - Output column - - Returns - ------- - Processed predictions - """ - return predictions - - def num_labels(self, dataset: "DashAIDataset", output_column: str) -> int | None: - """Get the number of unique labels in the output column. - - Parameters - ---------- - dataset : DashAIDataset - Dataset used for training - output_column : str - Output column - - Returns - ------- - int | None - Number of unique labels or None if not applicable - """ - return None diff --git a/DashAI/back/tasks/unsupervised_task.py b/DashAI/back/tasks/unsupervised_task.py new file mode 100644 index 000000000..7a63ba46a --- /dev/null +++ b/DashAI/back/tasks/unsupervised_task.py @@ -0,0 +1,12 @@ +"""Base task for unsupervised model.""" + +from DashAI.back.tasks.base_task import BaseTask + + +class UnsupervisedTask(BaseTask): + """Base class for tasks trained without target columns.""" + + REQUIRES_TARGET = False + SESSION_CONFIG_SCHEMA = { + "split_strategy": "none", + } diff --git a/DashAI/front/src/components/configurableObject/Inputs/SelectInput.jsx b/DashAI/front/src/components/configurableObject/Inputs/SelectInput.jsx index a50a07420..c01c29939 100644 --- a/DashAI/front/src/components/configurableObject/Inputs/SelectInput.jsx +++ b/DashAI/front/src/components/configurableObject/Inputs/SelectInput.jsx @@ -22,6 +22,7 @@ function SelectInput({ description, options, optionNames = undefined, + optionDescriptions = undefined, }) { const handleChange = (event) => { const inputValue = event.target.value; @@ -58,12 +59,27 @@ function SelectInput({ display: "block", }, }, + secondary: { + sx: { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "normal", + maxWidth: "100%", + display: "block", + lineHeight: 1.35, + }, + }, }} primary={ optionNames !== undefined && index < options.length ? optionNames[index] : option } + secondary={ + optionDescriptions !== undefined && index < options.length + ? optionDescriptions[index] + : undefined + } /> ))} @@ -80,6 +96,7 @@ SelectInput.propTypes = { error: PropTypes.string, options: PropTypes.arrayOf(PropTypes.string).isRequired, optionNames: PropTypes.arrayOf(PropTypes.string), + optionDescriptions: PropTypes.arrayOf(PropTypes.string), }; export default SelectInput; diff --git a/DashAI/front/src/components/models/AddModelDialog.jsx b/DashAI/front/src/components/models/AddModelDialog.jsx index 02e5c5e54..694df99e2 100644 --- a/DashAI/front/src/components/models/AddModelDialog.jsx +++ b/DashAI/front/src/components/models/AddModelDialog.jsx @@ -104,6 +104,18 @@ function AddModelDialog({ }); const tourContext = useTourContext(); + const sessionSplits = useMemo(() => { + if (!session?.splits) return {}; + if (typeof session.splits === "object") return session.splits; + + try { + return JSON.parse(session.splits); + } catch { + return {}; + } + }, [session?.splits]); + + const supportsOptimization = sessionSplits.splitType !== "none"; const outerSplit = useMemo(() => { return session?.splits ? JSON.parse(session.splits) : null; @@ -127,9 +139,10 @@ function AddModelDialog({ return checkIfHaveOptimazers(modelParameters); }, [modelParameters]); - const steps = hasOptimizableParams - ? [t("models:label.configureModel"), t("models:label.configureOptimizer")] - : [t("models:label.configureModel")]; + const steps = + supportsOptimization && hasOptimizableParams + ? [t("models:label.configureModel"), t("models:label.configureOptimizer")] + : [t("models:label.configureModel")]; useEffect(() => { if (preselectedModel && preselectedModel !== selectedModel) { @@ -235,7 +248,7 @@ function AddModelDialog({ return; } - if (hasOptimizableParams) { + if (supportsOptimization && hasOptimizableParams) { setActiveStep(1); } else { handleCreateRun(); @@ -275,13 +288,15 @@ function AddModelDialog({ selectedModel, name.trim(), modelParameters || {}, - selectedOptimizer || "", - { ...defaultOptimizerParams, ...optimizerParameters }, + supportsOptimization ? selectedOptimizer || "" : "", + supportsOptimization + ? { ...defaultOptimizerParams, ...optimizerParameters } + : {}, "", "", "", "", - goalMetric || "", + supportsOptimization ? goalMetric || "" : "", "", nestedConfig, ); @@ -349,14 +364,17 @@ function AddModelDialog({ ); const isStep1Valid = Boolean(selectedModel && name.trim() !== ""); - const isStep2Valid = Boolean( - selectedOptimizer && - goalMetric && - (!useNestedCV || - (innerConfig.splitterType && - innerConfig.nSplits > 1 && - innerConfig.nSplits <= maxInnerFolds)), - ); + // A task without optimization (clustering) has no optimizer step to fill in. + const isStep2Valid = + !supportsOptimization || + Boolean( + selectedOptimizer && + goalMetric && + (!useNestedCV || + (innerConfig.splitterType && + innerConfig.nSplits > 1 && + innerConfig.nSplits <= maxInnerFolds)), + ); return ( { if (!modelSessionDetail) return; - setAvailableMetrics({ + + let splitType; + if ( + modelSessionDetail.splits && + typeof modelSessionDetail.splits === "object" + ) { + splitType = modelSessionDetail.splits.splitType; + } else { + try { + splitType = JSON.parse(modelSessionDetail.splits || "{}").splitType; + } catch { + splitType = undefined; + } + } + const sessionUsesFullMetrics = splitType === "none"; + setUsesFullMetrics(sessionUsesFullMetrics); + if (sessionUsesFullMetrics) { + setSplit("FULL"); + } + + // Merged into the previous value rather than replacing it: the FULL entry + // is written by its own effect below and must survive this one. + setAvailableMetrics((prev) => ({ + ...prev, TRAIN: modelSessionDetail.train_metrics ?? [], VALIDATION: modelSessionDetail.validation_metrics ?? [], TEST: modelSessionDetail.test_metrics ?? [], - }); + })); }, [modelSessionDetail]); // Fallback bucket per split, built from the run's final metrics rather @@ -173,13 +207,29 @@ export function LiveMetricsChart({ run, modelSessionDetail = null }) { TRAIN: toFallbackBuckets(run.train_metrics), VALIDATION: toFallbackBuckets(run.validation_metrics), TEST: toFallbackBuckets(run.test_metrics), + FULL: toFallbackLastBucket(run.full_metrics), }), - [run.train_metrics, run.validation_metrics, run.test_metrics], + [ + run.train_metrics, + run.validation_metrics, + run.test_metrics, + run.full_metrics, + ], ); const splitHasRealData = hasAnyRealMetrics(data[split]); const splitFallback = fallbackBySplit[split]; + // Unlike TRAIN/VALIDATION/TEST, FULL has no session-level config to fetch — + // its metric names come straight from the computed run.full_metrics, so + // this updates independently without re-fetching the session. + useEffect(() => { + setAvailableMetrics((prev) => ({ + ...prev, + FULL: run.full_metrics ? Object.keys(run.full_metrics) : [], + })); + }, [run.full_metrics]); + const filteredMetrics = useMemo(() => { const metrics = splitHasRealData ? (data[split]?.[level] ?? {}) @@ -234,6 +284,11 @@ export function LiveMetricsChart({ run, modelSessionDetail = null }) { const hasEpochData = splitHasRealData ? Boolean(data[split]?.EPOCH && Object.keys(data[split].EPOCH).length > 0) : splitHasFallbackData; + // FULL only ever carries a single final value, which the backend writes at + // LAST level, so this one reads its own fallback bucket. + const hasLastData = splitHasRealData + ? Boolean(data[split]?.LAST && Object.keys(data[split].LAST).length > 0) + : Object.keys(splitFallback?.LAST ?? {}).length > 0; const levelLabel = useMemo(() => { if (!level) return ""; @@ -241,6 +296,11 @@ export function LiveMetricsChart({ run, modelSessionDetail = null }) { }, [level, t]); useEffect(() => { + if (usesFullMetrics) { + setLevel(hasLastData ? "LAST" : null); + return; + } + const currentLevelHasData = (level === "TRIAL" && hasTrialData) || (level === "STEP" && hasStepData) || @@ -254,7 +314,15 @@ export function LiveMetricsChart({ run, modelSessionDetail = null }) { else if (hasStepData) setLevel("STEP"); else if (hasTrialData) setLevel("TRIAL"); else setLevel(null); - }, [split, hasEpochData, hasStepData, hasTrialData, level]); + }, [ + split, + usesFullMetrics, + hasEpochData, + hasStepData, + hasTrialData, + hasLastData, + level, + ]); const filteredMetricKeys = useMemo( () => Object.keys(filteredMetrics).sort().join(","), @@ -342,41 +410,44 @@ export function LiveMetricsChart({ run, modelSessionDetail = null }) { {/* Spans every row below it so its containing block covers the whole scrollable panel, not just this header row - otherwise it would - stop sticking as soon as the header row itself scrolls out of view. */} - - { - if (newValue !== null) setSplit(newValue); - }} + stop sticking as soon as the header row itself scrolls out of view. + Hidden on a clustering run: FULL is the only split it ever has. */} + {!usesFullMetrics && ( + alpha(theme.palette.ui.box, 0.8), - backdropFilter: "blur(8px)", + gridColumn: "2", + gridRow: "1 / -1", + justifySelf: "end", + alignSelf: "start", + position: "sticky", + top: 0, + zIndex: 2, }} > - - {t("models:label.train")} - - - {t("models:label.validation")} - - {hasTestSplit && ( - - {t("models:label.test")} + { + if (newValue !== null) setSplit(newValue); + }} + sx={{ + bgcolor: (theme) => alpha(theme.palette.ui.box, 0.8), + backdropFilter: "blur(8px)", + }} + > + + {t("models:label.train")} - )} - - + + {t("models:label.validation")} + + {hasTestSplit && ( + + {t("models:label.test")} + + )} + + + )} {summaryMetrics.length > 0 && ( @@ -533,31 +604,33 @@ export function LiveMetricsChart({ run, modelSessionDetail = null }) { )} - - - - - - - + {!usesFullMetrics && ( + + + + + + + + )} ); diff --git a/DashAI/front/src/components/models/ModelCenterContent.jsx b/DashAI/front/src/components/models/ModelCenterContent.jsx index 4ce9f4e59..fb4294c0c 100644 --- a/DashAI/front/src/components/models/ModelCenterContent.jsx +++ b/DashAI/front/src/components/models/ModelCenterContent.jsx @@ -1,6 +1,6 @@ import { useModels } from "./ModelsContext"; import { useTranslation } from "react-i18next"; -import { Box } from "@mui/material"; +import { Box, Typography } from "@mui/material"; import CreateSessionSteps from "./CreateSessionSteps"; import DatasetVisualization from "../DatasetVisualization"; import SelectOptionMenu from "../threeSectionLayout/SelectOptionMenu"; @@ -145,6 +145,10 @@ export default function ModelsCenterContent() { description: task.description || task.metadata?.short_description || "", Icon: TASK_ICONS[task.name] || DefaultTaskIcon, + badge: + task.metadata?.requires_target === false + ? t("models:label.unsupervisedBadge") + : t("models:label.supervisedBadge"), }))} searchBar={false} goToPrevStep={selectedDatasetId ? handleBackToDataset : null} diff --git a/DashAI/front/src/components/models/ModelComparisonTable.jsx b/DashAI/front/src/components/models/ModelComparisonTable.jsx index 1a173406b..e434377c2 100644 --- a/DashAI/front/src/components/models/ModelComparisonTable.jsx +++ b/DashAI/front/src/components/models/ModelComparisonTable.jsx @@ -148,7 +148,7 @@ function ModelComparisonTable({ // Compute best value per metric field const bestValues = {}; Array.from(metricsSet).forEach((metricField) => { - const metricName = metricField.replace(/^(test|train|val)_/, ""); + const metricName = metricField.replace(/^(test|train|val|full)_/, ""); const metricInfo = metrics.find((m) => m.name === metricName); const maximize = metricInfo?.metadata?.maximize; if (maximize === undefined || maximize === null) return; @@ -171,7 +171,7 @@ function ModelComparisonTable({ }); return Array.from(metricsSet).map((metricField) => { - const metricName = metricField.replace(/^(test|train|val)_/, ""); + const metricName = metricField.replace(/^(test|train|val|full)_/, ""); const metricInfo = metrics.find((m) => m.name === metricName); const metricDescription = metricInfo?.description || metricName; const maximize = metricInfo?.metadata?.maximize; @@ -516,7 +516,7 @@ ModelComparisonTable.propTypes = { onViewDetails: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, onRowClick: PropTypes.func, - metricSplit: PropTypes.oneOf(["train", "validation", "test"]), + metricSplit: PropTypes.oneOf(["train", "validation", "test", "full"]), }; export default ModelComparisonTable; diff --git a/DashAI/front/src/components/models/RunResults.jsx b/DashAI/front/src/components/models/RunResults.jsx index 89ad21f43..97ea3af5e 100644 --- a/DashAI/front/src/components/models/RunResults.jsx +++ b/DashAI/front/src/components/models/RunResults.jsx @@ -15,6 +15,22 @@ import PredictionResultsTab from "./runResults/PredictionResultsTab"; import FoldMetricsChart from "./FoldMetricsChart"; import OuterFoldMetricsTable from "./OuterFoldMetricsTable"; +// A session whose splitter is "none" (clustering) trains on the whole dataset +// and has no held-out rows to predict on, so it never offers a predictions tab. +function getSessionSplitType(session) { + if (!session?.splits) return null; + if (typeof session.splits !== "string") { + return session.splits.splitType || null; + } + + try { + const splits = JSON.parse(session.splits || "{}"); + return splits.splitType || null; + } catch { + return null; + } +} + /** * Shows a run's results as two tab groups (metrics: live/hyperparameters, * operations: explainability/predictions). The data layer lives in @@ -60,6 +76,8 @@ export default function RunResults({ handlePredictionDeleted, } = useRunResultsData({ run, session, onRefresh, explainerRefreshTrigger }); + const supportsPredictions = getSessionSplitType(session) !== "none"; + const [internalVisible, setInternalVisible] = useState(() => { if (run.status === 0) return false; const saved = localStorage.getItem(`run-${run.id}-results-visible`); @@ -117,6 +135,10 @@ export default function RunResults({ const modelsContext = useModels(); const setRunDetailTab = modelsContext?.setRunDetailTab; const isDetailView = String(params.runId ?? "") === String(run.id); + useEffect(() => { + if (activeTab === 2 && !supportsPredictions) setActiveTab(0); + }, [activeTab, supportsPredictions]); + useEffect(() => { if (!isDetailView || !setRunDetailTab) return; setRunDetailTab(activeTab); @@ -131,6 +153,7 @@ export default function RunResults({ optimizables={optimizables} explainerCount={globalExplainers.length + localExplainers.length} predictionCount={predictions.length} + supportsPredictions={supportsPredictions} run={run} /> ); @@ -162,7 +185,7 @@ export default function RunResults({ /> )} - {activeTab === 2 && isFinished && ( + {activeTab === 2 && isFinished && supportsPredictions && ( { + if (!session?.splits) return {}; + if (typeof session.splits === "object") return session.splits; + + try { + return JSON.parse(session.splits); + } catch { + return {}; + } + }, [session?.splits]); + + const usesFullMetrics = sessionSplits.splitType === "none"; + const activeRun = React.useMemo( () => params.runId ? runs.find((r) => String(r.id) === params.runId) : null, @@ -167,6 +180,14 @@ export default function SessionVisualization() { [datasets, session?.dataset_id], ); + useEffect(() => { + if (usesFullMetrics) { + setMetricSplit("full"); + } else if (metricSplit === "full") { + setMetricSplit("test"); + } + }, [usesFullMetrics, metricSplit, session?.id]); + // Check which metrics are available. This re-scan only needs to happen // when `runs` itself changes, not on every render (e.g. drag state, tour // steps, or the highlight timer toggling elsewhere in this component). @@ -579,33 +600,37 @@ export default function SessionVisualization() { flexWrap: "wrap", }} > - {/* Metric Split Selector: controls both table and graph views */} - {(hasTrainMetrics || - hasValidationMetrics || - hasTestMetrics) && ( - { - if (newValue !== null) setMetricSplit(newValue); - }} - > - {hasTrainMetrics && ( - - {t("common:train")} - - )} - {hasValidationMetrics && ( - - {t("common:validation")} - - )} - {hasTestMetrics && ( - - {t("common:test")} - - )} - - )} + {/* Metric Split Selector: controls both table and graph views. + Hidden when the session only ever has one metric set + (clustering's full-dataset runs): there is nothing to + switch between. */} + {!usesFullMetrics && + (hasTrainMetrics || + hasValidationMetrics || + hasTestMetrics) && ( + { + if (newValue !== null) setMetricSplit(newValue); + }} + > + {hasTrainMetrics && ( + + {t("common:train")} + + )} + {hasValidationMetrics && ( + + {t("common:validation")} + + )} + {hasTestMetrics && ( + + {t("common:test")} + + )} + + )} diff --git a/DashAI/front/src/components/models/modelSession/DivideDatasetColumns.jsx b/DashAI/front/src/components/models/modelSession/DivideDatasetColumns.jsx index 20351a932..502154f5a 100644 --- a/DashAI/front/src/components/models/modelSession/DivideDatasetColumns.jsx +++ b/DashAI/front/src/components/models/modelSession/DivideDatasetColumns.jsx @@ -23,6 +23,7 @@ function DivideDatasetColumns({ inputHelperText = "", outputError = false, outputHelperText = "", + requiresTarget = true, disabled = false, }) { const { t } = useTranslation(["experiments", "common"]); @@ -156,35 +157,37 @@ function DivideDatasetColumns({ disabled={disabled || allColumnNames.length === 0} /> - option} - renderOption={renderColumnOption} - renderTags={renderTags} - filterSelectedOptions - fullWidth - renderInput={(params) => ( - 0 - ? t("common:selectColumns") - : t("common:loadingColumns") - } - /> - )} - sx={{ mb: 8 }} - disabled={disabled || allColumnNames.length === 0} - /> + {requiresTarget && ( + option} + renderOption={renderColumnOption} + renderTags={renderTags} + filterSelectedOptions + fullWidth + renderInput={(params) => ( + 0 + ? t("common:selectColumns") + : t("common:loadingColumns") + } + /> + )} + sx={{ mb: 8 }} + disabled={disabled || allColumnNames.length === 0} + /> + )} ); } @@ -200,6 +203,7 @@ DivideDatasetColumns.propTypes = { inputHelperText: PropTypes.string, outputError: PropTypes.bool, outputHelperText: PropTypes.string, + requiresTarget: PropTypes.bool, disabled: PropTypes.bool, }; diff --git a/DashAI/front/src/components/models/modelSession/PrepareDatasetStep.jsx b/DashAI/front/src/components/models/modelSession/PrepareDatasetStep.jsx index 7afe6007e..381c84829 100644 --- a/DashAI/front/src/components/models/modelSession/PrepareDatasetStep.jsx +++ b/DashAI/front/src/components/models/modelSession/PrepareDatasetStep.jsx @@ -65,8 +65,6 @@ function PrepareDatasetStep({ newExp.output_columns, ); - const columnsReady = - inputColumnNames.length >= 1 && outputColumnNames.length >= 1; const [columnsAreValid, setColumnsAreValid] = useState(false); // True until the current column selection has actually been checked against // the backend at least once — distinct from columnsAreValid=false, so the @@ -103,6 +101,16 @@ function PrepareDatasetStep({ const [splitType, setSplitType] = useState(""); const [splitsReady, setSplitsReady] = useState(false); + const requiresTarget = taskRequirements?.metadata?.requires_target !== false; + // Derived rather than held in state, so it never lags a render behind the + // selection. A task with no target has no output column to wait for, which + // is what keeps the Next button reachable for clustering. + const columnsReady = + inputColumnNames.length >= 1 && + (!requiresTarget || outputColumnNames.length >= 1); + const splitStrategy = + taskRequirements?.metadata?.session_config_schema?.split_strategy; + const usesSplits = splitStrategy !== "none"; const getDatasetInfo = async () => { if (!dataset?.id) return; @@ -132,6 +140,7 @@ function PrepareDatasetStep({ ) { const allNames = fetchedDatasetInfo.column_names; if ( + requiresTarget && inputColumnNames.length === 0 && (!newExp.input_columns || newExp.input_columns.length === 0) ) { @@ -143,6 +152,7 @@ function PrepareDatasetStep({ } if ( + requiresTarget && outputColumnNames.length === 0 && (!newExp.output_columns || newExp.output_columns.length === 0) ) { @@ -189,6 +199,8 @@ function PrepareDatasetStep({ inputs_cardinality: "", outputs_types: [], outputs_cardinality: "", + requires_target: true, + session_config_schema: {}, }, }); } @@ -215,7 +227,10 @@ function PrepareDatasetStep({ return; } - if (inputColumnNames.length === 0 || outputColumnNames.length === 0) { + if ( + inputColumnNames.length === 0 || + (requiresTarget && outputColumnNames.length === 0) + ) { setColumnsAreValid(false); return; } @@ -224,7 +239,7 @@ function PrepareDatasetStep({ newExp.task_name, dataset.id, inputColumnNames, - outputColumnNames, + requiresTarget ? outputColumnNames : [], ); setColumnsAreValid(validation.dataset_status === "valid"); } catch (error) { @@ -254,34 +269,70 @@ function PrepareDatasetStep({ const updatedExpData = { ...newExp, input_columns: inputColumnNames, - output_columns: outputColumnNames, - evaluation_strategy: evaluationStrategy, + output_columns: requiresTarget ? outputColumnNames : [], + // A task without splits never renders SplitDatasetRows, which is the only + // place that sets a strategy, so the parent's state stays null. The + // backend types this field as a plain str, so send the empty string it + // was initialised with instead of null. + evaluation_strategy: evaluationStrategy ?? "", }; - const splitterName = resolveSplitterName(strategyKind, cvType, holdoutType); - if (splitterName) { - updatedExpData.splits = buildSplitsPayload({ - splitterName, - splitType: - strategyKind === STRATEGY_KINDS.HOLDOUT ? splitType : SPLIT_TYPES.CV, - params: { - ...(splitterParams ?? {}), - // The group column select is rendered by hand, so its value is not - // part of the generated form's values. - ...(cvType?.schema?.properties?.group_column - ? { group_column: groupColumn } - : {}), - }, - indexes: - splitType === SPLIT_TYPES.PREDEFINED - ? datasetPartitionsIndex - : rowsPartitionsIndex, - }); + // A task that trains on the whole dataset (clustering) has no splitter to + // resolve; the backend recognises the session by this splitType. + if (!usesSplits) { + updatedExpData.splits = { splitType: "none" }; + } else { + const splitterName = resolveSplitterName( + strategyKind, + cvType, + holdoutType, + ); + if (splitterName) { + updatedExpData.splits = buildSplitsPayload({ + splitterName, + splitType: + strategyKind === STRATEGY_KINDS.HOLDOUT + ? splitType + : SPLIT_TYPES.CV, + params: { + ...(splitterParams ?? {}), + // The group column select is rendered by hand, so its value is not + // part of the generated form's values. + ...(cvType?.schema?.properties?.group_column + ? { group_column: groupColumn } + : {}), + }, + indexes: + splitType === SPLIT_TYPES.PREDEFINED + ? datasetPartitionsIndex + : rowsPartitionsIndex, + }); + } } setNewExp(updatedExpData); }; + // For tasks without a target column (e.g. clustering), input columns always default + // to the full dataset and output columns are always empty. This is a separate effect + // (rather than inline in getDatasetInfo) so it corrects itself regardless of whether + // dataset info or task requirements resolves first. + useEffect(() => { + if (!requiresTarget) { + setOutputColumnNames([]); + if (datasetInfo?.column_names?.length > 0) { + setInputColumnNames(datasetInfo.column_names); + } + } + }, [requiresTarget, datasetInfo]); + + useEffect(() => { + if (!usesSplits) { + setSplitsReady(true); + setSplitType("none"); + } + }, [usesSplits]); + // Column validity depends on the columns, the dataset and the task, never on // the split configuration. Gating it on the splits being ready made every // split change re-check the columns over HTTP and blank the requirements @@ -314,10 +365,16 @@ function PrepareDatasetStep({ setValidationPending(true); validateColumns(); } - }, [columnsReady, inputColumnNames, outputColumnNames, datasetInfo]); + }, [ + columnsReady, + inputColumnNames, + outputColumnNames, + datasetInfo, + requiresTarget, + ]); useEffect(() => { - if (columnsAreValid && splitsReady && columnsReady) { + if (columnsAreValid && (splitsReady || !usesSplits) && columnsReady) { updateExperiment(); setNextEnabled(true); } else { @@ -331,6 +388,8 @@ function PrepareDatasetStep({ splitterParams, inputColumnNames, outputColumnNames, + requiresTarget, + usesSplits, cvType, holdoutType, strategyKind, @@ -365,6 +424,14 @@ function PrepareDatasetStep({ ); return () => setSessionRightContent(null); } + if (!usesSplits) { + setSessionRightContent( + + {t("experiments:label.noSplitConfigNeeded")} + , + ); + return () => setSessionRightContent(null); + } setSessionRightContent( - - - - The output columns must be of the types - {renderTypesAsChips(taskRequirements.metadata.outputs_types)} - - , and they should have a cardinality of - {{ - cardinality: - taskRequirements.metadata.outputs_cardinality, - }} - . - - - - + {requiresTarget && ( + + + + The output columns must be of the types + {renderTypesAsChips( + taskRequirements.metadata.outputs_types, + )} + + , and they should have a cardinality of + {{ + cardinality: + taskRequirements.metadata.outputs_cardinality, + }} + . + + + + + )} )} @@ -557,13 +629,16 @@ function PrepareDatasetStep({ onInputColumnNamesChange={setInputColumnNames} selectedOutputColumnNames={outputColumnNames} onOutputColumnNamesChange={setOutputColumnNames} + requiresTarget={requiresTarget} inputError={inputColumnNames.length === 0} inputHelperText={ inputColumnNames.length === 0 ? t("common:required") : "" } - outputError={outputColumnNames.length === 0} + outputError={requiresTarget && outputColumnNames.length === 0} outputHelperText={ - outputColumnNames.length === 0 ? t("common:required") : "" + requiresTarget && outputColumnNames.length === 0 + ? t("common:required") + : "" } disabled={ infoLoading || (datasetInfo.column_names || []).length === 0 diff --git a/DashAI/front/src/components/models/runResults/ResultsTabsHeader.jsx b/DashAI/front/src/components/models/runResults/ResultsTabsHeader.jsx index 55a36a156..6210dc2d7 100644 --- a/DashAI/front/src/components/models/runResults/ResultsTabsHeader.jsx +++ b/DashAI/front/src/components/models/runResults/ResultsTabsHeader.jsx @@ -43,6 +43,7 @@ export default function ResultsTabsHeader({ optimizables, explainerCount, predictionCount, + supportsPredictions = true, run, }) { const { t } = useTranslation(["models"]); @@ -193,24 +194,26 @@ export default function ResultsTabsHeader({ } disabled={!isFinished || !hasDataToExplain} /> - - - {t("models:label.predictions")} - {isFinished && ( - - )} - - - } - disabled={!isFinished} - /> + {supportsPredictions && ( + + + {t("models:label.predictions")} + {isFinished && ( + + )} + + + } + disabled={!isFinished} + /> + )} @@ -224,4 +227,5 @@ ResultsTabsHeader.propTypes = { optimizables: PropTypes.number, explainerCount: PropTypes.number, predictionCount: PropTypes.number, + supportsPredictions: PropTypes.bool, }; diff --git a/DashAI/front/src/components/notebooks/ColumnSelector.jsx b/DashAI/front/src/components/notebooks/ColumnSelector.jsx index a00585eae..d53d48918 100644 --- a/DashAI/front/src/components/notebooks/ColumnSelector.jsx +++ b/DashAI/front/src/components/notebooks/ColumnSelector.jsx @@ -42,6 +42,7 @@ function ColumnSelector({ allowedDtypes = [], allowedTypes = [], nonAllowedDtypes = [], + allowedColumnNames = null, excludedColumnIds = [], onSelectionChange = () => {}, onValidationChange = () => {}, @@ -133,11 +134,11 @@ function ColumnSelector({ const isValidSelection = useCallback( (selection) => { if (selection.length === 0) { - return false; + return inputCardinality.exact === 0; } if ( - inputCardinality.exact && + inputCardinality.exact != null && selection.length !== inputCardinality.exact ) { return false; @@ -156,8 +157,15 @@ function ColumnSelector({ [inputCardinality], ); const getValidColumnIds = useCallback(() => { + if (inputCardinality.exact === 0) return []; return rows .filter((row) => { + if ( + allowedColumnNames !== null && + !allowedColumnNames.has(row.columnName) + ) { + return false; + } if (excludedColumnIds.includes(row.id)) { return false; } @@ -175,7 +183,14 @@ function ColumnSelector({ return true; }) .map((row) => row.id); - }, [rows, allowedDtypes, allowedTypes, nonAllowedDtypes, excludedColumnIds]); + }, [ + rows, + allowedDtypes, + allowedTypes, + nonAllowedDtypes, + allowedColumnNames, + excludedColumnIds, + ]); // Deselect any already-selected column that becomes excluded (e.g. it was // picked as scope and then also set as the target column) @@ -376,11 +391,14 @@ function ColumnSelector({ exact: inputCardinality.exact, min: inputCardinality.min || 0, max: inputCardinality.max, - context: inputCardinality.exact - ? "exact" - : inputCardinality.max - ? "range" - : "min", + context: + inputCardinality.exact === 0 + ? "none" + : inputCardinality.exact + ? "exact" + : inputCardinality.max + ? "range" + : "min", })} )} diff --git a/DashAI/front/src/components/notebooks/RightBar.jsx b/DashAI/front/src/components/notebooks/RightBar.jsx index 3a9641ed5..b1035f4ed 100644 --- a/DashAI/front/src/components/notebooks/RightBar.jsx +++ b/DashAI/front/src/components/notebooks/RightBar.jsx @@ -177,6 +177,31 @@ export default function RightBar({ notebook, onToggle }) { }); } + // Restrict selectable columns to those used by the last converter (if required) + if ( + explorer?.metadata?.restricts_to_converter_columns && + explorer?.metadata?.requires_converter_class + ) { + const latestConverter = [...explorersAndConverters] + .filter( + (item) => + item.type === "converter" && + item.status === 3 && + item.converter === explorer.metadata.requires_converter_class, + ) + .sort((a, b) => (b.id || 0) - (a.id || 0))[0]; + if (latestConverter) { + const converterColNames = new Set( + (latestConverter?.parameters?.scope?.columns || []).map( + (c) => c.columnName, + ), + ); + validColumns = validColumns.filter((col) => + converterColNames.has(col.columnName), + ); + } + } + // Check cardinality requirements if (inputCardinality.exact != null) { if (validColumns.length < inputCardinality.exact) { @@ -215,6 +240,41 @@ export default function RightBar({ notebook, onToggle }) { })}`; } + // Check if a required converter class has been run and finished + const requiresConverterClass = explorer?.metadata?.requires_converter_class; + if (requiresConverterClass) { + const finishedConverters = explorersAndConverters.filter( + (item) => + item.type === "converter" && + item.status === 3 && + item.converter === requiresConverterClass, + ); + if (finishedConverters.length === 0) { + disabled = true; + tooltip += `\n\n${t("datasets:error.requiresConverter", { + converterClass: requiresConverterClass, + })}`; + } else { + // Check if a specific algorithm is required (e.g. hdbscan, agglomerative) + const requiresAlgorithm = + explorer?.metadata?.requires_algorithm?.toLowerCase(); + if (requiresAlgorithm) { + const latest = [...finishedConverters].sort( + (a, b) => (b.id || 0) - (a.id || 0), + )[0]; + const usedAlgorithm = ( + latest?.parameters?.params?.algorithm?.toLowerCase() ?? "" + ).replace(/clustering$/, ""); + if (usedAlgorithm !== requiresAlgorithm) { + disabled = true; + tooltip += `\n\n${t("datasets:error.requiresAlgorithm", { + algorithm: requiresAlgorithm, + })}`; + } + } + } + } + return { disabled, tooltip, validColumns }; }; @@ -292,7 +352,7 @@ export default function RightBar({ notebook, onToggle }) { notebook, }; }), - [explorers, datasetColumns, notebook?.id], + [explorers, datasetColumns, notebook?.id, explorersAndConverters], ); const validatedConverters = useMemo( @@ -314,15 +374,17 @@ export default function RightBar({ notebook, onToggle }) { const query = searchQuery.trim().toLowerCase(); const tokens = query.split(/\s+/).filter(Boolean); + const asString = (value) => (typeof value === "string" ? value : ""); + const rankMatch = (item) => { const displayName = ( - item.metadata?.display_name || - item.name || + asString(item.metadata?.display_name) || + asString(item.name) || "" ).toLowerCase(); const description = ( - item.metadata?.short_description || - item.description || + asString(item.metadata?.short_description) || + asString(item.description) || "" ).toLowerCase(); if (tokens.every((token) => displayName.includes(token))) return 1; diff --git a/DashAI/front/src/components/notebooks/explorer/visualizations/ClusteringProfileVisualizer.jsx b/DashAI/front/src/components/notebooks/explorer/visualizations/ClusteringProfileVisualizer.jsx new file mode 100644 index 000000000..7e0b130c6 --- /dev/null +++ b/DashAI/front/src/components/notebooks/explorer/visualizations/ClusteringProfileVisualizer.jsx @@ -0,0 +1,319 @@ +import PropTypes from "prop-types"; +import { + Box, + Chip, + LinearProgress, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; + +function formatNumber(value, digits = 3) { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return "-"; + } + + const number = Number(value); + if (Math.abs(number) >= 100) return number.toFixed(1); + if (Math.abs(number) >= 10) return number.toFixed(2); + return number.toFixed(digits); +} + +function Section({ title, children }) { + return ( + + + {title} + + {children} + + ); +} + +function SummaryCard({ label, value, helper }) { + const theme = useTheme(); + + return ( + + + {label} + + + {value} + + {helper && ( + + {helper} + + )} + + ); +} + +function ClusteringProfileVisualizer({ data, minimalist = false }) { + const theme = useTheme(); + const clusterSizes = Object.entries(data.cluster_sizes || {}).sort( + ([a], [b]) => Number(a) - Number(b), + ); + const totalRows = clusterSizes.reduce( + (acc, [, size]) => acc + Number(size), + 0, + ); + const metrics = Object.entries(data.metrics || {}); + const profiles = [...(data.cluster_profiles || [])].sort( + (a, b) => Number(a.cluster) - Number(b.cluster), + ); + + return ( + + + + + + {data.noise_info?.n_noise_points !== undefined && ( + + )} + + + {metrics.length > 0 && ( +
+ + + + + Métrica + Valor + Interpretación + + + + {metrics.map(([key, metric]) => ( + + + {metric.label || key} + + + {formatNumber(metric.value)} + + + + {metric.interpretation || "-"} + + + + ))} + +
+
+
+ )} + +
+ + + + + Cluster + Filas + Porcentaje + Distribución + + + + {clusterSizes.map(([cluster, size]) => { + const percent = totalRows + ? (Number(size) / totalRows) * 100 + : 0; + return ( + + + Cluster {cluster} + + {size} + + {formatNumber(percent, 1)}% + + + + + + ); + })} + +
+
+
+ +
+ + Se muestran las variables cuyo promedio dentro del cluster se aleja + más del promedio global. Ayudan a entender que hace reconocible a cada + grupo. + + + + + + Cluster + Tamaño + Variables más descriptivas + + + + {profiles.map((profile) => ( + + + Cluster {profile.cluster} + + {profile.size} + + + {(profile.distinctive_features || []).map((feature) => ( + + + + {feature.direction === "above average" + ? "más alto" + : "más bajo"}{" "} + que el promedio + + + promedio cluster{" "} + {formatNumber(feature.cluster_mean)} — global{" "} + {formatNumber(feature.global_mean)} + + + ))} + + + + ))} + +
+
+
+ + {Object.keys(data.algorithm_extras || {}).length > 0 && ( +
+ + + + {Object.entries(data.algorithm_extras).map(([key, value]) => ( + + {key} + {formatNumber(value)} + + + {key === "inertia" + ? "Menor indica clusters más compactos para la misma cantidad de clusters." + : "Detalle especifico del algoritmo utilizado."} + + + + ))} + +
+
+
+ )} +
+ ); +} + +Section.propTypes = { + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +SummaryCard.propTypes = { + label: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + helper: PropTypes.string, +}; + +ClusteringProfileVisualizer.propTypes = { + data: PropTypes.object.isRequired, + minimalist: PropTypes.bool, +}; + +export default ClusteringProfileVisualizer; diff --git a/DashAI/front/src/components/notebooks/explorerCreation/ScopeStepExplorer.jsx b/DashAI/front/src/components/notebooks/explorerCreation/ScopeStepExplorer.jsx index e5eae85e9..534827306 100644 --- a/DashAI/front/src/components/notebooks/explorerCreation/ScopeStepExplorer.jsx +++ b/DashAI/front/src/components/notebooks/explorerCreation/ScopeStepExplorer.jsx @@ -1,10 +1,11 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Box, Typography } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import FormSchemaButtonGroup from "../../shared/FormSchemaButtonGroup"; import ColumnSelector from "../ColumnSelector"; import { useTourContext } from "../../tour/TourProvider"; import { useTranslation } from "react-i18next"; +import { useExplorersAndConverters } from "../context/ExplorersAndConvertersContext"; export default function ScopeStepExplorer({ notebook, @@ -21,6 +22,25 @@ export default function ScopeStepExplorer({ const nonAllowedDtypes = tool?.metadata?.non_allowed_dtypes || []; const tourContext = useTourContext(); const { t } = useTranslation(["datasets", "common"]); + const { explorersAndConverters } = useExplorersAndConverters(); + + const allowedColumnNames = useMemo(() => { + if (!tool?.metadata?.restricts_to_converter_columns) return null; + const requiresClass = tool?.metadata?.requires_converter_class; + if (!requiresClass) return null; + const latest = [...explorersAndConverters] + .filter( + (item) => + item.type === "converter" && + item.status === 3 && + item.converter === requiresClass, + ) + .sort((a, b) => (b.id || 0) - (a.id || 0))[0]; + if (!latest) return null; + return new Set( + (latest?.parameters?.scope?.columns || []).map((c) => c.columnName), + ); + }, [explorersAndConverters, tool?.metadata]); const handleSubmit = () => { nextStep(); @@ -53,6 +73,7 @@ export default function ScopeStepExplorer({ allowedTypes={allowedTypes} allowedDtypes={allowedDtypes} nonAllowedDtypes={nonAllowedDtypes} + allowedColumnNames={allowedColumnNames} onSelectionChange={(selected) => setScopeColumns(selected)} onValidationChange={(isValid) => setIsSelectionValid(isValid)} /> diff --git a/DashAI/front/src/components/shared/FormSchemaField.jsx b/DashAI/front/src/components/shared/FormSchemaField.jsx index 5613047f0..8f7e04402 100644 --- a/DashAI/front/src/components/shared/FormSchemaField.jsx +++ b/DashAI/front/src/components/shared/FormSchemaField.jsx @@ -47,6 +47,7 @@ function FormSchemaField({ objName, paramJsonSchema, field, error }) { {...commonProps} options={paramJsonSchema.enum} optionNames={paramJsonSchema.enumNames} + optionDescriptions={paramJsonSchema.optionDescriptions} /> ); } else { diff --git a/DashAI/front/src/components/shared/FormSchemaFieldWithCollapse.jsx b/DashAI/front/src/components/shared/FormSchemaFieldWithCollapse.jsx index 512aa563e..7915398b2 100644 --- a/DashAI/front/src/components/shared/FormSchemaFieldWithCollapse.jsx +++ b/DashAI/front/src/components/shared/FormSchemaFieldWithCollapse.jsx @@ -19,8 +19,9 @@ function FormSchemaFieldWithCollapse({ description, errorMessage, children, + defaultExpanded = false, }) { - const [showSection, setShowSection] = React.useState(false); + const [showSection, setShowSection] = React.useState(defaultExpanded); const { t } = useTranslation(["common"]); const toggleButton = ( @@ -56,6 +57,7 @@ FormSchemaFieldWithCollapse.propTypes = { description: PropTypes.string, errorMessage: PropTypes.string, children: PropTypes.node, + defaultExpanded: PropTypes.bool, }; export default FormSchemaFieldWithCollapse; diff --git a/DashAI/front/src/components/shared/FormSchemaRenderFields.jsx b/DashAI/front/src/components/shared/FormSchemaRenderFields.jsx index 61d033b2e..4049df777 100644 --- a/DashAI/front/src/components/shared/FormSchemaRenderFields.jsx +++ b/DashAI/front/src/components/shared/FormSchemaRenderFields.jsx @@ -6,9 +6,40 @@ import FormSchemaFieldWithCollapse from "./FormSchemaFieldWithCollapse"; import FormSchemaFieldWithOptimizers from "./FormSchemaFieldWithOptimizers"; import FormSchemaFieldWithParent from "./FormSchemaFieldWithParent"; import { getModelFromSubform } from "../../utils/schema"; -import { Stack } from "@mui/material"; +import { FormCardProvider } from "../../contexts/FormCardContext"; +import { Box, Stack, Typography } from "@mui/material"; import PropTypes from "prop-types"; +const getInitialValueFromSchema = (schema) => { + if (schema.type !== "object") { + return schema.placeholder; + } + + return Object.keys(schema.properties ?? {}).reduce((acc, key) => { + acc[key] = getInitialValueFromSchema(schema.properties[key]); + return acc; + }, {}); +}; + +const resolveConditionalSchema = (schema, values) => { + if (!schema?.dependsOn || !schema?.conditionalSchemas) { + return schema; + } + + const selectedValue = values?.[schema.dependsOn]; + const conditionalSchema = schema.conditionalSchemas[selectedValue]; + + if (!conditionalSchema) { + return schema; + } + + return { + ...schema, + ...conditionalSchema, + properties: conditionalSchema.properties ?? schema.properties, + }; +}; + // Extracted to its own component so useMemo is called at the top level (Rules of Hooks) function SubFieldItem({ objName, @@ -29,12 +60,57 @@ function SubFieldItem({ ); return ( - + span": { + display: "none", + }, + }} + > + + + + + {fieldSubschema.description && ( + + {fieldSubschema.description} + + )} + + ); +} + +function SubFieldHeader({ label, paramKey }) { + return ( + + + {label} + + {paramKey && paramKey !== label && ( + + {paramKey} + + )} + ); } @@ -48,6 +124,11 @@ SubFieldItem.propTypes = { fieldSubschema: PropTypes.object.isRequired, }; +SubFieldHeader.propTypes = { + label: PropTypes.string, + paramKey: PropTypes.string, +}; + function FormSchemaRenderFields({ modelSchema, formik, @@ -64,16 +145,51 @@ function FormSchemaRenderFields({ const handleChange = useCallback( (name, subName) => (value) => { const fieldPath = subName ? `${name}.${subName}` : name; + const dependentValues = {}; + formik.setFieldValue(fieldPath, value, true); // Always pass complete formik.values so handleUpdateSchema receives // ALL fields regardless of whether the context store has been // initialised yet (prevents race-condition with useEffect init). + let updatedValues = { ...formik.values, [fieldPath]: value }; + + if (subName) { + // A sub-field also has to land nested, not only under its dotted path. + updatedValues = { + ...updatedValues, + [name]: { + ...(formik.values?.[name] ?? {}), + [subName]: value, + }, + }; + } else { + Object.entries(modelSchema ?? {}).forEach( + ([schemaKey, schemaValue]) => { + if ( + schemaValue?.dependsOn === name && + schemaValue?.conditionalSchemas?.[value] + ) { + const dependentSchema = { + ...schemaValue, + ...schemaValue.conditionalSchemas[value], + properties: + schemaValue.conditionalSchemas[value].properties ?? + schemaValue.properties, + }; + const initialValue = getInitialValueFromSchema(dependentSchema); + dependentValues[schemaKey] = initialValue; + formik.setFieldValue(schemaKey, initialValue, true); + } + }, + ); + } + handleUpdateSchema( - { ...formik.values, [fieldPath]: value }, + { ...updatedValues, ...dependentValues }, autoSave ? onFormSubmit : null, ); }, - [formik, handleUpdateSchema, autoSave, onFormSubmit], + [formik, handleUpdateSchema, modelSchema, autoSave, onFormSubmit], ); const renderFields = useCallback(() => { @@ -84,7 +200,10 @@ function FormSchemaRenderFields({ // context the schema cannot carry, such as a dataset's column names. if (excludeFields.includes(key)) continue; - const fieldSchema = modelSchema[key]; + const fieldSchema = resolveConditionalSchema( + modelSchema[key], + formik?.values, + ); const objName = key; const value = formik?.values?.[objName]; const error = formik?.errors?.[objName]; @@ -142,6 +261,7 @@ function FormSchemaRenderFields({ label={fieldSchema.title} description={fieldSchema.description} errorMessage={errorsMessage?.[objName]?.message} + defaultExpanded={Boolean(fieldSchema?.dependsOn)} > {fieldSchema?.properties && Object.keys(fieldSchema.properties).map((subField) => ( diff --git a/DashAI/front/src/components/threeSectionLayout/OptionBox.jsx b/DashAI/front/src/components/threeSectionLayout/OptionBox.jsx index 9e3502ecb..eccbf9cb1 100644 --- a/DashAI/front/src/components/threeSectionLayout/OptionBox.jsx +++ b/DashAI/front/src/components/threeSectionLayout/OptionBox.jsx @@ -13,6 +13,7 @@ const OptionBox = forwardRef(function OptionBox( onClick, Icon = null, chips = [], + badge = null, dataTour, minHeight, onMeasure, @@ -105,24 +106,50 @@ const OptionBox = forwardRef(function OptionBox( {...otherProps} > - {/* Header: icon */} - {Icon && ( - - - - + {/* Header: icon + badge */} + {(Icon || badge) && ( + + {Icon && ( + + + + )} + {badge && ( + + {badge} + + )} )} diff --git a/DashAI/front/src/hooks/models/useSessions.js b/DashAI/front/src/hooks/models/useSessions.js index 65529c201..cbe10693f 100644 --- a/DashAI/front/src/hooks/models/useSessions.js +++ b/DashAI/front/src/hooks/models/useSessions.js @@ -235,6 +235,7 @@ export function useSessions({ t }) { run.test_metrics || run.train_metrics || run.validation_metrics || + run.full_metrics || run.status === 3; // Finished if (hasBeenTrained) { diff --git a/DashAI/front/src/pages/results/components/ResultsGraphs.jsx b/DashAI/front/src/pages/results/components/ResultsGraphs.jsx index 2b8c26cd8..c9545bee4 100644 --- a/DashAI/front/src/pages/results/components/ResultsGraphs.jsx +++ b/DashAI/front/src/pages/results/components/ResultsGraphs.jsx @@ -69,7 +69,12 @@ function ResultsGraphs({ ); const availableMetrics = useMemo(() => { - const sets = { train: new Set(), validation: new Set(), test: new Set() }; + const sets = { + train: new Set(), + validation: new Set(), + test: new Set(), + full: new Set(), + }; finishedRuns.forEach((run) => { if (run.train_metrics) Object.keys(run.train_metrics).forEach((m) => sets.train.add(m)); @@ -79,11 +84,14 @@ function ResultsGraphs({ ); if (run.test_metrics) Object.keys(run.test_metrics).forEach((m) => sets.test.add(m)); + if (run.full_metrics) + Object.keys(run.full_metrics).forEach((m) => sets.full.add(m)); }); return { train: Array.from(sets.train), validation: Array.from(sets.validation), test: Array.from(sets.test), + full: Array.from(sets.full), }; }, [finishedRuns]); @@ -96,6 +104,9 @@ function ResultsGraphs({ else if (availableMetrics.validation.length > 0) setInternalSplit("validation"); else if (availableMetrics.test.length > 0) setInternalSplit("test"); + // A clustering run has no train/validation/test metrics at all, so it + // falls through to the split it does have. + else if (availableMetrics.full.length > 0) setInternalSplit("full"); }, [availableMetrics, splitProp]); useEffect(() => { diff --git a/DashAI/front/src/types/run.ts b/DashAI/front/src/types/run.ts index f78bf3223..1eb3f8041 100644 --- a/DashAI/front/src/types/run.ts +++ b/DashAI/front/src/types/run.ts @@ -17,6 +17,7 @@ export interface IRun { train_metrics: object; test_metrics: object; validation_metrics: object; + full_metrics: object; artifacts: object; run_name: string; run_description: string; diff --git a/DashAI/front/src/utils/i18n/locales/de/common.json b/DashAI/front/src/utils/i18n/locales/de/common.json index b3ca550d1..296649011 100644 --- a/DashAI/front/src/utils/i18n/locales/de/common.json +++ b/DashAI/front/src/utils/i18n/locales/de/common.json @@ -82,6 +82,7 @@ "finish": "Fertigstellen", "finished": "Abgeschlossen", "from": "Von", + "full": "Vollständig", "generative": "Generativ", "graphs": "Graphen", "hardwareMonitor": { diff --git a/DashAI/front/src/utils/i18n/locales/de/datasets.json b/DashAI/front/src/utils/i18n/locales/de/datasets.json index 7e422e5be..f2f4a295d 100644 --- a/DashAI/front/src/utils/i18n/locales/de/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/de/datasets.json @@ -92,6 +92,8 @@ "noDatasetFileAvailable": "Keine Datensatzdatei verfügbar", "notebookNameEmpty": "Notizbuchname darf nicht leer sein", "noValidColumnsForExplorer": "Keine gültigen Spalten für diesen Explorer verfügbar.", + "requiresConverter": "Erfordert einen abgeschlossenen '{{converterClass}}'-Konverter. Führen Sie zuerst diesen Konverter auf Ihrem Dataset aus.", + "requiresAlgorithm": "Dieser Explorer erfordert den Algorithmus '{{algorithm}}', aber der letzte Konverter verwendete einen anderen.", "noValidColumnsWithDtypesMentioned": "Dieser Datensatz hat keine Spalten mit den erforderlichen Typen ({{dtypes}}).", "processConverterError": "Konverter konnte nicht verarbeitet werden", "requiredFieldsMissing": "Pflichtfelder fehlen", @@ -326,6 +328,7 @@ "requiredColumns": "Erforderliche Spalten", "requiredColumns_exact": "Erforderliche Spalten: genau {{exact}}", "requiredColumns_min": "Erforderliche Spalten: mindestens {{min}}", + "requiredColumns_none": "Keine Spalten erforderlich", "requiredColumns_range": "Erforderliche Spalten: zwischen {{min}} und {{max}}", "restrictedDataTypes": "Eingeschränkte Datentypen: <1><0>", "rightSkewedWarning": "<0>Rechtsschiefe Verteilung: Erwägen Sie eine Logarithmustransformation.", diff --git a/DashAI/front/src/utils/i18n/locales/de/experiments.json b/DashAI/front/src/utils/i18n/locales/de/experiments.json index be70c1492..20cba6a4b 100644 --- a/DashAI/front/src/utils/i18n/locales/de/experiments.json +++ b/DashAI/front/src/utils/i18n/locales/de/experiments.json @@ -57,6 +57,7 @@ "noDatasetsAvailableGoToDataTab": "Gehen Sie zum <1>Daten-Tab, um zuerst einen hochzuladen.", "noModelsAvailable": "Keine Modelle verfügbar", "noOptimizersNoMetric": "Keine Hyperparameter-Optimierung", + "noSplitConfigNeeded": "Dieser Aufgabentyp trainiert mit dem gesamten Datensatz — es muss keine Aufteilung in Training/Validierung/Test konfiguriert werden.", "optimizer": "Optimierer", "optimizerMetric": "Optimierungsmetrik", "parameterModification": "Parametermodifikation", diff --git a/DashAI/front/src/utils/i18n/locales/de/models.json b/DashAI/front/src/utils/i18n/locales/de/models.json index 4538ee7f6..5cd592be2 100644 --- a/DashAI/front/src/utils/i18n/locales/de/models.json +++ b/DashAI/front/src/utils/i18n/locales/de/models.json @@ -278,7 +278,10 @@ "validation": "Validierung", "validationMetrics": "Validierungsmetriken", "validationSet": "Validierungsmenge", - "viewResultsAs": "Ergebnisse als Spalten oder Graphen anzeigen" + "viewResultsAs": "Ergebnisse als Spalten oder Graphen anzeigen", + "last": "Ergebnis", + "supervisedBadge": "Überwacht", + "unsupervisedBadge": "Unüberwacht" }, "message": { "allRunsCompleted": "{{experiment}} hat alle Durchläufe abgeschlossen.", diff --git a/DashAI/front/src/utils/i18n/locales/en/common.json b/DashAI/front/src/utils/i18n/locales/en/common.json index 3dcb81fcd..23219dab6 100644 --- a/DashAI/front/src/utils/i18n/locales/en/common.json +++ b/DashAI/front/src/utils/i18n/locales/en/common.json @@ -82,6 +82,7 @@ "finish": "Finish", "finished": "Finished", "from": "From", + "full": "Full", "generative": "Generative", "graphs": "Graphs", "hardwareMonitor": { diff --git a/DashAI/front/src/utils/i18n/locales/en/datasets.json b/DashAI/front/src/utils/i18n/locales/en/datasets.json index 6de5912f5..2ea7f901d 100644 --- a/DashAI/front/src/utils/i18n/locales/en/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/en/datasets.json @@ -92,6 +92,8 @@ "noDatasetFileAvailable": "No dataset file available", "notebookNameEmpty": "Notebook name cannot be empty", "noValidColumnsForExplorer": "No valid columns available for this explorer.", + "requiresConverter": "Requires a finished '{{converterClass}}' converter. Run this converter on your dataset first.", + "requiresAlgorithm": "This explorer requires the '{{algorithm}}' algorithm, but the last converter used a different one.", "noValidColumnsWithDtypesMentioned": "This dataset does not have any columns with the required types ({{dtypes}}).", "processConverterError": "Failed to process converter", "requiredFieldsMissing": "Required fields missing", @@ -328,6 +330,7 @@ "requiredColumns": "Required columns", "requiredColumns_exact": "Required columns: exactly {{exact}}", "requiredColumns_min": "Required columns: at least {{min}}", + "requiredColumns_none": "No columns required", "requiredColumns_range": "Required columns: between {{min}} and {{max}}", "restrictedDataTypes": "Restricted data types: <1><0>", "rightSkewedWarning": "<0>Right-skewed distribution: Consider applying a log transformation.", diff --git a/DashAI/front/src/utils/i18n/locales/en/experiments.json b/DashAI/front/src/utils/i18n/locales/en/experiments.json index 2322e6854..4eb0da98b 100644 --- a/DashAI/front/src/utils/i18n/locales/en/experiments.json +++ b/DashAI/front/src/utils/i18n/locales/en/experiments.json @@ -57,6 +57,7 @@ "noDatasetsAvailableGoToDataTab": "Go to <1>data tab to upload one first.", "noModelsAvailable": "No Models Available", "noOptimizersNoMetric": "No hyperparameter optimization", + "noSplitConfigNeeded": "This task type trains on the full dataset — no train/validation/test split needs to be configured.", "optimizer": "Optimizer", "optimizerMetric": "Optimization Metric", "parameterModification": "Parameter Modification", diff --git a/DashAI/front/src/utils/i18n/locales/en/models.json b/DashAI/front/src/utils/i18n/locales/en/models.json index db12745b8..f428e63fd 100644 --- a/DashAI/front/src/utils/i18n/locales/en/models.json +++ b/DashAI/front/src/utils/i18n/locales/en/models.json @@ -278,7 +278,10 @@ "validation": "Validation", "validationMetrics": "Validation Metrics", "validationSet": "validation set", - "viewResultsAs": "View results as columns or graphs" + "viewResultsAs": "View results as columns or graphs", + "last": "Final", + "supervisedBadge": "Supervised", + "unsupervisedBadge": "Unsupervised" }, "message": { "allRunsCompleted": "{{experiment}} has completed all its runs.", diff --git a/DashAI/front/src/utils/i18n/locales/es/common.json b/DashAI/front/src/utils/i18n/locales/es/common.json index 4fef5381b..51610347a 100644 --- a/DashAI/front/src/utils/i18n/locales/es/common.json +++ b/DashAI/front/src/utils/i18n/locales/es/common.json @@ -84,6 +84,7 @@ "finish": "Finalizar", "finished": "Finalizado", "from": "Desde", + "full": "Completo", "generative": "Generativo", "graphs": "Gráficos", "hardwareMonitor": { diff --git a/DashAI/front/src/utils/i18n/locales/es/datasets.json b/DashAI/front/src/utils/i18n/locales/es/datasets.json index 1c636ae09..c3387c2a5 100644 --- a/DashAI/front/src/utils/i18n/locales/es/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/es/datasets.json @@ -94,6 +94,8 @@ "noDatasetFileAvailable": "No hay archivo del dataset disponible", "notebookNameEmpty": "El nombre del cuaderno no puede estar vacío", "noValidColumnsForExplorer": "No hay columnas válidas disponibles para este explorador.", + "requiresConverter": "Requiere un convertidor '{{converterClass}}' completado. Ejecuta primero este convertidor en tu dataset.", + "requiresAlgorithm": "Este explorador requiere el algoritmo '{{algorithm}}', pero el último convertidor usó uno diferente.", "noValidColumnsWithDtypesMentioned": "Este dataset no tiene columnas con los tipos requeridos ({{dtypes}}).", "processConverterError": "Fallo al procesar convertidor", "requiredFieldsMissing": "Faltan campos requeridos", @@ -336,6 +338,7 @@ "requiredColumns": "Columnas requeridas", "requiredColumns_exact": "Columnas requeridas: exactamente {{exact}}", "requiredColumns_min": "Columnas requeridas: al menos {{min}}", + "requiredColumns_none": "No se requieren columnas", "requiredColumns_range": "Columnas requeridas: entre {{min}} y {{max}}", "restrictedDataTypes": "Tipos de datos restringidos: <1><0>", "rightSkewedWarning": "<0>Distribución sesgada a la derecha: Considera aplicar una transformación logarítmica.", diff --git a/DashAI/front/src/utils/i18n/locales/es/experiments.json b/DashAI/front/src/utils/i18n/locales/es/experiments.json index b9dd6b244..f14c99eb9 100644 --- a/DashAI/front/src/utils/i18n/locales/es/experiments.json +++ b/DashAI/front/src/utils/i18n/locales/es/experiments.json @@ -57,6 +57,7 @@ "noDatasetsAvailableGoToDataTab": "Vaya a <1>pestaña de datos para subir uno primero.", "noModelsAvailable": "No hay Modelos Disponibles", "noOptimizersNoMetric": "Sin optimización de hiperparámetros", + "noSplitConfigNeeded": "Este tipo de tarea entrena con el dataset completo — no requiere configurar una partición de entrenamiento, validación y prueba.", "optimizer": "Optimizador", "optimizerMetric": "Métrica de Optimización", "parameterModification": "Modificación de Parámetros", diff --git a/DashAI/front/src/utils/i18n/locales/es/models.json b/DashAI/front/src/utils/i18n/locales/es/models.json index 63802b062..1bddcf294 100644 --- a/DashAI/front/src/utils/i18n/locales/es/models.json +++ b/DashAI/front/src/utils/i18n/locales/es/models.json @@ -284,7 +284,10 @@ "validation": "Validación", "validationMetrics": "Métricas de Validación", "validationSet": "conjunto de validación", - "viewResultsAs": "Ver resultados como columnas o gráficos" + "viewResultsAs": "Ver resultados como columnas o gráficos", + "last": "Final", + "supervisedBadge": "Supervisado", + "unsupervisedBadge": "No supervisado" }, "message": { "allRunsCompleted": "{{experiment}} ha completado todas sus ejecuciones.", diff --git a/DashAI/front/src/utils/i18n/locales/pt/common.json b/DashAI/front/src/utils/i18n/locales/pt/common.json index a6fa8c12d..7792861ab 100644 --- a/DashAI/front/src/utils/i18n/locales/pt/common.json +++ b/DashAI/front/src/utils/i18n/locales/pt/common.json @@ -84,6 +84,7 @@ "finish": "Finalizar", "finished": "Concluído", "from": "De", + "full": "Completo", "generative": "Generativo", "graphs": "Gráficos", "hardwareMonitor": { diff --git a/DashAI/front/src/utils/i18n/locales/pt/datasets.json b/DashAI/front/src/utils/i18n/locales/pt/datasets.json index b671ad4ac..63717fe0b 100644 --- a/DashAI/front/src/utils/i18n/locales/pt/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/pt/datasets.json @@ -94,6 +94,8 @@ "noDatasetFileAvailable": "Não há arquivo do conjunto de dados disponível", "notebookNameEmpty": "O nome do caderno não pode estar vazio", "noValidColumnsForExplorer": "Não há colunas válidas disponíveis para este explorador.", + "requiresConverter": "Requer um conversor '{{converterClass}}' concluído. Execute primeiro este conversor no seu dataset.", + "requiresAlgorithm": "Este explorador requer o algoritmo '{{algorithm}}', mas o último conversor usou um diferente.", "noValidColumnsWithDtypesMentioned": "Este conjunto de dados não possui colunas com os tipos necessários ({{dtypes}}).", "processConverterError": "Falha ao processar conversor", "requiredFieldsMissing": "Campos obrigatórios ausentes", @@ -336,6 +338,7 @@ "requiredColumns": "Colunas necessárias", "requiredColumns_exact": "Colunas necessárias: exatamente {{exact}}", "requiredColumns_min": "Colunas necessárias: pelo menos {{min}}", + "requiredColumns_none": "Nenhuma coluna necessária", "requiredColumns_range": "Colunas necessárias: entre {{min}} e {{max}}", "restrictedDataTypes": "Tipos de dados restritos: <1><0>", "rightSkewedWarning": "<0>Distribuição assimétrica à direita: Considere aplicar uma transformação logarítmica.", diff --git a/DashAI/front/src/utils/i18n/locales/pt/experiments.json b/DashAI/front/src/utils/i18n/locales/pt/experiments.json index 823e7244d..12c7cca94 100644 --- a/DashAI/front/src/utils/i18n/locales/pt/experiments.json +++ b/DashAI/front/src/utils/i18n/locales/pt/experiments.json @@ -57,6 +57,7 @@ "noDatasetsAvailableGoToDataTab": "Vá para a <1>aba de dados para enviar um primeiro.", "noModelsAvailable": "Nenhum Modelo Disponível", "noOptimizersNoMetric": "Sem otimização de hiperparâmetros", + "noSplitConfigNeeded": "Este tipo de tarefa treina com o conjunto de dados completo — não é necessário configurar uma divisão de treino/validação/teste.", "optimizer": "Otimizador", "optimizerMetric": "Métrica de Otimização", "parameterModification": "Modificação de Parâmetros", diff --git a/DashAI/front/src/utils/i18n/locales/pt/models.json b/DashAI/front/src/utils/i18n/locales/pt/models.json index 521d38d20..9c72b3abd 100644 --- a/DashAI/front/src/utils/i18n/locales/pt/models.json +++ b/DashAI/front/src/utils/i18n/locales/pt/models.json @@ -284,7 +284,10 @@ "validation": "Validação", "validationMetrics": "Métricas de Validação", "validationSet": "conjunto de validação", - "viewResultsAs": "Ver resultados como colunas ou gráficos" + "viewResultsAs": "Ver resultados como colunas ou gráficos", + "last": "Final", + "supervisedBadge": "Supervisionado", + "unsupervisedBadge": "Não supervisionado" }, "message": { "allRunsCompleted": "{{experiment}} concluiu todas as suas execuções.", diff --git a/DashAI/front/src/utils/i18n/locales/zh/common.json b/DashAI/front/src/utils/i18n/locales/zh/common.json index df958e9f0..b25ec704d 100644 --- a/DashAI/front/src/utils/i18n/locales/zh/common.json +++ b/DashAI/front/src/utils/i18n/locales/zh/common.json @@ -80,6 +80,7 @@ "finish": "完成", "finished": "已完成", "from": "从", + "full": "完整", "generative": "生成式", "graphs": "图表", "hardwareMonitor": { diff --git a/DashAI/front/src/utils/i18n/locales/zh/datasets.json b/DashAI/front/src/utils/i18n/locales/zh/datasets.json index fd4db7f53..14d48054c 100644 --- a/DashAI/front/src/utils/i18n/locales/zh/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/zh/datasets.json @@ -91,6 +91,8 @@ "noDatasetFileAvailable": "无可用数据集文件", "notebookNameEmpty": "笔记本名称不能为空", "noValidColumnsForExplorer": "此探索器没有有效的可用列。", + "requiresConverter": "需要一个已完成的'{{converterClass}}'转换器。请先在数据集上运行此转换器。", + "requiresAlgorithm": "此探索器需要'{{algorithm}}'算法,但最后一个转换器使用了不同的算法。", "noValidColumnsWithDtypesMentioned": "此数据集没有所需类型({{dtypes}})的列。", "processConverterError": "处理转换器失败", "requiredFieldsMissing": "必填字段缺失", @@ -327,6 +329,7 @@ "requiredColumns": "必需列", "requiredColumns_exact": "必需列:恰好 {{exact}} 列", "requiredColumns_min": "必需列:至少 {{min}} 列", + "requiredColumns_none": "无需列", "requiredColumns_range": "必需列:{{min}} 到 {{max}} 列", "restrictedDataTypes": "限制的数据类型:<1><0>", "rightSkewedWarning": "<0>右偏分布:考虑应用对数变换。", diff --git a/DashAI/front/src/utils/i18n/locales/zh/experiments.json b/DashAI/front/src/utils/i18n/locales/zh/experiments.json index ab2512e1f..72812a377 100644 --- a/DashAI/front/src/utils/i18n/locales/zh/experiments.json +++ b/DashAI/front/src/utils/i18n/locales/zh/experiments.json @@ -57,6 +57,7 @@ "noDatasetsAvailableGoToDataTab": "请前往<1>数据标签页先上传数据集。", "noModelsAvailable": "暂无可用模型", "noOptimizersNoMetric": "不进行超参数优化", + "noSplitConfigNeeded": "此任务类型使用完整数据集进行训练——无需配置训练/验证/测试划分。", "optimizer": "优化器", "optimizerMetric": "优化指标", "parameterModification": "参数修改", diff --git a/DashAI/front/src/utils/i18n/locales/zh/models.json b/DashAI/front/src/utils/i18n/locales/zh/models.json index e5a2aac54..534cdf933 100644 --- a/DashAI/front/src/utils/i18n/locales/zh/models.json +++ b/DashAI/front/src/utils/i18n/locales/zh/models.json @@ -277,7 +277,10 @@ "validation": "验证", "validationMetrics": "验证集指标", "validationSet": "验证集", - "viewResultsAs": "以列或图表方式查看结果" + "viewResultsAs": "以列或图表方式查看结果", + "last": "终值", + "supervisedBadge": "有监督", + "unsupervisedBadge": "无监督" }, "message": { "allRunsCompleted": "{{experiment}} 已完成所有运行。", diff --git a/DashAI/front/src/utils/schema.js b/DashAI/front/src/utils/schema.js index 906ae3e1f..b81f12218 100644 --- a/DashAI/front/src/utils/schema.js +++ b/DashAI/front/src/utils/schema.js @@ -145,7 +145,9 @@ const generateField = (subSchema) => { } else if (subSchema.type === "object") { field = Yup.object(); - if (!subSchema.parent) { + if (subSchema.conditionalSchemas) { + field = Yup.object(); + } else if (!subSchema.parent) { const properties = {}; Object.keys(subSchema.properties).forEach((key) => { properties[key] = generateField(subSchema.properties[key]); diff --git a/pyproject.toml b/pyproject.toml index 900622767..8235c0115 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ dependencies = [ "imblearn", "numba>=0.60", "llvmlite>=0.43", + "faiss-cpu", "huey", "ijson", "greenery==3.2", diff --git a/tests/back/api/test_components_api.py b/tests/back/api/test_components_api.py index d1381f796..2eaeaee43 100644 --- a/tests/back/api/test_components_api.py +++ b/tests/back/api/test_components_api.py @@ -176,6 +176,8 @@ def test_get_component_by_id(client: TestClient): "outputs_types": ["ClassLabel"], "inputs_cardinality": "n", "outputs_cardinality": 1, + "requires_target": True, + "session_config_schema": {}, }, "description": "Task 1.", "display_name": "Test Task 1", @@ -198,6 +200,8 @@ def test_get_component_by_id(client: TestClient): "outputs_types": ["ClassLabel"], "inputs_cardinality": 1, "outputs_cardinality": 1, + "requires_target": True, + "session_config_schema": {}, }, "description": "Task 2.", "display_name": None, @@ -322,6 +326,8 @@ def test_get_components_select_only_tasks(client: TestClient): "outputs_types": ["ClassLabel"], "inputs_cardinality": "n", "outputs_cardinality": 1, + "requires_target": True, + "session_config_schema": {}, }, "description": "Task 1.", "display_name": "Test Task 1", @@ -341,6 +347,8 @@ def test_get_components_select_only_tasks(client: TestClient): "outputs_types": ["ClassLabel"], "inputs_cardinality": 1, "outputs_cardinality": 1, + "requires_target": True, + "session_config_schema": {}, }, "description": "Task 2.", "display_name": None, @@ -494,6 +502,8 @@ def test_get_components_ignore_models(client: TestClient): "outputs_types": ["ClassLabel"], "inputs_cardinality": "n", "outputs_cardinality": 1, + "requires_target": True, + "session_config_schema": {}, }, "description": "Task 1.", "display_name": "Test Task 1", @@ -513,6 +523,8 @@ def test_get_components_ignore_models(client: TestClient): "outputs_types": ["ClassLabel"], "inputs_cardinality": 1, "outputs_cardinality": 1, + "requires_target": True, + "session_config_schema": {}, }, "description": "Task 2.", "display_name": None, @@ -700,6 +712,8 @@ def test_get_components_related_inverse_relation(client: TestClient): "outputs_types": ["ClassLabel"], "inputs_cardinality": "n", "outputs_cardinality": 1, + "requires_target": True, + "session_config_schema": {}, }, "description": "Task 1.", "display_name": "Test Task 1", diff --git a/tests/back/api/test_jobs.py b/tests/back/api/test_jobs.py index 9efc76677..d9cd5c217 100644 --- a/tests/back/api/test_jobs.py +++ b/tests/back/api/test_jobs.py @@ -12,13 +12,13 @@ from DashAI.back.evaluation.holdout import HoldoutEvaluationStrategy from DashAI.back.job.model_job import ModelJob from DashAI.back.metrics.base_metric import BaseMetric -from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.supervised_model import SupervisedModel 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.supervised_task import SupervisedTask -class DummyTask(BaseTask): +class DummyTask(SupervisedTask): name: str = "DummyTask" metadata: dict = { "inputs_types": [ClassLabel, Value], @@ -30,11 +30,8 @@ class DummyTask(BaseTask): def prepare_for_task(self, dataset, input_columns=None, output_columns=None): return dataset - def num_labels(self, dataset, output_column): - return None - -class DummyModel(BaseModel): +class DummyModel(SupervisedModel): COMPATIBLE_COMPONENTS = ["DummyTask"] def save(self, filename): @@ -53,7 +50,7 @@ def prepare_dataset(self, dataset, is_fit=False): return -class FailDummyModel(BaseModel): +class FailDummyModel(SupervisedModel): COMPATIBLE_COMPONENTS = ["DummyTask"] def save(self, filename): diff --git a/tests/back/api/test_predict_api.py b/tests/back/api/test_predict_api.py index 5ab03647a..1f90d9d54 100644 --- a/tests/back/api/test_predict_api.py +++ b/tests/back/api/test_predict_api.py @@ -13,13 +13,13 @@ from DashAI.back.job.dataset_job import DatasetJob from DashAI.back.job.model_job import ModelJob from DashAI.back.metrics.base_metric import BaseMetric -from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.supervised_model import SupervisedModel from DashAI.back.optimizers.optuna_optimizer import OptunaOptimizer -from DashAI.back.tasks.base_task import BaseTask +from DashAI.back.tasks.supervised_task import SupervisedTask from DashAI.back.tasks.tabular_classification_task import TabularClassificationTask -class DummyTask(BaseTask): +class DummyTask(SupervisedTask): name: str = "DummyTask" metadata: dict = { "inputs_types": [ClassLabel, Value], @@ -28,11 +28,11 @@ class DummyTask(BaseTask): "outputs_cardinality": 1, } - def prepare_for_task(self, dataset, output_columns): + def prepare_for_task(self, dataset, input_columns=None, output_columns=None): return dataset -class DummyModel(BaseModel): +class DummyModel(SupervisedModel): COMPATIBLE_COMPONENTS = ["DummyTask"] def save(self, filename): diff --git a/tests/back/job_queue/test_model_job_clustering.py b/tests/back/job_queue/test_model_job_clustering.py new file mode 100644 index 000000000..1f6788288 --- /dev/null +++ b/tests/back/job_queue/test_model_job_clustering.py @@ -0,0 +1,272 @@ +"""Regression tests for the target free branch of ``ModelJob``. + +Clustering runs take a different route through the job than every other task: +no splitter, no optimiser, no evaluation strategy, and metrics scored over the +whole dataset. These cover the two things that route gets wrong when left +alone: handing the models unscaled columns, and finishing a run that produced +nothing to report. +""" + +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum +from DashAI.back.dataloaders.classes.dashai_dataset import ( + to_dashai_dataset, + transform_dataset_with_schema, +) +from DashAI.back.dependencies.database.models import Base, Metric +from DashAI.back.job.base_job import JobError +from DashAI.back.job.model_job import ModelJob +from DashAI.back.metrics.clustering.calinski_harabasz import CalinskiHarabasz +from DashAI.back.metrics.clustering.davies_bouldin import DaviesBouldin +from DashAI.back.metrics.clustering.silhouette import Silhouette +from DashAI.back.models.scikit_learn.dbscan_clustering import DBSCANClustering +from DashAI.back.models.scikit_learn.kmeans_clustering import KMeansClustering + +METRICS = [Silhouette, CalinskiHarabasz, DaviesBouldin] + + +@pytest.fixture(name="db") +def fixture_db(): + """A throwaway session. Only the metric writes are exercised here, so the + run is a stand in for its id rather than a real row.""" + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with sessionmaker(bind=engine)() as session: + yield session + + +def _mixed_scales(rows=60, seed=0): + """Columns whose units are as far apart as a real tabular dataset's. + + ``score`` spans sixty units while ``hours`` spans ten, which is enough for + the wider column to dominate every distance the models measure. + """ + rng = np.random.default_rng(seed) + frame = pd.DataFrame( + { + "hours": rng.uniform(1.0, 11.0, rows), + "score": rng.uniform(40.0, 100.0, rows), + "constant": np.ones(rows), + } + ) + # Spelled out rather than inferred: a dataset built without a schema + # carries no DashAI types, and the task refuses to prepare one. + return transform_dataset_with_schema( + to_dashai_dataset(frame), + {name: {"type": "Float", "dtype": "float64"} for name in frame.columns}, + ) + + +def _blobs(per_centre=30, seed=0): + rng = np.random.default_rng(seed) + points = np.vstack( + [ + rng.normal(loc=c, scale=0.1, size=(per_centre, 2)) + for c in [(0.0, 0.0), (10.0, 10.0)] + ] + ) + return to_dashai_dataset(pd.DataFrame({"x": points[:, 0], "y": points[:, 1]})) + + +def _preparation(model, x): + return {"X": x, "factory": SimpleNamespace(model=model), "metrics": METRICS} + + +# --- the features the models are handed -------------------------------------- + + +def test_every_numeric_column_is_centred_and_scaled(): + scaled = ModelJob._standardise_features(_mixed_scales()).to_pandas() + + for column in ("hours", "score"): + assert scaled[column].mean() == pytest.approx(0.0, abs=1e-12) + assert scaled[column].std(ddof=0) == pytest.approx(1.0) + + +def test_a_column_without_variance_is_left_where_it_is(): + """Dividing it by a zero standard deviation is what produces the NaNs the + models then refuse, so it is skipped rather than scaled.""" + scaled = ModelJob._standardise_features(_mixed_scales()).to_pandas() + + assert (scaled["constant"] == 1.0).all() + + +def test_a_dataset_with_nothing_to_scale_is_returned_unchanged(): + x = to_dashai_dataset(pd.DataFrame({"constant": np.ones(5)})) + + assert ModelJob._standardise_features(x) is x + + +def test_the_row_and_column_shape_survives_scaling(): + x = _mixed_scales() + + scaled = ModelJob._standardise_features(x) + + assert scaled.to_pandas().shape == x.to_pandas().shape + assert list(scaled.to_pandas().columns) == list(x.to_pandas().columns) + + +def test_scaling_is_what_lets_dbscan_find_anything_on_mixed_units(): + """The failure this guards against: with raw columns the default eps is far + smaller than the spread of the widest one, so every row comes back noise.""" + x = _mixed_scales() + + raw = np.asarray(DBSCANClustering().train(x).get_cluster_labels(x)) + scaled_x = ModelJob._standardise_features(x) + scaled = np.asarray(DBSCANClustering().train(scaled_x).get_cluster_labels(scaled_x)) + + assert (raw == -1).all() + assert (scaled != -1).any() + + +def test_the_prepare_step_scales_before_the_model_is_built(monkeypatch): + """The helper above is only useful if the job actually calls it. Deleting + the one line in ``_prepare_without_target`` is otherwise invisible, since + nothing downstream fails, the results just get quietly worse. + """ + from DashAI.back.tasks.clustering_task import ClusteringTask + + seen = [] + monkeypatch.setattr( + ModelJob, + "_standardise_features", + staticmethod(lambda x: seen.append(x) or x), + ) + + class _Registry: + """Answers the metric lookup and nothing else. + + The model lookup that follows the scaling is left to fail on purpose: + by the time it does, the call under test has either happened or never + will, so the job does not need to run any further than this. + """ + + @staticmethod + def get_related_components(_task_name): + return [{"name": m.__name__, "type": "Metric"} for m in METRICS] + + def __getitem__(self, name): + return {"class": next(m for m in METRICS if m.__name__ == name)} + + # An instance without __init__: the method reaches the helper through self, + # and nothing else on the job is touched before it does. + job = ModelJob.__new__(ModelJob) + + with pytest.raises(JobError): + job._prepare_without_target( + run=SimpleNamespace(id=1, model_name="absent", parameters={}), + model_session=SimpleNamespace( + task_name="ClusteringTask", input_columns=["hours", "score"] + ), + dataset=SimpleNamespace(id=1), + loaded_dataset=_mixed_scales(), + task=ClusteringTask(), + component_registry=_Registry(), + ) + + assert seen, "_prepare_without_target no escaló las columnas" + + +# --- runs that produced nothing to report ------------------------------------ + + +def test_a_run_where_every_point_is_noise_fails_instead_of_finishing_empty(db): + """Left alone the metrics answer None one by one, no row is written and the + run reports success with an empty table and no reason given.""" + x = _mixed_scales() + + with pytest.raises(JobError, match="at least two clusters"): + ModelJob._train_without_target( + None, _preparation(DBSCANClustering(), x), run=SimpleNamespace(id=1), db=db + ) + + assert db.query(Metric).count() == 0 + + +def test_the_failure_names_the_model_and_counts_the_noise(db): + x = _mixed_scales() + + with pytest.raises(JobError) as raised: + ModelJob._train_without_target( + None, _preparation(DBSCANClustering(), x), run=SimpleNamespace(id=1), db=db + ) + + message = str(raised.value) + assert "DBSCANClustering" in message + assert "60 samples" in message + assert "60 of them labelled as noise" in message + + +def test_a_run_that_finds_a_single_cluster_fails_too(db): + """Not a noise problem: k means asked for one group has nothing to compare.""" + with pytest.raises(JobError, match="at least two clusters"): + ModelJob._train_without_target( + None, + _preparation(KMeansClustering(n_clusters=1, random_state=0), _blobs()), + run=SimpleNamespace(id=1), + db=db, + ) + + +# --- runs that worked -------------------------------------------------------- + + +def test_a_healthy_clustering_writes_one_row_per_metric(db): + ModelJob._train_without_target( + None, + _preparation(KMeansClustering(n_clusters=2, random_state=0), _blobs()), + run=SimpleNamespace(id=1), + db=db, + ) + + rows = db.query(Metric).all() + + assert {row.name for row in rows} == {m.__name__ for m in METRICS} + assert all(row.split == SplitEnum.FULL for row in rows) + assert all(row.level == LevelEnum.LAST for row in rows) + assert all(row.step == 0 for row in rows) + + +def test_the_fitted_model_is_handed_back_for_the_job_to_save(db): + model = KMeansClustering(n_clusters=2, random_state=0) + + returned = ModelJob._train_without_target( + None, _preparation(model, _blobs()), run=SimpleNamespace(id=1), db=db + ) + + assert returned is model + + +def test_training_again_replaces_the_previous_values_rather_than_adding_rows(db): + x = _blobs() + + for seed in (0, 1): + ModelJob._train_without_target( + None, + _preparation(KMeansClustering(n_clusters=2, random_state=seed), x), + run=SimpleNamespace(id=1), + db=db, + ) + + assert db.query(Metric).count() == len(METRICS) + + +def test_two_runs_of_the_same_session_keep_their_own_rows(db): + x = _blobs() + + for run_id in (1, 2): + ModelJob._train_without_target( + None, + _preparation(KMeansClustering(n_clusters=2, random_state=0), x), + run=SimpleNamespace(id=run_id), + db=db, + ) + + assert db.query(Metric).count() == 2 * len(METRICS) diff --git a/tests/back/metrics/test_clustering_metrics.py b/tests/back/metrics/test_clustering_metrics.py new file mode 100644 index 000000000..3821c2392 --- /dev/null +++ b/tests/back/metrics/test_clustering_metrics.py @@ -0,0 +1,141 @@ +import numpy as np +import pandas as pd +import pytest + +from DashAI.back.dataloaders.classes.dashai_dataset import to_dashai_dataset +from DashAI.back.metrics.clustering.calinski_harabasz import CalinskiHarabasz +from DashAI.back.metrics.clustering.davies_bouldin import DaviesBouldin +from DashAI.back.metrics.clustering.silhouette import Silhouette + +ALL_METRICS = [Silhouette, CalinskiHarabasz, DaviesBouldin] + + +def _x(values): + """A one column feature matrix, so distances are just differences.""" + return to_dashai_dataset(pd.DataFrame({"v": [float(v) for v in values]})) + + +def _two_groups(gap, per_group=20, spread=0.1, seed=0): + """Two equally sized groups whose centres sit ``gap`` apart.""" + rng = np.random.default_rng(seed) + left = rng.normal(loc=0.0, scale=spread, size=per_group) + right = rng.normal(loc=gap, scale=spread, size=per_group) + labels = np.repeat([0, 1], per_group) + return _x(np.concatenate([left, right])), labels + + +# --- the shared contract ----------------------------------------------------- + + +@pytest.mark.parametrize("metric", ALL_METRICS) +def test_every_metric_scores_features_against_labels(metric): + """Internal indices take (x, labels), not the (y_true, y_pred) of the rest.""" + x, labels = _two_groups(gap=10.0) + + assert isinstance(metric.score(x, labels), float) + + +@pytest.mark.parametrize("metric", ALL_METRICS) +def test_every_metric_is_undefined_when_a_single_cluster_is_found(metric): + assert metric.score(_x([1.0, 2.0, 3.0, 4.0]), np.array([0, 0, 0, 0])) is None + + +@pytest.mark.parametrize("metric", ALL_METRICS) +def test_every_metric_is_undefined_when_every_point_is_noise(metric): + """The shape DBSCAN produces when its eps is too small for the data.""" + assert metric.score(_x([1.0, 2.0, 3.0, 4.0]), np.array([-1, -1, -1, -1])) is None + + +@pytest.mark.parametrize("metric", ALL_METRICS) +def test_every_metric_is_undefined_when_every_point_is_its_own_cluster(metric): + assert metric.score(_x([1.0, 2.0, 3.0, 4.0]), np.array([0, 1, 2, 3])) is None + + +@pytest.mark.parametrize("metric", ALL_METRICS) +def test_every_metric_leaves_noise_out_of_the_score(metric): + """Noise is not a cluster, so adding a noise row must not move the answer.""" + x, labels = _two_groups(gap=10.0) + + frame = x.to_pandas() + frame.loc[len(frame)] = {"v": 500.0} + with_noise = to_dashai_dataset(frame) + labels_with_noise = np.append(labels, -1) + + assert metric.score(with_noise, labels_with_noise) == pytest.approx( + metric.score(x, labels) + ) + + +@pytest.mark.parametrize("metric", ALL_METRICS) +def test_every_metric_declares_which_direction_is_better(metric): + assert isinstance(metric.MAXIMIZE, bool) + + +# --- values that can be worked out by hand ----------------------------------- + + +def test_silhouette_matches_a_hand_computed_value(): + # Two pairs on a line: {0, 1} and {10, 11}. For the point at 0 the distance + # within its cluster is 1 and the mean distance to the other is 10.5, so its + # silhouette is 9.5/10.5. The four points give 0.904762, 0.894737, 0.894737 + # and 0.904762, and the metric reports their mean. + score = Silhouette.score(_x([0.0, 1.0, 10.0, 11.0]), np.array([0, 0, 1, 1])) + + assert score == pytest.approx(0.89975, abs=1e-5) + + +def test_silhouette_approaches_one_as_the_groups_separate(): + close, labels = _two_groups(gap=1.0) + far, _ = _two_groups(gap=100.0) + + assert Silhouette.score(far, labels) > 0.99 + assert Silhouette.score(close, labels) < Silhouette.score(far, labels) + + +def test_silhouette_turns_negative_when_the_labels_are_shuffled_across_groups(): + """Points assigned to the far group score below zero, which is the point of + a negative silhouette: it flags samples that sit in the wrong cluster.""" + x, labels = _two_groups(gap=100.0) + crossed = np.tile([0, 1], len(labels) // 2) + + assert Silhouette.score(x, crossed) < 0 + + +# --- the direction each index moves in --------------------------------------- + + +def test_calinski_harabasz_rises_with_separation(): + labels = np.repeat([0, 1], 20) + + close, _ = _two_groups(gap=1.0) + far, _ = _two_groups(gap=100.0) + + assert CalinskiHarabasz.score(far, labels) > CalinskiHarabasz.score(close, labels) + assert CalinskiHarabasz.MAXIMIZE is True + + +def test_davies_bouldin_falls_with_separation(): + labels = np.repeat([0, 1], 20) + + close, _ = _two_groups(gap=1.0) + far, _ = _two_groups(gap=100.0) + + assert DaviesBouldin.score(far, labels) < DaviesBouldin.score(close, labels) + assert DaviesBouldin.MAXIMIZE is False + + +# --- inputs the metrics must refuse ------------------------------------------ + + +@pytest.mark.parametrize("metric", ALL_METRICS) +def test_every_metric_refuses_a_label_count_that_does_not_match_the_rows(metric): + with pytest.raises(ValueError, match="must have the same length"): + metric.score(_x([1.0, 2.0, 3.0]), np.array([0, 1])) + + +@pytest.mark.parametrize("metric", ALL_METRICS) +def test_every_metric_refuses_a_dataset_with_no_numeric_column(metric): + x = to_dashai_dataset(pd.DataFrame({"name": ["a", "b", "c", "d"]})) + + with pytest.raises(ValueError, match="at least one numeric column"): + metric.score(x, np.array([0, 0, 1, 1])) diff --git a/tests/back/models/test_clustering_models.py b/tests/back/models/test_clustering_models.py new file mode 100644 index 000000000..ebf23c489 --- /dev/null +++ b/tests/back/models/test_clustering_models.py @@ -0,0 +1,214 @@ +import json +import sys + +import numpy as np +import pandas as pd +import pytest + +from DashAI.back.dataloaders.classes.dashai_dataset import to_dashai_dataset +from DashAI.back.models.clustering_model import ClusteringModel +from DashAI.back.models.faiss.faiss_dbscan_clustering import FaissDBSCANClustering +from DashAI.back.models.faiss.faiss_kmeans_clustering import FaissKMeansClustering +from DashAI.back.models.scikit_learn.agglomerative_clustering import ( + AgglomerativeClustering, +) +from DashAI.back.models.scikit_learn.dbscan_clustering import DBSCANClustering +from DashAI.back.models.scikit_learn.gaussian_mixture_clustering import ( + GaussianMixtureClustering, +) +from DashAI.back.models.scikit_learn.hdbscan_clustering import HDBSCANClustering +from DashAI.back.models.scikit_learn.kmeans_clustering import KMeansClustering +from DashAI.back.models.scikit_learn.spectral_clustering import SpectralClustering + +ALL_MODELS = [ + KMeansClustering, + DBSCANClustering, + HDBSCANClustering, + AgglomerativeClustering, + GaussianMixtureClustering, + SpectralClustering, + FaissKMeansClustering, + FaissDBSCANClustering, +] + +# The number of groups is not a shared parameter: centroid based models are +# told how many to look for, density based ones work it out from the data. +# Pairing each model with the arguments that ask it for three keeps the +# recovery test below honest instead of asserting each algorithm's default. +ASKED_FOR_THREE = [ + (KMeansClustering, {"n_clusters": 3, "random_state": 0}), + (AgglomerativeClustering, {"n_clusters": 3}), + (GaussianMixtureClustering, {"n_components": 3, "random_state": 0}), + (SpectralClustering, {"n_clusters": 3, "random_state": 0}), + (FaissKMeansClustering, {"n_clusters": 3, "random_state": 0}), + (DBSCANClustering, {}), + (HDBSCANClustering, {}), + (FaissDBSCANClustering, {}), +] + +CENTRES = [(0.0, 0.0), (10.0, 10.0), (-10.0, 10.0)] + +# FAISS trains its own k-means index and wants roughly forty points per +# centroid before it stops warning and starts converging, so the fixture is +# sized for the strictest model rather than the cheapest one. +PER_CENTRE = 100 + + +def _blobs(per_centre=PER_CENTRE, spread=0.1, seed=0): + """Three tight, far apart blobs that every model here can recover. + + The centres sit a hundred times the spread apart, so the grouping is not a + matter of judgement: any algorithm that fails this is broken rather than + merely differently tuned. + """ + rng = np.random.default_rng(seed) + points = np.vstack( + [rng.normal(loc=c, scale=spread, size=(per_centre, 2)) for c in CENTRES] + ) + return to_dashai_dataset(pd.DataFrame({"x": points[:, 0], "y": points[:, 1]})) + + +def _truth(per_centre=PER_CENTRE): + """Which blob each row of ``_blobs`` was drawn from.""" + return np.repeat(range(len(CENTRES)), per_centre) + + +# --- the shared contract ----------------------------------------------------- + + +@pytest.mark.parametrize("model_class", ALL_MODELS) +def test_every_model_assigns_one_label_per_row(model_class): + x = _blobs() + + model = model_class() + model.train(x) + labels = np.asarray(model.get_cluster_labels(x)) + + assert labels.shape == (len(CENTRES) * PER_CENTRE,) + assert np.issubdtype(labels.dtype, np.integer) + + +@pytest.mark.parametrize("model_class", ALL_MODELS) +def test_every_model_is_declared_for_the_clustering_task(model_class): + assert model_class.COMPATIBLE_COMPONENTS == ["ClusteringTask"] + assert issubclass(model_class, ClusteringModel) + + +@pytest.mark.parametrize("model_class", ALL_MODELS) +def test_every_model_reports_fit_attributes_the_converter_can_serialise(model_class): + """The report reaches the explorers as JSON, so a numpy array would break it.""" + model = model_class() + model.train(_blobs()) + + json.dumps(model.get_fit_attributes()) + + +@pytest.mark.parametrize("model_class", ALL_MODELS) +def test_every_model_round_trips_through_save_and_load(model_class, tmp_path): + x = _blobs() + model = model_class() + model.train(x) + expected = list(np.asarray(model.get_cluster_labels(x))) + + path = tmp_path / "model.joblib" + model.save(str(path)) + restored = model_class.load(str(path)) + + assert list(np.asarray(restored.get_cluster_labels(x))) == expected + + +@pytest.mark.parametrize(("model_class", "params"), ASKED_FOR_THREE) +def test_every_model_recovers_three_separated_blobs(model_class, params): + x = _blobs() + truth = _truth() + + model = model_class(**params) + model.train(x) + labels = np.asarray(model.get_cluster_labels(x)) + + clustered = labels[labels != -1] + assert len(set(clustered.tolist())) == 3 + + # Every row of a blob lands in one cluster, and no two blobs share it. + per_blob = [set(labels[truth == k].tolist()) for k in range(len(CENTRES))] + assert all(len(group) == 1 for group in per_blob) + assert len({group.pop() for group in per_blob}) == 3 + + +# --- what the converter relies on -------------------------------------------- + + +def test_the_registry_finds_every_algorithm_once_its_module_is_imported(): + """``Clustering`` builds its dropdown from this, with no manual list.""" + registry = ClusteringModel.get_registry() + + for model_class in ALL_MODELS: + assert registry[model_class.__name__] is model_class + + +def test_the_registry_leaves_out_the_abstract_adapters(): + """Only classes carrying their own SCHEMA are concrete algorithms.""" + registry = ClusteringModel.get_registry() + + assert "SklearnLikeClusterer" not in registry + assert "FaissLikeClusterer" not in registry + + +# --- the behaviours each family is chosen for -------------------------------- + + +def test_dbscan_calls_a_far_away_point_noise_rather_than_its_own_cluster(): + frame = _blobs().to_pandas() + frame.loc[len(frame)] = {"x": 500.0, "y": 500.0} + x = to_dashai_dataset(frame) + + model = DBSCANClustering() + model.train(x) + labels = np.asarray(model.get_cluster_labels(x)) + + assert labels[-1] == -1 + assert (labels[:-1] != -1).all() + + +def test_kmeans_reports_its_centres_and_inertia_after_fitting(): + model = KMeansClustering(n_clusters=3, random_state=0) + model.train(_blobs()) + + attributes = model.get_fit_attributes() + + assert len(attributes["cluster_centers"]) == 3 + # Inertia is the summed squared distance to each centre. With 300 points + # drawn around two axes at a spread of 0.1, that is 300 * 2 * 0.1 ** 2. + assert attributes["inertia"] == pytest.approx(6.0, rel=0.3) + + +# --- the macOS deadlock ------------------------------------------------------ + + +def test_faiss_is_pinned_to_one_thread_on_macos(monkeypatch): + """FAISS and torch each ship an OpenMP runtime, and two of them in one + process deadlock on macOS. That hung a CI job for six hours inside + FaissKMeansClustering.train while the scikit-learn clusterers beside it + finished in under a second. + """ + faiss = FaissKMeansClustering._import_faiss() + before = faiss.omp_get_max_threads() + try: + monkeypatch.setattr(sys, "platform", "darwin") + + FaissKMeansClustering._import_faiss() + + assert faiss.omp_get_max_threads() == 1 + finally: + faiss.omp_set_num_threads(before) + + +def test_faiss_keeps_its_threads_everywhere_else(monkeypatch): + """The adapter exists to accelerate large datasets, so only macOS pays.""" + faiss = FaissKMeansClustering._import_faiss() + before = faiss.omp_get_max_threads() + monkeypatch.setattr(sys, "platform", "linux") + + FaissKMeansClustering._import_faiss() + + assert faiss.omp_get_max_threads() == before diff --git a/tests/back/models/test_epoch_reporter.py b/tests/back/models/test_epoch_reporter.py index 5a63be565..030cad59a 100644 --- a/tests/back/models/test_epoch_reporter.py +++ b/tests/back/models/test_epoch_reporter.py @@ -1,10 +1,11 @@ -"""Tests for the per-epoch reporting hook on BaseModel. +"""Tests for the per-epoch reporting hook on SupervisedModel. The hook exists so an optimizer can watch a trial while it trains. It lives on -the base class rather than inside each model's epoch loop because every model -that trains in epochs already routes its per-epoch metrics through +the supervised base class rather than inside each model's epoch loop because +every model that trains in epochs already routes its per-epoch metrics through `calculate_metrics` — five loops across five files that share no common ancestor -below `BaseModel`. +below `SupervisedModel`. The flag itself (`_epoch_reporter`) stays on +`BaseModel`, since the optimizer sets it without knowing the model's family. What matters here is that it fires for exactly one combination (validation metrics, epoch level) and stays out of the way otherwise. @@ -13,10 +14,10 @@ import pytest from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum -from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.supervised_model import SupervisedModel -class ModelStub(BaseModel): +class ModelStub(SupervisedModel): """The smallest thing `calculate_metrics` will run against.""" def __init__(self) -> None: diff --git a/tests/back/optimizers/test_optuna_pruning_integration.py b/tests/back/optimizers/test_optuna_pruning_integration.py index 0f08be0d7..fe7555ce7 100644 --- a/tests/back/optimizers/test_optuna_pruning_integration.py +++ b/tests/back/optimizers/test_optuna_pruning_integration.py @@ -6,7 +6,7 @@ for — a pruner that never prunes passes every unit test in the file next door. Real, not stubbed: `OptunaOptimizer.optimize`, `HoldoutEvaluationStrategy. -evaluate` (the strategy that trains with validation data), `BaseModel. +evaluate` (the strategy that trains with validation data), `SupervisedModel. calculate_metrics` (where the hook lives), `_report_epoch`, and Optuna's own MedianPruner and trial bookkeeping. @@ -27,7 +27,7 @@ from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum from DashAI.back.evaluation.holdout import HoldoutEvaluationStrategy -from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.supervised_model import SupervisedModel from DashAI.back.optimizers.optuna_optimizer import OptunaOptimizer @@ -59,7 +59,7 @@ def score(y_true, y_pred): return y_pred -class SteppedModel(BaseModel): +class SteppedModel(SupervisedModel): """A model that gets worse every trial, revealing it epoch by epoch. Each trial improves by `1 / (1 + trials already run)` per epoch, so trial 5 diff --git a/tests/back/tasks/test_clustering_task.py b/tests/back/tasks/test_clustering_task.py new file mode 100644 index 000000000..d110237f6 --- /dev/null +++ b/tests/back/tasks/test_clustering_task.py @@ -0,0 +1,108 @@ +import pandas as pd +import pytest + +from DashAI.back.dataloaders.classes.dashai_dataset import ( + to_dashai_dataset, + transform_dataset_with_schema, +) +from DashAI.back.tasks.clustering_task import ClusteringTask +from DashAI.back.tasks.supervised_task import SupervisedTask +from DashAI.back.tasks.unsupervised_task import UnsupervisedTask + +NUMERIC_SCHEMA = { + "study_hours": {"type": "Float", "dtype": "float64"}, + "exam_score": {"type": "Float", "dtype": "float64"}, + "cohort": {"type": "Categorical", "dtype": "string"}, +} + + +def _dataset(): + frame = pd.DataFrame( + { + "study_hours": [1.0, 2.0, 8.0, 9.0], + "exam_score": [40.0, 45.0, 90.0, 95.0], + "cohort": ["a", "a", "b", "b"], + } + ) + return transform_dataset_with_schema(to_dashai_dataset(frame), NUMERIC_SCHEMA) + + +# --- what the session screen reads ------------------------------------------- + + +def test_clustering_needs_no_target_column(): + assert ClusteringTask.REQUIRES_TARGET is False + assert SupervisedTask.REQUIRES_TARGET is True + + +def test_clustering_asks_the_session_for_no_splits(): + """PrepareDatasetStep hides the splitter on this value, and ModelJob reads + the matching ``splitType: none`` back when it runs the session.""" + assert ClusteringTask.SESSION_CONFIG_SCHEMA["split_strategy"] == "none" + + +def test_the_metadata_sent_to_the_frontend_carries_both_session_flags(): + """The screen derives ``requiresTarget`` and ``usesSplits`` from these two + keys, so dropping either one takes the session form down with it.""" + metadata = ClusteringTask.get_metadata() + + assert metadata["requires_target"] is False + assert metadata["session_config_schema"] == {"split_strategy": "none"} + + +def test_clustering_declares_no_output_columns(): + metadata = ClusteringTask.get_metadata() + + assert metadata["outputs_cardinality"] == 0 + assert metadata["outputs_types"] == [] + assert metadata["inputs_cardinality"] == "n" + + +def test_clustering_is_an_unsupervised_task(): + assert issubclass(ClusteringTask, UnsupervisedTask) + assert not issubclass(ClusteringTask, SupervisedTask) + + +# --- dataset validation ------------------------------------------------------ + + +def test_a_numeric_dataset_with_no_output_column_is_accepted(): + ClusteringTask().validate_dataset_for_task( + dataset=_dataset(), + dataset_name="students", + input_columns=["study_hours", "exam_score"], + output_columns=[], + ) + + +def test_a_missing_output_column_list_is_treated_as_none_given(): + """The session sends no outputs at all for a task that needs no target.""" + ClusteringTask().validate_dataset_for_task( + dataset=_dataset(), + dataset_name="students", + input_columns=["study_hours", "exam_score"], + output_columns=None, + ) + + +def test_naming_an_output_column_is_refused(): + """``outputs_types`` is empty, so no column type is allowed as a target and + the refusal lands on the type check before cardinality is ever reached.""" + with pytest.raises(TypeError, match="not an allowed type for output columns"): + ClusteringTask().validate_dataset_for_task( + dataset=_dataset(), + dataset_name="students", + input_columns=["study_hours"], + output_columns=["exam_score"], + ) + + +def test_a_categorical_input_column_is_refused(): + """Every algorithm registered for this task measures numeric distances.""" + with pytest.raises(TypeError): + ClusteringTask().validate_dataset_for_task( + dataset=_dataset(), + dataset_name="students", + input_columns=["study_hours", "cohort"], + output_columns=[], + ) diff --git a/tests/back/tasks/test_tasks.py b/tests/back/tasks/test_tasks.py index 7bce6b40b..da8675fe2 100644 --- a/tests/back/tasks/test_tasks.py +++ b/tests/back/tasks/test_tasks.py @@ -15,6 +15,7 @@ from DashAI.back.dataloaders.classes.json_dataloader import JSONDataLoader from DashAI.back.dependencies.database.models import ProcessData from DashAI.back.tasks.controlnet_task import ControlNetTask +from DashAI.back.tasks.supervised_task import SupervisedTask from DashAI.back.tasks.tabular_classification_task import TabularClassificationTask from DashAI.back.tasks.text_classification_task import TextClassificationTask from DashAI.back.tasks.text_to_image_generation_task import TextToImageGenerationTask @@ -134,11 +135,13 @@ def test_get_tabular_class_task_metadata(): tabular_class_task = TabularClassificationTask() metadata = tabular_class_task.get_metadata() - assert len(metadata.keys()) == 4 + assert len(metadata.keys()) == 6 assert metadata["inputs_types"] == ["Float", "Integer", "Categorical"] assert metadata["outputs_types"] == ["Categorical"] assert metadata["inputs_cardinality"] == "n" assert metadata["outputs_cardinality"] == 1 + assert metadata["requires_target"] is True + assert metadata["session_config_schema"] == SupervisedTask.SESSION_CONFIG_SCHEMA @pytest.fixture(scope="module", name="text_classification_dataset") @@ -195,11 +198,13 @@ def test_get_text_class_task_metadata(): text_class_task = TextClassificationTask() metadata = text_class_task.get_metadata() - assert len(metadata.keys()) == 4 + assert len(metadata.keys()) == 6 assert metadata["inputs_types"] == ["Text"] assert metadata["outputs_types"] == ["Categorical"] assert metadata["inputs_cardinality"] == 1 assert metadata["outputs_cardinality"] == 1 + assert metadata["requires_target"] is True + assert metadata["session_config_schema"] == SupervisedTask.SESSION_CONFIG_SCHEMA @pytest.fixture(scope="module", name="translation_dataset") @@ -256,11 +261,13 @@ def test_get_translation_task_metadata(): translation_task = TranslationTask() metadata = translation_task.get_metadata() - assert len(metadata.keys()) == 4 + assert len(metadata.keys()) == 6 assert metadata["inputs_types"] == ["Text"] assert metadata["outputs_types"] == ["Text"] assert metadata["inputs_cardinality"] == 1 assert metadata["outputs_cardinality"] == 1 + assert metadata["requires_target"] is True + assert metadata["session_config_schema"] == SupervisedTask.SESSION_CONFIG_SCHEMA # Generative tasks diff --git a/uv.lock b/uv.lock index 8f8288ad8..32c58937f 100644 --- a/uv.lock +++ b/uv.lock @@ -1484,6 +1484,7 @@ dependencies = [ { name = "dice-ml" }, { name = "diffusers" }, { name = "evaluate" }, + { name = "faiss-cpu" }, { name = "fastapi", extra = ["all"] }, { name = "filetype" }, { name = "grad-cam" }, @@ -1589,6 +1590,7 @@ requires-dist = [ { name = "dice-ml", specifier = ">=0.12" }, { name = "diffusers" }, { name = "evaluate" }, + { name = "faiss-cpu" }, { name = "fastapi", extras = ["all"] }, { name = "filetype" }, { name = "grad-cam", specifier = ">=1.5.5" }, @@ -1916,6 +1918,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "faiss-cpu" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, + { name = "packaging" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/68/20e91694ad9a8b2bb48af956899e52b645cb1501e7e2ec31cb733da4d4c5/faiss_cpu-1.15.0-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:50ea471ef1f4f3580eda8ab0ec9727d4bf65fd71c444bf306ce7cdbba8a42b21", size = 4904897, upload-time = "2026-08-03T17:49:37.003Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/ef4cf498977c4a84af7a8920bc97ca49fc19060c8464c63fab58847b4692/faiss_cpu-1.15.0-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:dd383bb1ce06fabcff5785f998f253aa88f88dcbe1fe36c922417cd6666dd896", size = 7087977, upload-time = "2026-08-03T17:49:38.947Z" }, + { url = "https://files.pythonhosted.org/packages/94/c8/88b072bf55714405d0d7e11c12349510f15a69ae56033b1cd894fb2be7d6/faiss_cpu-1.15.0-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d0a2d5d33fe023e263d0d355a837f20db67578e3be27fc5f4012a273274abf6", size = 9835009, upload-time = "2026-08-03T17:49:40.8Z" }, + { url = "https://files.pythonhosted.org/packages/c8/3b/8878dbfc78a0084bbd408b34827a58b530be98132fcf620b7e15f9191614/faiss_cpu-1.15.0-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec9b29aae29e428c085c2d49dbb02e4673cdea75db418d420f9e60e0b4184498", size = 18764625, upload-time = "2026-08-03T17:49:43.676Z" }, + { url = "https://files.pythonhosted.org/packages/db/2a/654116e6ee2808562a6b2a11c396bdb46d45689e3bf7206ee99400589cab/faiss_cpu-1.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:30da3029952f0de69f16ce31946fd63fc3e292c867749bbcd2c0a0f09fd06f65", size = 11413863, upload-time = "2026-08-03T17:49:46.471Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/0a0f09659c1972aa83b9820cd3dd7f68f6678cfcfebde542e1c23d7d8663/faiss_cpu-1.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:88fbe1acac6978869063cb2f9477f85718da596a6e0a17751618f9c756bce255", size = 19470092, upload-time = "2026-08-03T17:49:50.253Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/f073d88436f1d4d4f7dcd638c5512197ad78fcdc1eb77db1d9929cf158ea/faiss_cpu-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:e0fe7278f3784b7d205ae715a115801cafb75f6e55db6b0fbe83c4ff379f003f", size = 16246848, upload-time = "2026-08-03T17:49:53.512Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b1/47967207659650ad74c1b06c42671e6beb4f7d798fe6eb2d53ba5e77ad90/faiss_cpu-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:90169515a95ea58a9a95d419e518907927a8ef54c46788396365ec5902c9c8df", size = 16247194, upload-time = "2026-08-03T17:49:56.38Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/021398ec5608314124b554bb025878a86f129bcf3576c293826352d9a783/faiss_cpu-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:5b940897b317febaa761088513a3db164fad3ac71a5e1ed7be9a052c9bf1a447", size = 16251530, upload-time = "2026-08-03T17:50:00.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/74/4a70395a6e07036628a1bd0b3f709101a6aecfa6a746db13b6e7921cf291/faiss_cpu-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:22dddb013e764aad66dac6cd15b49c7598d60339e0591b73b5e081629419c21b", size = 16251914, upload-time = "2026-08-03T17:50:03.293Z" }, + { url = "https://files.pythonhosted.org/packages/ec/13/0a021b9df16963f839a3f325657656b70f23b5a6dbeb422eaa187d0121b3/faiss_cpu-1.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:37170d5e9ead4b6bfd9c314afc39e17e92064068a0c5a4063dd3f39568c2667e", size = 16535739, upload-time = "2026-08-03T17:50:06.714Z" }, +] + [[package]] name = "fastapi" version = "0.140.13"