diff --git a/DashAI/back/converters/category/feature_engineering.py b/DashAI/back/converters/category/feature_engineering.py new file mode 100644 index 000000000..5dca3da00 --- /dev/null +++ b/DashAI/back/converters/category/feature_engineering.py @@ -0,0 +1,30 @@ +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 FeatureEngineeringConverter(BaseConverter): + """Base class for converters that derive new features from existing columns. + + Feature engineering converters compute new columns out of one or more + existing columns instead of modifying them in place. Examples include + ColumnArithmetic (arithmetic combinations of two numeric columns), + NumericExpansion (log1p, square, and square-root expansions of a numeric + column), and ColumnConcat (string concatenation of two text/categorical + columns). + + Use these converters to craft new signals for models when the raw + columns alone are not expressive enough. + """ + + CATEGORY = MultilingualString( + en="Feature Engineering", + es="Ingeniería de Características", + pt="Engenharia de Características", + de="Feature-Engineering", + zh="特征工程", + ) + ICON: Final[str] = Icon.Functions.value + COLOR: Final[str] = "rgb(0, 188, 212)" diff --git a/DashAI/back/converters/scikit_learn/simple_imputer.py b/DashAI/back/converters/scikit_learn/simple_imputer.py index 37071f65a..6a56d2ad9 100644 --- a/DashAI/back/converters/scikit_learn/simple_imputer.py +++ b/DashAI/back/converters/scikit_learn/simple_imputer.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING, Union + from sklearn.impute import SimpleImputer as SimpleImputerOperation from DashAI.back.converters.category.basic_preprocessing import ( @@ -20,6 +22,9 @@ from DashAI.back.types.dashai_data_type import DashAIDataType from DashAI.back.types.value_types import Float, Integer +if TYPE_CHECKING: + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + class SimpleImputerSchema(BaseSchema): """Schema for configuring the SimpleImputer converter. @@ -103,9 +108,15 @@ class SimpleImputer( Columns with all-missing values are handled according to the ``keep_empty_features`` flag. When ``add_indicator=True``, a - ``MissingIndicator`` binary matrix is stacked onto the output. All - output columns are typed as ``Float64`` in DashAI regardless of the - original column type. + ``MissingIndicator`` binary matrix is stacked onto the output. + + Output typing preserves the original column type whenever the strategy + does not force a fractional result: ``"most_frequent"`` and + ``"constant"`` never perform arithmetic, so the source type (Integer, + Float, or Categorical) is kept. For ``"mean"``/``"median"`` the computed + per-column statistic is inspected, and an originally-Integer column + stays ``Integer`` if that statistic happens to be a whole number (e.g. a + median over an odd count of integers); otherwise it becomes ``Float64``. Wraps ``sklearn.impute.SimpleImputer``. @@ -170,20 +181,73 @@ def __init__(self, **kwargs): """ super().__init__(**kwargs) + def fit( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "SimpleImputer": + """Fit the imputer, remembering input types and column order. + + These are needed by ``get_output_type`` to preserve the original + column type instead of always coercing to ``Float64``. + + Parameters + ---------- + x : DashAIDataset + The input dataset to fit the imputer on. + y : DashAIDataset, optional + Ignored; present for API consistency. + + Returns + ------- + SimpleImputer + The fitted imputer instance (self). + """ + if hasattr(x, "types") and x.types is not None: + self._input_types = dict(x.types) + self._input_columns = {name: idx for idx, name in enumerate(x.column_names)} + return super().fit(x, y) + def get_output_type(self, column_name: str = None) -> DashAIDataType: """Return the DashAI data type produced by this converter for a column. Parameters ---------- column_name : str, optional - Not used; all output columns share the - same type. Defaults to None. + The name of the output column. Defaults to None. Returns ------- DashAIDataType - A Float type backed by ``pyarrow.float64()``. + ``Integer`` for the binary ``MissingIndicator`` columns appended + when ``add_indicator=True``. Otherwise, the original column type + for ``"most_frequent"``/``"constant"`` (no arithmetic is performed + on the values), or for ``"mean"``/``"median"``, ``Integer`` if the + source column was an Integer and the computed statistic is a + whole number — otherwise a Float type backed by + ``pyarrow.float64()``. """ import pyarrow as pa + if column_name and str(column_name).startswith("missingindicator_"): + return Integer(arrow_type=pa.int64()) + + input_types = getattr(self, "_input_types", None) + input_type = input_types.get(column_name) if input_types else None + + if self.strategy in ("most_frequent", "constant"): + if input_type is not None: + return input_type + return Float(arrow_type=pa.float64()) + + if isinstance(input_type, Integer): + columns = getattr(self, "_input_columns", None) + statistics = getattr(self, "statistics_", None) + if ( + columns is not None + and statistics is not None + and column_name in columns + ): + value = statistics[columns[column_name]] + if float(value).is_integer(): + return Integer(arrow_type=pa.int64()) + return Float(arrow_type=pa.float64()) diff --git a/DashAI/back/converters/simple_converters/column_arithmetic.py b/DashAI/back/converters/simple_converters/column_arithmetic.py new file mode 100644 index 000000000..1396f9dc4 --- /dev/null +++ b/DashAI/back/converters/simple_converters/column_arithmetic.py @@ -0,0 +1,467 @@ +import math +from typing import TYPE_CHECKING, Union + +from DashAI.back.converters.base_converter import BaseConverter +from DashAI.back.converters.category.feature_engineering import ( + FeatureEngineeringConverter, +) +from DashAI.back.core.schema_fields import ( + bool_field, + enum_field, + float_field, + none_type, + schema_field, + string_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.types.dashai_data_type import DashAIDataType +from DashAI.back.types.value_types import Float, Integer + +if TYPE_CHECKING: + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + +OPERATIONS = ["add", "subtract", "multiply", "divide"] + + +class ColumnArithmeticSchema(BaseSchema): + """Schema for ColumnArithmetic hyperparameters.""" + + operation: schema_field( + enum_field(OPERATIONS), + "add", + description=MultilingualString( + en=( + "Arithmetic operation to apply between the selected columns " + "(or between the single selected column and 'constant')." + ), + es=( + "Operación aritmética a aplicar entre las columnas " + "seleccionadas (o entre la única columna seleccionada y " + "'constant')." + ), + pt=( + "Operação aritmética a aplicar entre as colunas selecionadas " + "(ou entre a única coluna selecionada e 'constant')." + ), + de=( + "Arithmetische Operation zwischen den ausgewählten Spalten " + "(oder zwischen der einzelnen ausgewählten Spalte und " + "'constant')." + ), + zh="在所选列之间(或在单个所选列与 'constant' 之间)应用的算术运算。", + ), + ) # type: ignore + constant: schema_field( + none_type(float_field()), + None, + description=MultilingualString( + en=( + "Fixed number used as the second operand. Only used (and " + "required) when a single column is selected." + ), + es=( + "Número fijo usado como segundo operando. Solo se usa (y es " + "requerido) cuando se selecciona una sola columna." + ), + pt=( + "Número fixo usado como segundo operando. Usado (e " + "necessário) apenas quando uma única coluna é selecionada." + ), + de=( + "Feste Zahl, die als zweiter Operand verwendet wird. Wird nur " + "verwendet (und benötigt), wenn eine einzelne Spalte " + "ausgewählt ist." + ), + zh="用作第二个操作数的固定数值。仅在选择单个列时使用(且必填)。", + ), + ) # type: ignore + swap_operands: schema_field( + bool_field(), + False, + description=MultilingualString( + en=( + "When two columns are selected, swap the operand order. By " + "default the operation is 'first column' OP 'second column' " + "(in dataset order); enable this to compute 'second' OP " + "'first' instead. Only affects subtract and divide." + ), + es=( + "Cuando se seleccionan dos columnas, invierte el orden de los " + "operandos. Por defecto la operación es 'primera columna' OP " + "'segunda columna' (en el orden del dataset); actívalo para " + "calcular 'segunda' OP 'primera'. Solo afecta a restar y " + "dividir." + ), + pt=( + "Quando duas colunas são selecionadas, inverte a ordem dos " + "operandos. Por padrão a operação é 'primeira coluna' OP " + "'segunda coluna' (na ordem do dataset); ative para calcular " + "'segunda' OP 'primeira'. Afeta apenas subtrair e dividir." + ), + de=( + "Wenn zwei Spalten ausgewählt sind, wird die Reihenfolge der " + "Operanden getauscht. Standardmäßig ist die Operation 'erste " + "Spalte' OP 'zweite Spalte' (in Datensatzreihenfolge); " + "aktivieren, um stattdessen 'zweite' OP 'erste' zu berechnen. " + "Betrifft nur Subtraktion und Division." + ), + zh="当选择两列时,交换操作数顺序。默认运算为'第一列' OP '第二列'" + "(按数据集顺序);启用此项则改为计算'第二列' OP '第一列'。" + "仅影响减法和除法。", + ), + ) # type: ignore + output_column_name: schema_field( + none_type(string_field()), + None, + description=MultilingualString( + en=( + "Name of the resulting column. If null, a name is generated " + "from the operands and the operation." + ), + es=( + "Nombre de la columna resultante. Si es nulo, se genera un " + "nombre a partir de los operandos y la operación." + ), + pt=( + "Nome da coluna resultante. Se nulo, um nome é gerado a " + "partir dos operandos e da operação." + ), + de=( + "Name der resultierenden Spalte. Wenn null, wird ein Name " + "aus den Operanden und der Operation generiert." + ), + zh="结果列的名称。如果为空,将根据操作数和运算生成名称。", + ), + ) # type: ignore + + +class ColumnArithmetic(FeatureEngineeringConverter, BaseConverter): + """Combine the selected columns into a new numeric column. + + Applies addition, subtraction, multiplication, or division element-wise + to the columns selected in scope. Select **two** columns to operate + between them, or **one** column to operate between it and a fixed + ``constant`` (e.g. ``column * 2``). The operands are taken from the + selection: with two columns the operation is + `` `` in dataset order, which can be + reversed with ``swap_operands`` (relevant for ``subtract`` and + ``divide``). Division by zero yields ``NaN`` instead of raising an + error. + + The original columns are left untouched; the result is appended as a new + column named ``output_column_name``, or, if not provided, + ``__`` or ``__``. + + The output column is ``Integer`` when both operands are ``Integer`` (a + whole-number ``constant`` counts as ``Integer``), the operation is + ``add``, ``subtract``, or ``multiply`` (all of which stay exact on + integers), and neither operand column has missing values (since a + missing value has no exact integer representation). ``divide`` always + produces a ``Float`` column, since integer division is not exact in + general, and any operation involving a ``Float`` operand, or an + operand column with missing values, also produces a ``Float`` column. + """ + + SCHEMA = ColumnArithmeticSchema + DESCRIPTION = MultilingualString( + en=( + "Applies an arithmetic operation (add, subtract, multiply, divide) " + "to the selected columns — two columns to operate between them, or " + "one column with a fixed constant (e.g. column * 2) — and appends " + "the result as a new column." + ), + es=( + "Aplica una operación aritmética (sumar, restar, multiplicar, " + "dividir) a las columnas seleccionadas — dos columnas para operar " + "entre ellas, o una columna con una constante fija (por ejemplo, " + "columna * 2) — y agrega el resultado como una nueva columna." + ), + pt=( + "Aplica uma operação aritmética (somar, subtrair, multiplicar, " + "dividir) às colunas selecionadas — duas colunas para operar " + "entre elas, ou uma coluna com uma constante fixa (por exemplo, " + "coluna * 2) — e adiciona o resultado como uma nova coluna." + ), + de=( + "Wendet eine arithmetische Operation (Addieren, Subtrahieren, " + "Multiplizieren, Dividieren) auf die ausgewählten Spalten an — " + "zwei Spalten, um zwischen ihnen zu rechnen, oder eine Spalte mit " + "einer festen Konstante (z. B. Spalte * 2) — und fügt das " + "Ergebnis als neue Spalte hinzu." + ), + zh=( + "对所选列应用算术运算(加、减、乘、除)——选择两列在其之间运算," + "或选择一列与固定常量运算(例如 列 * 2)——并将结果作为新列追加。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="Arithmetic combination of the selected columns (or a column and a " + "constant).", + es="Combinación aritmética de las columnas seleccionadas (o una " + "columna y una constante).", + pt="Combinação aritmética das colunas selecionadas (ou uma coluna e " + "uma constante).", + de="Arithmetische Kombination der ausgewählten Spalten (oder einer " + "Spalte und einer Konstante).", + zh="对所选列进行算术组合(或一列与一个常量)。", + ) + DISPLAY_NAME = MultilingualString( + en="Column Arithmetic", + es="Aritmética de Columnas", + pt="Aritmética de Colunas", + de="Spalten-Arithmetik", + zh="列算术运算", + ) + IMAGE_PREVIEW = "column_arithmetic.png" + + metadata = { + "allowed_types": [Float, Integer], + "allowed_dtypes": [], + "input_cardinality": {"min": 1, "max": 2}, + } + + def __init__( + self, + operation: str, + constant: Union[float, None] = None, + swap_operands: bool = False, + output_column_name: Union[str, None] = None, + ): + """Initialise the converter with the operation and options. + + The operand columns are not passed here: they are derived from the + columns selected in scope during :meth:`fit` (one or two columns). + + Parameters + ---------- + operation : str + One of ``"add"``, ``"subtract"``, ``"multiply"``, ``"divide"``. + constant : float, optional + Fixed number used as the second operand when a single column is + selected. Ignored when two columns are selected. + swap_operands : bool, optional + When two columns are selected, swap the operand order (compute + ``second first`` instead of ``first second``). Only + relevant for ``subtract`` and ``divide``. Defaults to False. + output_column_name : str, optional + Name of the resulting column. If ``None`` or not a string, a name + is generated from the operands and the operation. + + Raises + ------ + ValueError + If ``operation`` is not one of the supported values. + """ + super().__init__() + if operation not in OPERATIONS: + raise ValueError( + f"'operation' must be one of {OPERATIONS}, got '{operation}'." + ) + + self.operation = operation + self.constant = constant + self.swap_operands = bool(swap_operands) + self.output_column_name = ( + output_column_name if isinstance(output_column_name, str) else None + ) + # Derived during fit() from the columns selected in scope. + self.operand_b_mode: Union[str, None] = None + self.column_a: Union[str, None] = None + self.column_b: Union[str, None] = None + self._result_column_name: Union[str, None] = None + self._output_is_integer: bool = False + + @staticmethod + def _format_constant(value: float) -> str: + """Render a constant for use in an auto-generated column name.""" + return str(int(value)) if value == int(value) else str(value) + + def fit( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "ColumnArithmetic": + """Derive the operands from scope and validate them. + + The operand columns come from the columns selected in scope: two + columns operate between them (``column_a`` and ``column_b`` in + dataset order, optionally swapped via ``swap_operands``), one column + operates against ``constant``. + + Parameters + ---------- + x : DashAIDataset + The scoped dataset, expected to contain one or two columns. + y : DashAIDataset, optional + Ignored. Defaults to None. + + Returns + ------- + ColumnArithmetic + The fitted converter instance (self). + + Raises + ------ + ValueError + If the number of selected columns is not one or two, if a + selected column is not numeric (Float or Integer), or if a + single column is selected without a valid ``constant``. + """ + columns = list(x.column_names) + if len(columns) not in (1, 2): + raise ValueError( + "ColumnArithmetic requires selecting one or two columns in " + f"scope, but {len(columns)} were selected." + ) + + for col in columns: + if not isinstance(x.types.get(col), (Float, Integer)): + raise ValueError( + f"Column '{col}' must be numeric (Float or Integer) to be " + "used in ColumnArithmetic." + ) + + if len(columns) == 2: + self.operand_b_mode = "column" + first, second = columns + if self.swap_operands: + first, second = second, first + self.column_a = first + self.column_b = second + operand_b_label = self.column_b + operand_b_is_integer = isinstance(x.types.get(self.column_b), Integer) + has_missing_values = ( + x.arrow_table[self.column_a].null_count > 0 + or x.arrow_table[self.column_b].null_count > 0 + ) + else: + if not isinstance(self.constant, (int, float)) or isinstance( + self.constant, bool + ): + raise ValueError( + "'constant' must be a number when a single column is " + "selected in scope." + ) + if not math.isfinite(self.constant): + raise ValueError( + "'constant' must be a finite number (not NaN or " + "infinite) when a single column is selected in scope." + ) + self.operand_b_mode = "constant" + self.constant = float(self.constant) + self.column_a = columns[0] + self.column_b = None + operand_b_label = self._format_constant(self.constant) + operand_b_is_integer = self.constant == int(self.constant) + has_missing_values = x.arrow_table[self.column_a].null_count > 0 + + self._result_column_name = self.output_column_name or ( + f"{self.column_a}_{self.operation}_{operand_b_label}" + ) + self._output_is_integer = ( + self.operation != "divide" + and isinstance(x.types.get(self.column_a), Integer) + and operand_b_is_integer + and not has_missing_values + ) + return self + + def _get_operand_b(self, x_pandas, dtype: str): + """Return the second operand as an array aligned with ``x_pandas``. + + Either the values of ``column_b``, or ``constant`` broadcast to the + same length, depending on ``operand_b_mode``. + """ + import numpy as np + + if self.operand_b_mode == "column": + return x_pandas[self.column_b].to_numpy(dtype=dtype) + return np.full(len(x_pandas), self.constant, dtype=dtype) + + def transform( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "DashAIDataset": + """Compute the arithmetic result and append it as a new column. + + Parameters + ---------- + x : DashAIDataset + The dataset containing the operand column(s) derived during + ``fit``. + y : DashAIDataset, optional + Ignored. Defaults to None. + + Returns + ------- + DashAIDataset + The original dataset with the arithmetic result appended as a + new column, typed ``Integer`` or ``Float`` depending on the + operands and the operation (see class docstring). + """ + import numpy as np + import pyarrow as pa + + from DashAI.back.dataloaders.classes.dashai_dataset import modify_table + + x_pandas = x.to_pandas() + + if self._output_is_integer: + a = x_pandas[self.column_a].to_numpy(dtype="int64") + b = self._get_operand_b(x_pandas, "int64") + + if self.operation == "add": + result = a + b + elif self.operation == "subtract": + result = a - b + else: # multiply + result = a * b + + arrow_type = pa.int64() + else: + a = x_pandas[self.column_a].to_numpy(dtype="float64") + b = self._get_operand_b(x_pandas, "float64") + + with np.errstate(divide="ignore", invalid="ignore"): + if self.operation == "add": + result = a + b + elif self.operation == "subtract": + result = a - b + elif self.operation == "multiply": + result = a * b + else: # divide + result = np.where(b != 0, a / b, np.nan) + + arrow_type = pa.float64() + + new_types = dict(x.types) + new_types[self._result_column_name] = self.get_output_type() + + return modify_table( + x, + {self._result_column_name: pa.array(result, type=arrow_type)}, + types=new_types, + ) + + def get_output_type(self, column_name: str = None) -> DashAIDataType: + """Return the output type for the arithmetic result. + + Determined during ``fit``: ``Integer`` when both operands are + ``Integer`` (a whole-number ``constant`` counts as ``Integer``) and + the operation isn't ``divide``, ``Float`` otherwise. + + Parameters + ---------- + column_name : str, optional + Not used; the result column always has the same type. + Defaults to None. + + Returns + ------- + DashAIDataType + An ``Integer`` type backed by ``pyarrow.int64()``, or a + ``Float`` type backed by ``pyarrow.float64()``. + """ + import pyarrow as pa + + if self._output_is_integer: + return Integer(arrow_type=pa.int64()) + return Float(arrow_type=pa.float64()) diff --git a/DashAI/back/converters/simple_converters/column_concat.py b/DashAI/back/converters/simple_converters/column_concat.py new file mode 100644 index 000000000..9027a685b --- /dev/null +++ b/DashAI/back/converters/simple_converters/column_concat.py @@ -0,0 +1,371 @@ +from typing import TYPE_CHECKING, Union + +from DashAI.back.converters.base_converter import BaseConverter +from DashAI.back.converters.category.feature_engineering import ( + FeatureEngineeringConverter, +) +from DashAI.back.core.schema_fields import ( + bool_field, + none_type, + schema_field, + string_field, +) +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.types.categorical import Categorical +from DashAI.back.types.dashai_data_type import DashAIDataType +from DashAI.back.types.value_types import Text + +if TYPE_CHECKING: + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class ColumnConcatSchema(BaseSchema): + """Schema for ColumnConcat hyperparameters.""" + + constant: schema_field( + none_type(string_field()), + None, + description=MultilingualString( + en=( + "Fixed string used as the second operand. Only used (and " + "required) when a single column is selected." + ), + es=( + "String fijo usado como segundo operando. Solo se usa (y es " + "requerido) cuando se selecciona una sola columna." + ), + pt=( + "String fixa usada como segundo operando. Usada (e " + "necessária) apenas quando uma única coluna é selecionada." + ), + de=( + "Feste Zeichenkette, die als zweiter Operand verwendet wird. " + "Wird nur verwendet (und benötigt), wenn eine einzelne Spalte " + "ausgewählt ist." + ), + zh="用作第二个操作数的固定字符串。仅在选择单个列时使用(且必填)。", + ), + ) # type: ignore + separator: schema_field( + none_type(string_field()), + None, + description=MultilingualString( + en=( + "Optional string inserted between the two operands. If null, " + "they are joined directly with no separator." + ), + es=( + "String opcional insertado entre los dos operandos. Si es " + "nulo, se unen directamente sin separador." + ), + pt=( + "String opcional inserida entre os dois operandos. Se nula, " + "são unidos diretamente sem separador." + ), + de=( + "Optionale Zeichenkette, die zwischen die beiden Operanden " + "eingefügt wird. Wenn null, werden sie direkt ohne " + "Trennzeichen verbunden." + ), + zh="插入在两个操作数之间的可选字符串。如果为空,则直接连接,无分隔符。", + ), + ) # type: ignore + swap_operands: schema_field( + bool_field(), + False, + description=MultilingualString( + en=( + "When two columns are selected, swap the concatenation order. " + "By default it is 'first column' + 'second column' (in " + "dataset order); enable this to concatenate 'second' + " + "'first' instead." + ), + es=( + "Cuando se seleccionan dos columnas, invierte el orden de " + "concatenación. Por defecto es 'primera columna' + 'segunda " + "columna' (en el orden del dataset); actívalo para concatenar " + "'segunda' + 'primera'." + ), + pt=( + "Quando duas colunas são selecionadas, inverte a ordem de " + "concatenação. Por padrão é 'primeira coluna' + 'segunda " + "coluna' (na ordem do dataset); ative para concatenar " + "'segunda' + 'primeira'." + ), + de=( + "Wenn zwei Spalten ausgewählt sind, wird die " + "Verkettungsreihenfolge getauscht. Standardmäßig ist es " + "'erste Spalte' + 'zweite Spalte' (in Datensatzreihenfolge); " + "aktivieren, um stattdessen 'zweite' + 'erste' zu verketten." + ), + zh="当选择两列时,交换连接顺序。默认为'第一列' + '第二列'" + "(按数据集顺序);启用此项则改为连接'第二列' + '第一列'。", + ), + ) # type: ignore + output_column_name: schema_field( + none_type(string_field()), + None, + description=MultilingualString( + en=( + "Name of the resulting column. If null, a name is generated " + "from the operands." + ), + es=( + "Nombre de la columna resultante. Si es nulo, se genera un " + "nombre a partir de los operandos." + ), + pt=( + "Nome da coluna resultante. Se nulo, um nome é gerado a " + "partir dos operandos." + ), + de=( + "Name der resultierenden Spalte. Wenn null, wird ein Name " + "aus den Operanden generiert." + ), + zh="结果列的名称。如果为空,将根据操作数生成名称。", + ), + ) # type: ignore + + +class ColumnConcat(FeatureEngineeringConverter, BaseConverter): + """Concatenate the selected string columns into a new column. + + Joins the columns selected in scope element-wise. Select **two** columns + to concatenate them together, or **one** column to concatenate it with a + fixed ``constant`` string. With two columns the order is + `` + `` in dataset order, which can be + reversed with ``swap_operands``. An optional ``separator`` is inserted + between the operands (none by default, e.g. ``"foo"`` + ``"bar"`` -> + ``"foobar"``). All selected columns must be ``Text`` or ``Categorical``. + If either operand is missing (``None``) for a row, the result for that + row is ``None``. + + The original columns are left untouched; the result is appended as a + new ``Text`` column named ``output_column_name``, or, if not provided, + ``_concat_`` or ``_concat_``. + """ + + SCHEMA = ColumnConcatSchema + DESCRIPTION = MultilingualString( + en=( + "Concatenates the selected string columns — two columns to join " + "them, or one column with a fixed string — with an optional " + "separator, and appends the result as a new column." + ), + es=( + "Concatena las columnas de texto seleccionadas — dos columnas " + "para unirlas, o una columna con un string fijo — con un " + "separador opcional, y agrega el resultado como una nueva " + "columna." + ), + pt=( + "Concatena as colunas de texto selecionadas — duas colunas para " + "uni-las, ou uma coluna com uma string fixa — com um separador " + "opcional, e adiciona o resultado como uma nova coluna." + ), + de=( + "Verkettet die ausgewählten Textspalten — zwei Spalten, um sie " + "zu verbinden, oder eine Spalte mit einer festen Zeichenkette — " + "mit einem optionalen Trennzeichen und fügt das Ergebnis als " + "neue Spalte hinzu." + ), + zh="连接所选的文本列——两列将其合并,或一列与固定字符串——带可选分隔符," + "并将结果作为新列追加。", + ) + SHORT_DESCRIPTION = MultilingualString( + en="Concatenates the selected string columns (or a column and a constant).", + es="Concatena las columnas de texto seleccionadas (o una columna y " + "una constante).", + pt="Concatena as colunas de texto selecionadas (ou uma coluna e uma " + "constante).", + de="Verkettet die ausgewählten Textspalten (oder eine Spalte und " + "eine Konstante).", + zh="连接所选的文本列(或一列与一个常量)。", + ) + DISPLAY_NAME = MultilingualString( + en="Column Concat", + es="Concatenación de Columnas", + pt="Concatenação de Colunas", + de="Spalten-Verkettung", + zh="列拼接", + ) + IMAGE_PREVIEW = "column_concat.png" + + metadata = { + "allowed_types": [Text, Categorical], + "allowed_dtypes": [], + "input_cardinality": {"min": 1, "max": 2}, + } + + def __init__( + self, + constant: Union[str, None] = None, + separator: Union[str, None] = None, + swap_operands: bool = False, + output_column_name: Union[str, None] = None, + ): + """Initialise the converter with the concatenation options. + + The operand columns are not passed here: they are derived from the + columns selected in scope during :meth:`fit` (one or two columns). + + Parameters + ---------- + constant : str, optional + Fixed string used as the second operand when a single column is + selected. Ignored when two columns are selected. + separator : str, optional + String inserted between the two operands. Defaults to None (no + separator). + swap_operands : bool, optional + When two columns are selected, swap the concatenation order + (``second + first`` instead of ``first + second``). Defaults to + False. + output_column_name : str, optional + Name of the resulting column. If ``None`` or not a string, a name + is generated from the operands. + """ + super().__init__() + self.constant = constant + self.separator = separator if isinstance(separator, str) else "" + self.swap_operands = bool(swap_operands) + self.output_column_name = ( + output_column_name if isinstance(output_column_name, str) else None + ) + # Derived during fit() from the columns selected in scope. + self.operand_b_mode: Union[str, None] = None + self.column_a: Union[str, None] = None + self.column_b: Union[str, None] = None + self._result_column_name: Union[str, None] = None + + def fit( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "ColumnConcat": + """Derive the operands from scope and validate them. + + The operand columns come from the columns selected in scope: two + columns concatenate together (``column_a`` and ``column_b`` in + dataset order, optionally swapped via ``swap_operands``), one column + concatenates with ``constant``. + + Parameters + ---------- + x : DashAIDataset + The scoped dataset, expected to contain one or two columns. + y : DashAIDataset, optional + Ignored. Defaults to None. + + Returns + ------- + ColumnConcat + The fitted converter instance (self). + + Raises + ------ + ValueError + If the number of selected columns is not one or two, if a + selected column is not string-like (Text or Categorical), or if + a single column is selected without a valid ``constant``. + """ + columns = list(x.column_names) + if len(columns) not in (1, 2): + raise ValueError( + "ColumnConcat requires selecting one or two columns in " + f"scope, but {len(columns)} were selected." + ) + + for col in columns: + if not isinstance(x.types.get(col), (Text, Categorical)): + raise ValueError( + f"Column '{col}' must be a string (Text or Categorical) " + "to be used in ColumnConcat." + ) + + if len(columns) == 2: + self.operand_b_mode = "column" + first, second = columns + if self.swap_operands: + first, second = second, first + self.column_a = first + self.column_b = second + operand_b_label = self.column_b + else: + if not isinstance(self.constant, str): + raise ValueError( + "'constant' must be a string when a single column is " + "selected in scope." + ) + self.operand_b_mode = "constant" + self.column_a = columns[0] + self.column_b = None + operand_b_label = self.constant + + self._result_column_name = self.output_column_name or ( + f"{self.column_a}_concat_{operand_b_label}" + ) + return self + + def transform( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "DashAIDataset": + """Concatenate the operands and append the result as a new column. + + Parameters + ---------- + x : DashAIDataset + The dataset containing the operand column(s) derived during + ``fit``. + y : DashAIDataset, optional + Ignored. Defaults to None. + + Returns + ------- + DashAIDataset + The original dataset with the concatenated result appended as a + new ``Text`` column. + """ + import pyarrow as pa + + from DashAI.back.dataloaders.classes.dashai_dataset import modify_table + + x_pandas = x.to_pandas() + + a = x_pandas[self.column_a].tolist() + if self.operand_b_mode == "column": + b = x_pandas[self.column_b].tolist() + else: + b = [self.constant] * len(x_pandas) + + sep = self.separator + result = [ + None if av is None or bv is None else f"{av}{sep}{bv}" + for av, bv in zip(a, b, strict=True) + ] + + new_types = dict(x.types) + new_types[self._result_column_name] = self.get_output_type() + + return modify_table( + x, + {self._result_column_name: pa.array(result, type=pa.string())}, + types=new_types, + ) + + def get_output_type(self, column_name: str = None) -> DashAIDataType: + """Return the output type for the concatenation result. + + Parameters + ---------- + column_name : str, optional + Not used; the result column always has the same type. + Defaults to None. + + Returns + ------- + DashAIDataType + A ``Text`` type backed by ``pyarrow.string()``. + """ + import pyarrow as pa + + return Text(arrow_type=pa.string()) diff --git a/DashAI/back/converters/simple_converters/numeric_expansion.py b/DashAI/back/converters/simple_converters/numeric_expansion.py new file mode 100644 index 000000000..4db709263 --- /dev/null +++ b/DashAI/back/converters/simple_converters/numeric_expansion.py @@ -0,0 +1,269 @@ +from typing import TYPE_CHECKING, Dict, List, Union + +from DashAI.back.converters.base_converter import BaseConverter +from DashAI.back.converters.category.feature_engineering import ( + FeatureEngineeringConverter, +) +from DashAI.back.core.schema_fields import enum_field, schema_field +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.types.dashai_data_type import DashAIDataType +from DashAI.back.types.value_types import Float, Integer + +if TYPE_CHECKING: + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + +OPERATIONS = ["log1p", "square", "sqrt"] + + +class NumericExpansionSchema(BaseSchema): + """Schema for NumericExpansion hyperparameters.""" + + operation: schema_field( + enum_field(OPERATIONS), + "log1p", + description=MultilingualString( + en=( + "Unary numeric expansion to apply to each selected column: " + "'log1p' (ln(1+x)), 'square' (x^2), or 'sqrt' (sqrt(x))." + ), + es=( + "Expansión numérica unaria a aplicar a cada columna " + "seleccionada: 'log1p' (ln(1+x)), 'square' (x^2) o " + "'sqrt' (raíz cuadrada de x)." + ), + pt=( + "Expansão numérica unária a aplicar a cada coluna " + "selecionada: 'log1p' (ln(1+x)), 'square' (x^2) ou " + "'sqrt' (raiz quadrada de x)." + ), + de=( + "Unäre numerische Erweiterung, die auf jede ausgewählte " + "Spalte angewendet wird: 'log1p' (ln(1+x)), 'square' (x^2) " + "oder 'sqrt' (Quadratwurzel von x)." + ), + zh="应用于每个所选列的一元数值扩展:" + "'log1p'(ln(1+x))、'square'(x^2)或 'sqrt'(x 的平方根)。", + ), + ) # type: ignore + + +class NumericExpansion(FeatureEngineeringConverter, BaseConverter): + """Derive a new numeric feature from each selected column via a unary function. + + Applies one of ``log1p`` (``ln(1+x)``), ``square`` (``x^2``), or ``sqrt`` + (``sqrt(x)``) to every numeric column in scope, appending one new column + per input column named ``_``. Values outside the + domain of the chosen function (``x <= -1`` for ``log1p``, ``x < 0`` for + ``sqrt``) become ``NaN`` in the corresponding output. + + The original columns are left untouched. ``square`` preserves the input + column's type (``Integer`` stays ``Integer``, ``Float`` stays ``Float``) + when the source column has no missing values, since squaring is exact + for both. ``log1p`` and ``sqrt`` always produce a ``Float`` column, since + they can yield non-integer or ``NaN`` results even from integer input, + and ``square`` also falls back to ``Float`` when the source ``Integer`` + column has missing values (since a missing value has no exact integer + representation). + """ + + SCHEMA = NumericExpansionSchema + DESCRIPTION = MultilingualString( + en=( + "Applies a unary numeric expansion (log1p, square, or sqrt) to " + "each selected column and appends the result as a new column." + ), + es=( + "Aplica una expansión numérica unaria (log1p, square o sqrt) a " + "cada columna seleccionada y agrega el resultado como una nueva " + "columna." + ), + pt=( + "Aplica uma expansão numérica unária (log1p, square ou sqrt) a " + "cada coluna selecionada e adiciona o resultado como uma nova " + "coluna." + ), + de=( + "Wendet eine unäre numerische Erweiterung (log1p, square oder " + "sqrt) auf jede ausgewählte Spalte an und fügt das Ergebnis als " + "neue Spalte hinzu." + ), + zh=( + "对每个所选列应用一元数值扩展(log1p、square 或 sqrt)," + "并将结果作为新列追加。" + ), + ) + SHORT_DESCRIPTION = MultilingualString( + en="Unary numeric expansion (log1p, square, sqrt) of a column.", + es="Expansión numérica unaria (log1p, square, sqrt) de una columna.", + pt="Expansão numérica unária (log1p, square, sqrt) de uma coluna.", + de="Unäre numerische Erweiterung (log1p, square, sqrt) einer Spalte.", + zh="列的一元数值扩展(log1p、square、sqrt)。", + ) + DISPLAY_NAME = MultilingualString( + en="Numeric Expansion", + es="Expansión Numérica", + pt="Expansão Numérica", + de="Numerische Erweiterung", + zh="数值扩展", + ) + IMAGE_PREVIEW = "numeric_expansion.png" + + metadata = { + "allowed_types": [Float, Integer], + "allowed_dtypes": [], + } + + def __init__(self, operation: str): + """Initialise the converter with the unary operation to apply. + + Parameters + ---------- + operation : str + One of ``"log1p"``, ``"square"``, ``"sqrt"``. + + Raises + ------ + ValueError + If ``operation`` is not one of the supported operations. + """ + super().__init__() + if operation not in OPERATIONS: + raise ValueError( + f"'operation' must be one of {OPERATIONS}, got '{operation}'." + ) + self.operation = operation + self._target_columns: List[str] = [] + self._output_types: Dict[str, DashAIDataType] = {} + + def fit( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "NumericExpansion": + """Identify which columns in ``x`` are numeric (Float or Integer). + + Also precomputes the output type of each resulting column: ``square`` + keeps the input column's type, while ``log1p`` and ``sqrt`` always + produce ``Float``. + + Parameters + ---------- + x : DashAIDataset + The dataset whose columns will be inspected. + y : DashAIDataset, optional + Ignored. Defaults to None. + + Returns + ------- + NumericExpansion + The fitted converter instance (self). + """ + import pyarrow as pa + + self._target_columns = [] + self._output_types = {} + for col_name in x.column_names: + col_type = x.types.get(col_name) + if isinstance(col_type, (Float, Integer)): + self._target_columns.append(col_name) + new_col_name = f"{self.operation}_{col_name}" + has_missing_values = x.arrow_table[col_name].null_count > 0 + if ( + self.operation == "square" + and isinstance(col_type, Integer) + and not has_missing_values + ): + self._output_types[new_col_name] = Integer(arrow_type=pa.int64()) + else: + self._output_types[new_col_name] = Float(arrow_type=pa.float64()) + else: + print( + f"Warning: Column '{col_name}' in scope is not numeric " + "(Float or Integer) and will be ignored by NumericExpansion." + ) + if not self._target_columns: + print( + "Warning: NumericExpansion did not find any valid numeric " + "columns in the provided scope." + ) + return self + + def transform( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "DashAIDataset": + """Apply the configured unary expansion to the fitted numeric columns. + + Parameters + ---------- + x : DashAIDataset + The dataset to transform. + y : DashAIDataset, optional + Ignored. Defaults to None. + + Returns + ------- + DashAIDataset + The dataset with one new ``_`` column appended + per fitted numeric column, typed ``Integer`` or ``Float`` + depending on the source column and the operation (see class + docstring). + """ + import numpy as np + import pyarrow as pa + + from DashAI.back.dataloaders.classes.dashai_dataset import modify_table + + if not self._target_columns: + return x + + x_pandas = x.to_pandas() + new_columns = {} + new_types = dict(x.types) + + with np.errstate(divide="ignore", invalid="ignore"): + for col in self._target_columns: + new_col_name = f"{self.operation}_{col}" + output_type = self._output_types[new_col_name] + + if isinstance(output_type, Integer): + values = x_pandas[col].to_numpy(dtype="int64") + result = values**2 + arrow_type = pa.int64() + else: + values = x_pandas[col].to_numpy(dtype="float64") + if self.operation == "log1p": + result = np.where(values > -1, np.log1p(values), np.nan) + elif self.operation == "square": + result = values**2 + else: # sqrt + result = np.where(values >= 0, np.sqrt(values), np.nan) + arrow_type = pa.float64() + + new_columns[new_col_name] = pa.array(result, type=arrow_type) + new_types[new_col_name] = output_type + + return modify_table(x, new_columns, types=new_types) + + def get_output_type(self, column_name: str = None) -> DashAIDataType: + """Return the output type for a given expanded column. + + Determined during ``fit``: ``Integer`` when the operation is + ``square`` and the source column is ``Integer``, ``Float`` + otherwise. + + Parameters + ---------- + column_name : str, optional + Name of the output column (e.g. ``"square_age"``). Defaults to + None. + + Returns + ------- + DashAIDataType + An ``Integer`` type backed by ``pyarrow.int64()``, or a + ``Float`` type backed by ``pyarrow.float64()``. + """ + import pyarrow as pa + + if column_name in self._output_types: + return self._output_types[column_name] + return Float(arrow_type=pa.float64()) diff --git a/DashAI/back/converters/simple_converters/type_cast.py b/DashAI/back/converters/simple_converters/type_cast.py new file mode 100644 index 000000000..38c5877f0 --- /dev/null +++ b/DashAI/back/converters/simple_converters/type_cast.py @@ -0,0 +1,381 @@ +from typing import TYPE_CHECKING, Union + +from DashAI.back.converters.base_converter import BaseConverter +from DashAI.back.converters.category.basic_preprocessing import ( + BasicPreprocessingConverter, +) +from DashAI.back.core.schema_fields import enum_field, schema_field +from DashAI.back.core.schema_fields.base_schema import BaseSchema +from DashAI.back.core.utils import MultilingualString +from DashAI.back.types.categorical import Categorical +from DashAI.back.types.dashai_data_type import DashAIDataType +from DashAI.back.types.value_types import Float, Integer, Text + +if TYPE_CHECKING: + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + +NEW_TYPES = ["Integer", "Float", "Text", "Categorical"] +ON_ERROR_MODES = ["raise", "skip"] + + +class TypeCastSchema(BaseSchema): + """Schema for TypeCast hyperparameters.""" + + new_type: schema_field( + enum_field(NEW_TYPES), + "Text", + description=MultilingualString( + en="Target type to cast the columns in scope to.", + es="Tipo objetivo al que se convertirán las columnas del alcance.", + pt="Tipo de destino para o qual as colunas do escopo serão convertidas.", + de="Zieltyp, in den die Spalten im Geltungsbereich umgewandelt werden.", + zh="要将范围内的列转换为的目标类型。", + ), + ) # type: ignore + on_error: schema_field( + enum_field(ON_ERROR_MODES), + "raise", + description=MultilingualString( + en=( + "What to do when a column cannot be safely converted: 'raise' " + "to stop with a descriptive error, or 'skip' to leave that " + "column unchanged and continue with the rest." + ), + es=( + "Qué hacer cuando una columna no puede convertirse de forma " + "segura: 'raise' para detenerse con un error descriptivo, o " + "'skip' para dejar esa columna sin cambios y continuar con " + "el resto." + ), + pt=( + "O que fazer quando uma coluna não pode ser convertida com " + "segurança: 'raise' para parar com um erro descritivo, ou " + "'skip' para deixar essa coluna inalterada e continuar com " + "o restante." + ), + de=( + "Was zu tun ist, wenn eine Spalte nicht sicher konvertiert " + "werden kann: 'raise', um mit einer aussagekräftigen " + "Fehlermeldung abzubrechen, oder 'skip', um diese Spalte " + "unverändert zu lassen und mit dem Rest fortzufahren." + ), + zh="当某列无法安全转换时的处理方式:'raise' 以详细错误信息终止," + "或 'skip' 保持该列不变并继续处理其余列。", + ), + ) # type: ignore + + +class TypeCast(BasicPreprocessingConverter, BaseConverter): + """Change the DashAI type of the columns selected in scope. + + Casts every column in scope to ``new_type`` (one of ``"Integer"``, + ``"Float"``, ``"Text"``, or ``"Categorical"``), reusing the exact same + validation and conversion rules used when changing column types from + the dataset upload preview screen (see + ``DashAI.back.types.type_validation.validate_type_change``, also used by + the ``/datasets/validate_type_changes`` endpoint). This keeps behaviour + consistent between the upload preview and pipeline-time type changes. + + Columns already of ``new_type`` are left untouched. If a column's + values cannot be safely converted (e.g. a ``Text`` column with + non-numeric values targeting ``Integer``, or a ``Float`` column with + decimal values targeting ``Integer``), the behaviour is controlled by + ``on_error``: ``"raise"`` (default) stops with a descriptive, + column-specific error message, while ``"skip"`` leaves that column + unchanged, prints a warning, and continues with the rest. + """ + + SCHEMA = TypeCastSchema + DESCRIPTION = MultilingualString( + en=( + "Changes the type of the selected columns (Integer, Float, Text, " + "or Categorical), using the same validation used in the dataset " + "upload preview. Columns that cannot be safely converted are " + "either reported as an error or skipped, depending on 'on_error'." + ), + es=( + "Cambia el tipo de las columnas seleccionadas (Integer, Float, " + "Text o Categorical), usando la misma validación empleada en la " + "vista previa de carga del dataset. Las columnas que no pueden " + "convertirse de forma segura se reportan como error o se omiten, " + "según 'on_error'." + ), + pt=( + "Altera o tipo das colunas selecionadas (Integer, Float, Text ou " + "Categorical), usando a mesma validação empregada na pré-" + "visualização de upload do dataset. Colunas que não podem ser " + "convertidas com segurança são reportadas como erro ou " + "ignoradas, dependendo de 'on_error'." + ), + de=( + "Ändert den Typ der ausgewählten Spalten (Integer, Float, Text " + "oder Categorical) und verwendet dabei dieselbe Validierung wie " + "in der Upload-Vorschau des Datensatzes. Spalten, die nicht " + "sicher konvertiert werden können, werden je nach 'on_error' " + "entweder als Fehler gemeldet oder übersprungen." + ), + zh="使用与数据集上传预览相同的验证规则,更改所选列的类型(Integer、" + "Float、Text 或 Categorical)。无法安全转换的列将根据 'on_error' " + "报告为错误或被跳过。", + ) + SHORT_DESCRIPTION = MultilingualString( + en="Changes the type of the columns in scope.", + es="Cambia el tipo de las columnas del alcance.", + pt="Altera o tipo das colunas do escopo.", + de="Ändert den Typ der Spalten im Geltungsbereich.", + zh="更改范围内列的类型。", + ) + DISPLAY_NAME = MultilingualString( + en="Type Cast", + es="Cambio de Tipo", + pt="Conversão de Tipo", + de="Typumwandlung", + zh="类型转换", + ) + IMAGE_PREVIEW = "type_cast.png" + + metadata = { + "allowed_types": [Integer, Float, Text, Categorical], + "allowed_dtypes": [], + } + + def __init__(self, new_type: str, on_error: str = "raise"): + """Initialise the converter with the target type and error behaviour. + + Parameters + ---------- + new_type : str + One of ``"Integer"``, ``"Float"``, ``"Text"``, ``"Categorical"``. + on_error : str, optional + Either ``"raise"`` (default), to stop on the first column that + cannot be converted, or ``"skip"``, to leave it unchanged and + continue with the rest. + + Raises + ------ + ValueError + If ``new_type`` or ``on_error`` is not one of the supported + values. + """ + super().__init__() + if new_type not in NEW_TYPES: + raise ValueError( + f"'new_type' must be one of {NEW_TYPES}, got '{new_type}'." + ) + if on_error not in ON_ERROR_MODES: + raise ValueError( + f"'on_error' must be one of {ON_ERROR_MODES}, got '{on_error}'." + ) + + self.new_type = new_type + self.on_error = on_error + self._target_columns: list = [] + self._current_types: dict = {} + self._skip_columns: set = set() + # Cache of already-converted columns from fit(), reused in transform() + # when it is called with the same dataset (the common case, since + # ConverterJob only re-fetches a fresh scope when a row scope is set). + self._fit_x = None + self._converted_cache: dict = {} + + def fit( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "TypeCast": + """Validate that every column in scope can be cast to ``new_type``. + + Parameters + ---------- + x : DashAIDataset + The scoped dataset whose columns will be cast. + y : DashAIDataset, optional + Ignored. Defaults to None. + + Returns + ------- + TypeCast + The fitted converter instance (self). + + Raises + ------ + ValueError + If ``on_error`` is ``"raise"`` and a column's values cannot be + safely converted to ``new_type``. + """ + from DashAI.back.types.type_validation import validate_type_change + + self._target_columns = list(x.column_names) + self._current_types = {} + self._skip_columns = set() + self._converted_cache = {} + self._fit_x = x + + if not self._target_columns: + return self + + x_pandas = x.to_pandas() + for col in self._target_columns: + current_type_obj = x.types.get(col) + if current_type_obj is None: + print( + f"Warning: column '{col}' has no known DashAI type and " + "will be left unchanged by TypeCast." + ) + self._skip_columns.add(col) + continue + + current_type_str = current_type_obj.to_string().get("type") + self._current_types[col] = current_type_str + + if current_type_str == self.new_type: + continue + + is_valid, message, converted = validate_type_change( + x_pandas[col], current_type_str, self.new_type + ) + if not is_valid: + full_message = ( + f"Column '{col}' cannot be converted from " + f"'{current_type_str}' to '{self.new_type}': {message}" + ) + if self.on_error == "raise": + raise ValueError(full_message) + print(f"Warning: {full_message} The column will be left unchanged.") + self._skip_columns.add(col) + continue + + if message: + print(f"Warning: column '{col}': {message}") + self._converted_cache[col] = converted + + return self + + def _arrow_type(self): + """Return the PyArrow type backing ``new_type``.""" + import pyarrow as pa + + return { + "Integer": pa.int64(), + "Float": pa.float64(), + "Text": pa.string(), + "Categorical": pa.string(), + }[self.new_type] + + def _cast_values(self, values: list) -> list: + """Cast a list of non-null-normalised Python values to ``new_type``.""" + if self.new_type == "Integer": + return [None if v is None else int(v) for v in values] + if self.new_type == "Float": + return [None if v is None else float(v) for v in values] + return [None if v is None else str(v) for v in values] + + def _build_output_type(self, values: list) -> DashAIDataType: + """Return the DashAI type for a converted column's values.""" + from DashAI.back.types.utils import arrow_to_dashai_types + + if self.new_type != "Categorical": + return arrow_to_dashai_types(self._arrow_type()) + unique_values = sorted({v for v in values if v is not None}) + return Categorical(values=unique_values) + + def transform( + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None + ) -> "DashAIDataset": + """Cast the fitted columns to ``new_type``. + + Parameters + ---------- + x : DashAIDataset + The dataset whose columns (matching those seen in ``fit``) will + be cast. + y : DashAIDataset, optional + Ignored. Defaults to None. + + Returns + ------- + DashAIDataset + The dataset with the fitted columns cast to ``new_type``. + Columns that already had ``new_type``, that had no known type, + or that failed conversion under ``on_error="skip"`` are left + unchanged. + + Raises + ------ + ValueError + If ``on_error`` is ``"raise"`` and a column's values cannot be + safely converted to ``new_type``. + """ + import pandas as pd + import pyarrow as pa + + from DashAI.back.dataloaders.classes.dashai_dataset import modify_table + from DashAI.back.types.type_validation import validate_type_change + + if not self._target_columns: + return x + + x_pandas = x.to_pandas() + new_columns = {} + new_types = dict(x.types) + arrow_type = self._arrow_type() + reuse_cache = x is self._fit_x + + for col in self._target_columns: + if col in self._skip_columns: + continue + current_type_str = self._current_types.get(col) + if current_type_str == self.new_type: + continue + + if reuse_cache and col in self._converted_cache: + converted = self._converted_cache[col] + else: + is_valid, message, converted = validate_type_change( + x_pandas[col], current_type_str, self.new_type + ) + if not is_valid: + full_message = ( + f"Column '{col}' cannot be converted from " + f"'{current_type_str}' to '{self.new_type}': {message}" + ) + if self.on_error == "raise": + raise ValueError(full_message) + print(f"Warning: {full_message} The column will be left unchanged.") + continue + + clean_values = [ + None if pd.isna(v) else v for v in converted.reindex(x_pandas.index) + ] + cast_values = self._cast_values(clean_values) + new_columns[col] = pa.array(cast_values, type=arrow_type) + new_types[col] = self._build_output_type(cast_values) + + if not new_columns: + return x + + return modify_table(x, new_columns, types=new_types) + + def get_output_type(self, column_name: str = None) -> DashAIDataType: + """Return the output type produced for any column cast by this converter. + + Every column cast by ``transform`` ends up with the same type, + ``new_type``, regardless of its original type. + + Parameters + ---------- + column_name : str, optional + Not used; every cast column has the same output type. + Defaults to None. + + Returns + ------- + DashAIDataType + An ``Integer``, ``Float``, ``Text``, or ``Categorical`` type + matching ``new_type``. For ``Categorical``, the categories are + unknown until ``transform`` runs, so an empty placeholder is + returned. + """ + from DashAI.back.types.utils import arrow_to_dashai_types + + if self.new_type != "Categorical": + return arrow_to_dashai_types(self._arrow_type()) + return Categorical(values=[]) diff --git a/DashAI/back/converters/sklearn_wrapper.py b/DashAI/back/converters/sklearn_wrapper.py index 8879687d8..5bbfd0b2b 100644 --- a/DashAI/back/converters/sklearn_wrapper.py +++ b/DashAI/back/converters/sklearn_wrapper.py @@ -10,6 +10,39 @@ from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset +def _normalize_missing(df: object) -> object: + """Normalize missing-value sentinels to ``np.nan`` before handing off to sklearn. + + ``DashAIDataset.to_pandas()`` converts Arrow nulls in string/Categorical + columns to Python ``None``, while numeric columns get actual float + ``nan``. Several sklearn transformers (notably ``SimpleImputer``, whose + default ``missing_values=np.nan`` masks via a ``value != value`` check) + only recognize float ``nan`` as missing: ``None != None`` is ``False``, + so ``None`` entries are silently treated as real values and never + imputed, even though ``pandas``/Arrow both consider them missing. + ``DataFrame.notna()`` correctly flags both, so this unifies the + representation without altering any non-missing value or dtype. + + Parameters + ---------- + df : object + The value returned by ``DashAIDataset.to_pandas()``. Only + ``pandas.DataFrame`` instances are normalized; anything else is + returned unchanged. + + Returns + ------- + object + The normalized DataFrame, or ``df`` unchanged if it isn't one. + """ + import numpy as np + import pandas as pd + + if not isinstance(df, pd.DataFrame): + return df + return df.where(df.notna(), np.nan) + + class SklearnWrapper(BaseConverter, metaclass=ABCMeta): """Abstract mixin that adapts scikit-learn transformers to the DashAI converter API. @@ -85,49 +118,60 @@ def fit( If no scikit-learn class with a `fit` method is found in the MRO. """ - x_pandas = x.to_pandas() if hasattr(x, "to_pandas") else x - y_pandas = y.to_pandas() if y is not None and hasattr(y, "to_pandas") else y - - # Detect whether the underlying sklearn estimator needs a target. - # sklearn >= 1.6 moved tags from ``_get_tags`` to ``__sklearn_tags__``, - # so we consult both for backward/forward compatibility. - requires_y = False - if hasattr(self, "__sklearn_tags__"): - try: - tags = self.__sklearn_tags__() - target_tags = getattr(tags, "target_tags", None) - if target_tags is not None: - requires_y = bool(getattr(target_tags, "required", False)) - except Exception: - requires_y = False - if not requires_y and hasattr(self, "_get_tags"): - with contextlib.suppress(Exception): - requires_y = bool(self._get_tags().get("requires_y", False)) - - if requires_y and y is None: - raise ValueError("This transformer requires y for fitting") - - sklearn_cls = next( - ( - cls - for cls in type(self).__mro__ - if "sklearn" in cls.__module__ - and "DashAI" not in cls.__module__ - and "fit" in cls.__dict__ - ), - None, - ) + try: + if hasattr(x, "to_pandas"): + x_pandas = _normalize_missing(x.to_pandas()) + self._fit_input_cache = (x, x_pandas) + else: + x_pandas = x + self._fit_input_cache = None + y_pandas = ( + _normalize_missing(y.to_pandas()) + if y is not None and hasattr(y, "to_pandas") + else y + ) - if sklearn_cls is None: - raise RuntimeError( - "No sklearn class with a 'fit' method found in the MRO. " - "Ensure that your transformer inherits from a valid sklearn class." + requires_y = False + if hasattr(self, "__sklearn_tags__"): + try: + tags = self.__sklearn_tags__() + target_tags = getattr(tags, "target_tags", None) + if target_tags is not None: + requires_y = bool(getattr(target_tags, "required", False)) + except Exception: + requires_y = False + if not requires_y and hasattr(self, "_get_tags"): + with contextlib.suppress(Exception): + requires_y = bool(self._get_tags().get("requires_y", False)) + + if requires_y and y is None: + raise ValueError("This transformer requires y for fitting") + + sklearn_cls = next( + ( + cls + for cls in type(self).__mro__ + if "sklearn" in cls.__module__ + and "DashAI" not in cls.__module__ + and "fit" in cls.__dict__ + ), + None, ) - fit_method = sklearn_cls.__dict__["fit"] - if requires_y or y_pandas is not None: - fit_method(self, x_pandas, y_pandas) - else: - fit_method(self, x_pandas) + + if sklearn_cls is None: + raise RuntimeError( + "No sklearn class with a 'fit' method found in the MRO. " + "Ensure that your transformer inherits from a valid sklearn " + "class." + ) + fit_method = sklearn_cls.__dict__["fit"] + if requires_y or y_pandas is not None: + fit_method(self, x_pandas, y_pandas) + else: + fit_method(self, x_pandas) + except Exception: + self._fit_input_cache = None + raise return self @@ -165,7 +209,16 @@ def transform( from DashAI.back.dataloaders.classes.dashai_dataset import to_dashai_dataset - x_pandas = x.to_pandas() if hasattr(x, "to_pandas") else x + cached = getattr(self, "_fit_input_cache", None) + if cached is not None and cached[0] is x: + x_pandas = cached[1] + elif hasattr(x, "to_pandas"): + x_pandas = _normalize_missing(x.to_pandas()) + else: + x_pandas = x + # Drop the reference now that it's been consumed (or wasn't a hit), + # so the cached DataFrame doesn't outlive this transform call. + self._fit_input_cache = None sklearn_cls = next( ( diff --git a/DashAI/back/initial_components.py b/DashAI/back/initial_components.py index 3c26524e7..f49baf60c 100644 --- a/DashAI/back/initial_components.py +++ b/DashAI/back/initial_components.py @@ -58,8 +58,12 @@ from DashAI.back.converters.simple_converters.character_replacer import ( CharacterReplacer, ) +from DashAI.back.converters.simple_converters.column_arithmetic import ColumnArithmetic +from DashAI.back.converters.simple_converters.column_concat import ColumnConcat from DashAI.back.converters.simple_converters.column_remover import ColumnRemover from DashAI.back.converters.simple_converters.nan_remover import NanRemover +from DashAI.back.converters.simple_converters.numeric_expansion import NumericExpansion +from DashAI.back.converters.simple_converters.type_cast import TypeCast # DataLoaders from DashAI.back.dataloaders.classes.arff_dataloader import ARFFDataLoader @@ -115,6 +119,7 @@ # Metrics from DashAI.back.metrics.classification.accuracy import Accuracy +from DashAI.back.metrics.classification.balanced_accuracy import BalancedAccuracy from DashAI.back.metrics.classification.cohen_kappa import CohenKappa from DashAI.back.metrics.classification.f1 import F1 from DashAI.back.metrics.classification.hamming_distance import HammingDistance @@ -251,6 +256,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.lasso_regression import LassoRegression +from DashAI.back.models.scikit_learn.lightgbm_classifier import LGBMClassifier from DashAI.back.models.scikit_learn.linear_regression import LinearRegression from DashAI.back.models.scikit_learn.linear_svc_classifier import LinearSVCClassifier from DashAI.back.models.scikit_learn.linearSVR import LinearSVR @@ -270,6 +276,7 @@ from DashAI.back.models.scikit_learn.tfidf_logreg_text_classification_model import ( TfIdfLogRegTextClassificationModel, ) +from DashAI.back.models.scikit_learn.xgboost_classifier import XGBClassifier # Optimizers from DashAI.back.optimizers.hyperopt_optimizer import HyperOptOptimizer @@ -348,6 +355,7 @@ def get_initial_components(): KNeighborsClassifier, KNeighborsRegression, LassoRegression, + LGBMClassifier, LinearRegression, LinearSVCClassifier, LinearSVR, @@ -391,6 +399,7 @@ def get_initial_components(): T5SmallTransformer, TfIdfLogRegTextClassificationModel, TongyiZImageModel, + XGBClassifier, XlmRobertaTransformer, XlnetTransformer, MLPImageClassifier, @@ -412,6 +421,7 @@ def get_initial_components(): # Metrics F1, Accuracy, + BalancedAccuracy, Precision, Recall, Bleu, @@ -464,6 +474,10 @@ def get_initial_components(): ColumnRemover, NanRemover, CharacterReplacer, + ColumnArithmetic, + ColumnConcat, + NumericExpansion, + TypeCast, FastICA, IncrementalPCA, PCA, diff --git a/DashAI/back/job/converter_job.py b/DashAI/back/job/converter_job.py index 97cb18c06..8d5b9cdbf 100644 --- a/DashAI/back/job/converter_job.py +++ b/DashAI/back/job/converter_job.py @@ -57,14 +57,23 @@ def _rebuild_dataset_with_transformed_columns( original_columns = base.column_names transformed_cols = transformed.column_names - removed_cols = [col for col in scope_column_names if col not in transformed_cols] - replacement_cols = [col for col in scope_column_names if col in transformed_cols] - new_cols = [col for col in transformed_cols if col not in scope_column_names] + transformed_cols_set = set(transformed_cols) + scope_column_names_set = set(scope_column_names) + + removed_cols = [ + col for col in scope_column_names if col not in transformed_cols_set + ] + replacement_cols = [ + col for col in scope_column_names if col in transformed_cols_set + ] + new_cols = [col for col in transformed_cols if col not in scope_column_names_set] + + removed_cols_set = set(removed_cols) new_columns_order = [] seen_cols = set() for col in original_columns: - if col in removed_cols: + if col in removed_cols_set: continue if col not in seen_cols: new_columns_order.append(col) @@ -83,10 +92,10 @@ def _rebuild_dataset_with_transformed_columns( updated_arrays = {} for col in replacement_cols: - if col in transformed.arrow_table.column_names: + if col in transformed_cols_set: updated_arrays[col] = transformed.arrow_table[col] for col, unique_col in col_name_mapping.items(): - if col in transformed.arrow_table.column_names: + if col in transformed_cols_set: updated_arrays[unique_col] = transformed.arrow_table[col] updated_types = base.types.copy() @@ -345,10 +354,11 @@ def instantiate_converters( ) if scope_rows_indexes: y_dataset_fit = y_dataset_fit.select(scope_rows_indexes) - - y_full_transform = loaded_dataset.select_columns( - [target_column_name] - ) + y_full_transform = loaded_dataset.select_columns( + [target_column_name] + ) + else: + y_full_transform = y_dataset_fit X_dataset_fit = loaded_dataset.select_columns(scope_column_names) @@ -370,7 +380,14 @@ def instantiate_converters( f"Error fitting converter {converter_name}: {e}" ) from e - X_full_transform = loaded_dataset.select_columns(scope_column_names) + if scope_rows_indexes: + X_full_transform = loaded_dataset.select_columns( + scope_column_names + ) + else: + # Same reuse as above: no row-level fit scope means + # X_dataset_fit already covers the full transform scope. + X_full_transform = X_dataset_fit try: transformed_dataset = converter_instance.transform( diff --git a/DashAI/back/job/predict_job.py b/DashAI/back/job/predict_job.py index efbdee0ca..2b1019fad 100644 --- a/DashAI/back/job/predict_job.py +++ b/DashAI/back/job/predict_job.py @@ -422,7 +422,7 @@ def run( manual_input_data, dataset_trained_path ) - prepared_dataset, y_pred = _run_prediction_pipeline( + _, y_pred = _run_prediction_pipeline( task=task, trained_model=trained_model, train_dataset=train_dataset, @@ -460,9 +460,13 @@ def run( full_path = Path(path) / folder_name full_path.mkdir(parents=True, exist_ok=True) - # Add predictions to loaded dataset + output_col = model_session.output_columns[0] + base_columns = [ + col for col in loaded_dataset.column_names if col != output_col + ] + output_dataset = loaded_dataset.select_columns(base_columns) dataset_with_prediction = to_dashai_dataset( - prepared_dataset.add_column(model_session.output_columns[0], y_pred) + output_dataset.add_column(output_col, y_pred) ) # Filter schema from trained dataset diff --git a/DashAI/back/metrics/classification/balanced_accuracy.py b/DashAI/back/metrics/classification/balanced_accuracy.py new file mode 100644 index 000000000..2a1fe0873 --- /dev/null +++ b/DashAI/back/metrics/classification/balanced_accuracy.py @@ -0,0 +1,77 @@ +"""DashAI balanced accuracy classification metric implementation.""" + +from typing import TYPE_CHECKING + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.metrics.classification_metric import ( + ClassificationMetric, + prepare_to_metric, +) + +if TYPE_CHECKING: + import numpy as np + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + +class BalancedAccuracy(ClassificationMetric): + """Average of recall obtained on each class. + + Balanced Accuracy is the macro-average of recall scores per class. It + avoids the inflated performance estimates that plain accuracy gives on + imbalanced datasets, since each class contributes equally regardless of + how many samples it has. + + :: + + Balanced Accuracy = (1 / C) * sum(recall_c for c in classes) + + Range: [0, 1], higher is better (``MAXIMIZE = True``). + + References + ---------- + - [1] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.balanced_accuracy_score.html + """ + + DESCRIPTION = MultilingualString( + en=("Macro-average of recall per class, best suited for imbalanced datasets."), + es=( + "Promedio macro del recall por clase, más adecuado para " + "datasets desbalanceados." + ), + pt=( + "Média macro do recall por classe, mais adequada para " + "conjuntos de dados desbalanceados." + ), + de=( + "Makro-Durchschnitt des Recalls je Klasse, am besten geeignet für " + "unausgewogene Datensätze." + ), + zh=("按类别宏平均的召回率,最适用于类别不均衡的数据集。"), + ) + + @staticmethod + def score( + true_labels: "DashAIDataset", + probs_pred_labels: "np.ndarray", + ) -> float: + """Calculate the balanced accuracy between true and predicted labels. + + Parameters + ---------- + true_labels : DashAIDataset + A DashAI dataset with labels. + probs_pred_labels : np.ndarray + A two-dimensional matrix in which each column represents a class + and the row values represent the probability that an example belongs + to the class associated with the column. + + Returns + ------- + float + Balanced accuracy score between true labels and predicted labels + """ + from sklearn.metrics import balanced_accuracy_score + + true_labels, pred_labels = prepare_to_metric(true_labels, probs_pred_labels) + return balanced_accuracy_score(true_labels, pred_labels) diff --git a/DashAI/back/models/scikit_learn/decision_tree_classifier.py b/DashAI/back/models/scikit_learn/decision_tree_classifier.py index cfeab27e0..fcabdebdb 100644 --- a/DashAI/back/models/scikit_learn/decision_tree_classifier.py +++ b/DashAI/back/models/scikit_learn/decision_tree_classifier.py @@ -184,6 +184,46 @@ class DecisionTreeClassifierSchema(BaseSchema): zh="最大特征数", ), ) # type: ignore + class_weight: schema_field( + none_type(enum_field(enum=["balanced"])), + placeholder=None, + description=MultilingualString( + en=( + "Weights associated with classes, used to correct for class " + "imbalance. 'balanced' automatically adjusts weights inversely " + "proportional to class frequencies. Use None for no weighting." + ), + es=( + "Pesos asociados a las clases, usados para corregir el desbalance " + "de clases. 'balanced' ajusta automáticamente los pesos de forma " + "inversamente proporcional a la frecuencia de cada clase. Use None " + "para no aplicar ponderación." + ), + pt=( + "Pesos associados às classes, usados para corrigir o " + "desbalanceamento de classes. 'balanced' ajusta automaticamente os " + "pesos de forma inversamente proporcional à frequência de cada " + "classe. Use None para não aplicar ponderação." + ), + de=( + "Gewichte, die den Klassen zugeordnet sind, um " + "Klassenungleichgewichte auszugleichen. 'balanced' passt die " + "Gewichte automatisch umgekehrt proportional zur " + "Klassenhäufigkeit an. Verwenden Sie None für keine Gewichtung." + ), + zh=( + "与类别关联的权重,用于纠正类别不平衡。'balanced'会根据类别频率的" + "反比自动调整权重。使用None表示不加权。" + ), + ), + alias=MultilingualString( + en="Class weight", + es="Peso de clase", + pt="Peso da classe", + de="Klassengewicht", + zh="类别权重", + ), + ) # type: ignore class DecisionTreeClassifier( diff --git a/DashAI/back/models/scikit_learn/extra_trees_classifier.py b/DashAI/back/models/scikit_learn/extra_trees_classifier.py index cb2f5ba4c..4f0efbe8b 100644 --- a/DashAI/back/models/scikit_learn/extra_trees_classifier.py +++ b/DashAI/back/models/scikit_learn/extra_trees_classifier.py @@ -3,6 +3,7 @@ from DashAI.back.core.schema_fields import ( BaseSchema, bool_field, + enum_field, none_type, optimizer_int_field, schema_field, @@ -198,6 +199,54 @@ class ExtraTreesClassifierSchema(BaseSchema): zh="随机状态", ), ) # type: ignore + class_weight: schema_field( + none_type(enum_field(enum=["balanced", "balanced_subsample"])), + placeholder=None, + description=MultilingualString( + en=( + "Weights associated with classes, used to correct for class " + "imbalance. 'balanced' adjusts weights inversely proportional to " + "class frequencies in the whole dataset; 'balanced_subsample' does " + "the same but per bootstrap sample of each tree. Use None for no " + "weighting." + ), + es=( + "Pesos asociados a las clases, usados para corregir el desbalance " + "de clases. 'balanced' ajusta los pesos de forma inversamente " + "proporcional a la frecuencia de cada clase en todo el conjunto de " + "datos; 'balanced_subsample' hace lo mismo pero por cada muestra " + "bootstrap de cada árbol. Use None para no aplicar ponderación." + ), + pt=( + "Pesos associados às classes, usados para corrigir o " + "desbalanceamento de classes. 'balanced' ajusta os pesos de forma " + "inversamente proporcional à frequência de cada classe em todo o " + "conjunto de dados; 'balanced_subsample' faz o mesmo, mas por " + "amostra bootstrap de cada árvore. Use None para não aplicar " + "ponderação." + ), + de=( + "Gewichte, die den Klassen zugeordnet sind, um " + "Klassenungleichgewichte auszugleichen. 'balanced' passt die " + "Gewichte umgekehrt proportional zur Klassenhäufigkeit im " + "gesamten Datensatz an; 'balanced_subsample' tut dasselbe, jedoch " + "pro Bootstrap-Stichprobe jedes Baums. Verwenden Sie None für " + "keine Gewichtung." + ), + zh=( + "与类别关联的权重,用于纠正类别不平衡。'balanced'根据整个数据集中" + "各类别频率的反比调整权重;'balanced_subsample'则对每棵树的自举" + "采样分别执行相同操作。使用None表示不加权。" + ), + ), + alias=MultilingualString( + en="Class weight", + es="Peso de clase", + pt="Peso da classe", + de="Klassengewicht", + zh="类别权重", + ), + ) # type: ignore class ExtraTreesClassifier( diff --git a/DashAI/back/models/scikit_learn/hist_gradient_boosting_classifier.py b/DashAI/back/models/scikit_learn/hist_gradient_boosting_classifier.py index 515914e7c..21af0271d 100644 --- a/DashAI/back/models/scikit_learn/hist_gradient_boosting_classifier.py +++ b/DashAI/back/models/scikit_learn/hist_gradient_boosting_classifier.py @@ -4,6 +4,8 @@ from DashAI.back.core.schema_fields import ( BaseSchema, + enum_field, + none_type, optimizer_float_field, optimizer_int_field, schema_field, @@ -228,6 +230,46 @@ class HistGradientBoostingClassifierSchema(BaseSchema): zh="L2正则化", ), ) # type: ignore + class_weight: schema_field( + none_type(enum_field(enum=["balanced"])), + placeholder=None, + description=MultilingualString( + en=( + "Weights associated with classes, used to correct for class " + "imbalance. 'balanced' automatically adjusts weights inversely " + "proportional to class frequencies. Use None for no weighting." + ), + es=( + "Pesos asociados a las clases, usados para corregir el desbalance " + "de clases. 'balanced' ajusta automáticamente los pesos de forma " + "inversamente proporcional a la frecuencia de cada clase. Use None " + "para no aplicar ponderación." + ), + pt=( + "Pesos associados às classes, usados para corrigir o " + "desbalanceamento de classes. 'balanced' ajusta automaticamente os " + "pesos de forma inversamente proporcional à frequência de cada " + "classe. Use None para não aplicar ponderação." + ), + de=( + "Gewichte, die den Klassen zugeordnet sind, um " + "Klassenungleichgewichte auszugleichen. 'balanced' passt die " + "Gewichte automatisch umgekehrt proportional zur " + "Klassenhäufigkeit an. Verwenden Sie None für keine Gewichtung." + ), + zh=( + "与类别关联的权重,用于纠正类别不平衡。'balanced'会根据类别频率的" + "反比自动调整权重。使用None表示不加权。" + ), + ), + alias=MultilingualString( + en="Class weight", + es="Peso de clase", + pt="Peso da classe", + de="Klassengewicht", + zh="类别权重", + ), + ) # type: ignore class HistGradientBoostingClassifier( diff --git a/DashAI/back/models/scikit_learn/lightgbm_classifier.py b/DashAI/back/models/scikit_learn/lightgbm_classifier.py new file mode 100644 index 000000000..90584582f --- /dev/null +++ b/DashAI/back/models/scikit_learn/lightgbm_classifier.py @@ -0,0 +1,464 @@ +from lightgbm import LGBMClassifier as _LGBMClassifier + +from DashAI.back.core.schema_fields import ( + BaseSchema, + enum_field, + none_type, + optimizer_float_field, + optimizer_int_field, + schema_field, +) +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.scikit_learn.sklearn_like_classifier import ( + SklearnLikeClassifier, +) +from DashAI.back.models.tabular_classification_model import TabularClassificationModel + + +class LGBMClassifierSchema(BaseSchema): + """Schema that configures the LightGBM Classifier. + + LightGBM is a gradient boosting ensemble method that grows trees + leaf-wise (best-first) rather than level-wise, using histogram-based + splitting to reach high accuracy with fast training on large tabular + datasets. The underlying implementation is ``lightgbm.LGBMClassifier``. + """ + + n_estimators: schema_field( + optimizer_int_field(ge=1), + placeholder={ + "optimize": False, + "fixed_value": 100, + "lower_bound": 10, + "upper_bound": 500, + }, + description=MultilingualString( + en=( + "The number of boosting rounds, i.e. the number of trees to fit " + "sequentially. Must be an integer greater than or equal to 1." + ), + es=( + "El número de rondas de boosting, es decir, la cantidad de árboles " + "que se ajustan secuencialmente. Debe ser un entero mayor o igual a 1." + ), + pt=( + "O número de rodadas de boosting, ou seja, a quantidade de árvores " + "ajustadas sequencialmente. Deve ser um inteiro maior ou igual a 1." + ), + de=( + "Die Anzahl der Boosting-Runden, d.h. die Anzahl der sequenziell " + "angepassten Bäume. Muss eine ganze Zahl größer oder gleich 1 sein." + ), + zh="提升轮数,即依次拟合的树的数量。必须为大于或等于1的整数。", + ), + alias=MultilingualString( + en="N estimators", + es="N estimadores", + pt="N estimadores", + de="Anzahl Schätzer", + zh="估计器数量", + ), + ) # type: ignore + num_leaves: schema_field( + optimizer_int_field(ge=2), + placeholder={ + "optimize": False, + "fixed_value": 31, + "lower_bound": 8, + "upper_bound": 128, + }, + description=MultilingualString( + en=( + "The maximum number of leaves per tree. This is LightGBM's main " + "capacity control, since it grows trees leaf-wise rather than " + "level-wise. Must be an integer greater than 1." + ), + es=( + "El número máximo de hojas por árbol. Este es el principal " + "control de capacidad de LightGBM, ya que construye árboles hoja " + "por hoja en lugar de nivel por nivel. Debe ser un entero mayor a 1." + ), + pt=( + "O número máximo de folhas por árvore. Este é o principal " + "controle de capacidade do LightGBM, já que ele constrói árvores " + "folha a folha em vez de nível a nível. Deve ser um inteiro maior " + "que 1." + ), + de=( + "Die maximale Anzahl von Blättern pro Baum. Dies ist die " + "wichtigste Kapazitätskontrolle von LightGBM, da es Bäume " + "blattweise statt ebenenweise aufbaut. Muss eine ganze Zahl " + "größer als 1 sein." + ), + zh="每棵树的最大叶节点数。这是LightGBM的主要容量控制参数,因为它是逐叶而非逐层生长树的。必须为大于1的整数。", + ), + alias=MultilingualString( + en="Num leaves", + es="Número de hojas", + pt="Número de folhas", + de="Anzahl der Blätter", + zh="叶节点数量", + ), + ) # type: ignore + learning_rate: schema_field( + optimizer_float_field(gt=0.0), + placeholder={ + "optimize": False, + "fixed_value": 0.1, + "lower_bound": 0.01, + "upper_bound": 1.0, + }, + description=MultilingualString( + en=( + "Step size shrinkage applied to each tree's contribution. Lower " + "values need more estimators but generalise better." + ), + es=( + "Reducción del tamaño de paso aplicada a la contribución de cada " + "árbol. Valores menores requieren más estimadores pero generalizan " + "mejor." + ), + pt=( + "Redução do tamanho do passo aplicada à contribuição de cada " + "árvore. Valores menores exigem mais estimadores, mas generalizam " + "melhor." + ), + de=( + "Schrittweitenreduktion, die auf den Beitrag jedes Baums " + "angewendet wird. Kleinere Werte benötigen mehr Schätzer, " + "generalisieren aber besser." + ), + zh="应用于每棵树贡献的步长收缩。较小的值需要更多的估计器,但泛化效果更好。", + ), + alias=MultilingualString( + en="Learning rate", + es="Tasa de aprendizaje", + pt="Taxa de aprendizado", + de="Lernrate", + zh="学习率", + ), + ) # type: ignore + subsample: schema_field( + optimizer_float_field(gt=0.0, le=1.0), + placeholder={ + "optimize": False, + "fixed_value": 1.0, + "lower_bound": 0.5, + "upper_bound": 1.0, + }, + description=MultilingualString( + en=( + "Fraction of training samples randomly drawn to grow each tree " + "(bagging fraction). Values below 1.0 introduce randomness that " + "helps prevent overfitting." + ), + es=( + "Fracción de muestras de entrenamiento tomadas aleatoriamente para " + "construir cada árbol (bagging fraction). Valores menores a 1.0 " + "introducen aleatoriedad que ayuda a prevenir el sobreajuste." + ), + pt=( + "Fração de amostras de treinamento sorteadas aleatoriamente para " + "construir cada árvore (bagging fraction). Valores abaixo de 1.0 " + "introduzem aleatoriedade que ajuda a prevenir o overfitting." + ), + de=( + "Anteil der Trainingsstichproben, die zufällig zum Aufbau jedes " + "Baums gezogen werden (Bagging-Anteil). Werte unter 1,0 führen " + "Zufälligkeit ein, die Overfitting vorbeugt." + ), + zh="随机抽取用于构建每棵树的训练样本比例(bagging比例)。小于1.0的值会引入随机性,有助于防止过拟合。", + ), + alias=MultilingualString( + en="Subsample", + es="Submuestra", + pt="Subamostra", + de="Teilstichprobe", + zh="子采样比例", + ), + ) # type: ignore + colsample_bytree: schema_field( + optimizer_float_field(gt=0.0, le=1.0), + placeholder={ + "optimize": False, + "fixed_value": 1.0, + "lower_bound": 0.5, + "upper_bound": 1.0, + }, + description=MultilingualString( + en=( + "Fraction of features randomly sampled when building each tree. " + "Values below 1.0 decorrelate the trees, reducing overfitting." + ), + es=( + "Fracción de características muestreadas aleatoriamente al " + "construir cada árbol. Valores menores a 1.0 decorrelacionan los " + "árboles, reduciendo el sobreajuste." + ), + pt=( + "Fração de atributos amostrados aleatoriamente ao construir cada " + "árvore. Valores abaixo de 1.0 descorrelacionam as árvores, " + "reduzindo o overfitting." + ), + de=( + "Anteil der Merkmale, die zufällig beim Aufbau jedes Baums " + "ausgewählt werden. Werte unter 1,0 dekorrelieren die Bäume und " + "verringern Overfitting." + ), + zh="构建每棵树时随机采样的特征比例。小于1.0的值可以降低树之间的相关性,减少过拟合。", + ), + alias=MultilingualString( + en="Column subsample by tree", + es="Submuestra de columnas por árbol", + pt="Subamostra de colunas por árvore", + de="Spalten-Teilstichprobe pro Baum", + zh="每棵树的列子采样比例", + ), + ) # type: ignore + reg_alpha: schema_field( + optimizer_float_field(ge=0.0), + placeholder={ + "optimize": False, + "fixed_value": 0.0, + "lower_bound": 0.0, + "upper_bound": 5.0, + }, + description=MultilingualString( + en="L1 regularization term on the tree leaf weights. Use 0 for none.", + es=( + "Término de regularización L1 sobre los pesos de las hojas del " + "árbol. Use 0 para no aplicar regularización." + ), + pt=( + "Termo de regularização L1 sobre os pesos das folhas da árvore. " + "Use 0 para não aplicar regularização." + ), + de=( + "L1-Regularisierungsterm für die Blattgewichte des Baums. " + "Verwenden Sie 0 für keine Regularisierung." + ), + zh="树叶节点权重的L1正则化项。使用0表示不正则化。", + ), + alias=MultilingualString( + en="L1 regularization", + es="Regularización L1", + pt="Regularização L1", + de="L1-Regularisierung", + zh="L1正则化", + ), + ) # type: ignore + reg_lambda: schema_field( + optimizer_float_field(ge=0.0), + placeholder={ + "optimize": False, + "fixed_value": 0.0, + "lower_bound": 0.0, + "upper_bound": 5.0, + }, + description=MultilingualString( + en="L2 regularization term on the tree leaf weights. Use 0 for none.", + es=( + "Término de regularización L2 sobre los pesos de las hojas del " + "árbol. Use 0 para no aplicar regularización." + ), + pt=( + "Termo de regularização L2 sobre os pesos das folhas da árvore. " + "Use 0 para não aplicar regularização." + ), + de=( + "L2-Regularisierungsterm für die Blattgewichte des Baums. " + "Verwenden Sie 0 für keine Regularisierung." + ), + zh="树叶节点权重的L2正则化项。使用0表示不正则化。", + ), + alias=MultilingualString( + en="L2 regularization", + es="Regularización L2", + pt="Regularização L2", + de="L2-Regularisierung", + zh="L2正则化", + ), + ) # type: ignore + min_child_samples: schema_field( + optimizer_int_field(ge=1), + placeholder={ + "optimize": False, + "fixed_value": 20, + "lower_bound": 5, + "upper_bound": 50, + }, + description=MultilingualString( + en=( + "The minimum number of samples required in a leaf. LightGBM's " + "main safeguard against overfitting on small datasets, since it " + "grows trees leaf-wise." + ), + es=( + "El número mínimo de muestras requeridas en una hoja. La " + "principal salvaguarda de LightGBM contra el sobreajuste en " + "conjuntos de datos pequeños, ya que construye árboles hoja por " + "hoja." + ), + pt=( + "O número mínimo de amostras necessárias em uma folha. A " + "principal salvaguarda do LightGBM contra o overfitting em " + "conjuntos de dados pequenos, já que ele constrói árvores folha a " + "folha." + ), + de=( + "Die Mindestanzahl von Stichproben, die in einem Blatt " + "erforderlich sind. Die wichtigste Absicherung von LightGBM gegen " + "Overfitting bei kleinen Datensätzen, da es Bäume blattweise " + "aufbaut." + ), + zh="叶节点所需的最小样本数。这是LightGBM在小数据集上防止过拟合的主要保障,因为它是逐叶生长树的。", + ), + alias=MultilingualString( + en="Min child samples", + es="Muestras mínimas por hoja", + pt="Amostras mínimas por folha", + de="Minimale Stichproben pro Blatt", + zh="最小叶节点样本数", + ), + ) # type: ignore + class_weight: schema_field( + none_type(enum_field(enum=["balanced"])), + placeholder=None, + description=MultilingualString( + en=( + "Weights associated with classes, used to correct for class " + "imbalance. 'balanced' automatically adjusts weights inversely " + "proportional to class frequencies. Use None for no weighting. " + "Only applies to multiclass problems, or binary problems where " + "``is_unbalance``/``scale_pos_weight`` is not set." + ), + es=( + "Pesos asociados a las clases, usados para corregir el desbalance " + "de clases. 'balanced' ajusta automáticamente los pesos de forma " + "inversamente proporcional a la frecuencia de cada clase. Use None " + "para no aplicar ponderación. Solo aplica a problemas " + "multiclase, o binarios en los que no se haya configurado " + "``is_unbalance``/``scale_pos_weight``." + ), + pt=( + "Pesos associados às classes, usados para corrigir o " + "desbalanceamento de classes. 'balanced' ajusta automaticamente os " + "pesos de forma inversamente proporcional à frequência de cada " + "classe. Use None para não aplicar ponderação. Aplica-se apenas a " + "problemas multiclasse, ou binários em que ``is_unbalance``/" + "``scale_pos_weight`` não estejam definidos." + ), + de=( + "Gewichte, die den Klassen zugeordnet sind, um " + "Klassenungleichgewichte auszugleichen. 'balanced' passt die " + "Gewichte automatisch umgekehrt proportional zur " + "Klassenhäufigkeit an. Verwenden Sie None für keine Gewichtung. " + "Gilt nur für Mehrklassenprobleme oder binäre Probleme, bei " + "denen ``is_unbalance``/``scale_pos_weight`` nicht gesetzt ist." + ), + zh=( + "与类别关联的权重,用于纠正类别不平衡。'balanced'会根据类别频率的" + "反比自动调整权重。使用None表示不加权。仅适用于多分类问题," + "或未设置``is_unbalance``/``scale_pos_weight``的二分类问题。" + ), + ), + alias=MultilingualString( + en="Class weight", + es="Peso de clase", + pt="Peso da classe", + de="Klassengewicht", + zh="类别权重", + ), + ) # type: ignore + + +class _LightGBMDashAIMixin(TabularClassificationModel, SklearnLikeClassifier): + """Combines DashAI's two mixins into a single class. + + ``lightgbm.LGBMModel.get_params()`` does not use cooperative ``super()`` + dispatch; it inspects ``type(self).__bases__`` directly and assumes + exactly two entries: a mixin without ``get_params`` (normally + ``ClassifierMixin``) followed by the real estimator class. With three + separate bases the lookup mis-resolves to a mixin that lacks + ``get_params`` and raises ``AttributeError``. Folding both DashAI mixins + into one intermediate class keeps that assumption satisfied. + + Note: this class must not have "Base" in its name — DashAI's + ``ComponentRegistry._get_base_type`` matches ancestor classes by that + substring, and a match here would collide with ``BaseModel``. + """ + + +class LGBMClassifier(_LightGBMDashAIMixin, _LGBMClassifier): + """LightGBM gradient boosting classifier for tabular data. + + LightGBM grows trees leaf-wise (choosing the leaf with the largest loss + reduction at each step) instead of level-wise, combined with + histogram-based feature binning, which typically yields faster training + and lower memory usage than level-wise boosting methods on large tabular + datasets, at the cost of being more prone to overfitting on small ones + (mitigated via ``num_leaves`` and ``min_child_samples``). + + Key hyperparameters include ``n_estimators`` (number of boosting rounds), + ``num_leaves``, ``learning_rate``, ``subsample``, ``colsample_bytree``, + ``reg_alpha``, ``reg_lambda``, and ``min_child_samples``. The + implementation wraps ``lightgbm.LGBMClassifier``. + + References + ---------- + - [1] Ke, G. et al. (2017). "LightGBM: A Highly Efficient Gradient + Boosting Decision Tree." Advances in Neural Information Processing + Systems 30. + https://proceedings.neurips.cc/paper/2017/hash/6449f44a102fde848669bdd9eb6b76fa-Abstract.html + - [2] https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.LGBMClassifier.html + """ + + SCHEMA = LGBMClassifierSchema + DISPLAY_NAME: str = MultilingualString( + en="LightGBM", + es="LightGBM", + pt="LightGBM", + de="LightGBM", + zh="LightGBM", + ) + DESCRIPTION: str = MultilingualString( + en=( + "Fast, leaf-wise gradient boosting framework optimized for large " + "tabular datasets." + ), + es=( + "Marco de gradient boosting rápido y hoja por hoja, optimizado para " + "conjuntos de datos tabulares grandes." + ), + pt=( + "Framework de gradient boosting rápido e folha a folha, otimizado " + "para conjuntos de dados tabulares grandes." + ), + de=( + "Schnelles, blattweises Gradient-Boosting-Framework, optimiert für " + "große tabellarische Datensätze." + ), + zh="快速的逐叶生长梯度提升框架,针对大型表格数据集进行了优化。", + ) + COLOR: str = "#7986CB" + ICON: str = "FlashOn" + + 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 LightGBM wrapper. + See the associated schema class for available keys and their + defaults. + """ + # LightGBM only applies `subsample` (bagging_fraction) when + # `subsample_freq` (bagging_freq) is > 0; its own default is 0, which + # would silently turn the schema's `subsample` field into a no-op. + kwargs["subsample_freq"] = 1 + # Silence the per-iteration [LightGBM] Info/Warning banner, which + # would otherwise spam the Huey worker process logs. + kwargs["verbose"] = -1 + kwargs["n_jobs"] = 1 + super().__init__(**kwargs) diff --git a/DashAI/back/models/scikit_learn/linear_svc_classifier.py b/DashAI/back/models/scikit_learn/linear_svc_classifier.py index dcb314d91..dcc7cbff6 100644 --- a/DashAI/back/models/scikit_learn/linear_svc_classifier.py +++ b/DashAI/back/models/scikit_learn/linear_svc_classifier.py @@ -192,6 +192,47 @@ class LinearSVCClassifierSchema(BaseSchema): ), ) # type: ignore + class_weight: schema_field( + none_type(enum_field(enum=["balanced"])), + placeholder=None, + description=MultilingualString( + en=( + "Weights associated with classes, used to correct for class " + "imbalance. 'balanced' automatically adjusts weights inversely " + "proportional to class frequencies. Use None for no weighting." + ), + es=( + "Pesos asociados a las clases, usados para corregir el desbalance " + "de clases. 'balanced' ajusta automáticamente los pesos de forma " + "inversamente proporcional a la frecuencia de cada clase. Use None " + "para no aplicar ponderación." + ), + pt=( + "Pesos associados às classes, usados para corrigir o " + "desbalanceamento de classes. 'balanced' ajusta automaticamente os " + "pesos de forma inversamente proporcional à frequência de cada " + "classe. Use None para não aplicar ponderação." + ), + de=( + "Gewichte, die den Klassen zugeordnet sind, um " + "Klassenungleichgewichte auszugleichen. 'balanced' passt die " + "Gewichte automatisch umgekehrt proportional zur " + "Klassenhäufigkeit an. Verwenden Sie None für keine Gewichtung." + ), + zh=( + "与类别关联的权重,用于纠正类别不平衡。'balanced'会根据类别频率的" + "反比自动调整权重。使用None表示不加权。" + ), + ), + alias=MultilingualString( + en="Class weight", + es="Peso de clase", + pt="Peso da classe", + de="Klassengewicht", + zh="类别权重", + ), + ) # type: ignore + class LinearSVCClassifier( TabularClassificationModel, SklearnLikeClassifier, _LinearSVC @@ -281,7 +322,15 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): params = { k: getattr(self, k) - for k in ["C", "loss", "max_iter", "tol", "fit_intercept", "random_state"] + for k in [ + "C", + "loss", + "max_iter", + "tol", + "fit_intercept", + "random_state", + "class_weight", + ] if hasattr(self, k) } base = _LinearSVCRaw(**params) diff --git a/DashAI/back/models/scikit_learn/logistic_regression.py b/DashAI/back/models/scikit_learn/logistic_regression.py index 9aa5f4dc8..2efc4581e 100644 --- a/DashAI/back/models/scikit_learn/logistic_regression.py +++ b/DashAI/back/models/scikit_learn/logistic_regression.py @@ -3,6 +3,7 @@ from DashAI.back.core.schema_fields import ( BaseSchema, enum_field, + none_type, optimizer_float_field, optimizer_int_field, schema_field, @@ -114,6 +115,46 @@ class LogisticRegressionSchema(BaseSchema): zh="最大迭代次数", ), ) # type: ignore + class_weight: schema_field( + none_type(enum_field(enum=["balanced"])), + placeholder=None, + description=MultilingualString( + en=( + "Weights associated with classes, used to correct for class " + "imbalance. 'balanced' automatically adjusts weights inversely " + "proportional to class frequencies. Use None for no weighting." + ), + es=( + "Pesos asociados a las clases, usados para corregir el desbalance " + "de clases. 'balanced' ajusta automáticamente los pesos de forma " + "inversamente proporcional a la frecuencia de cada clase. Use None " + "para no aplicar ponderación." + ), + pt=( + "Pesos associados às classes, usados para corrigir o " + "desbalanceamento de classes. 'balanced' ajusta automaticamente os " + "pesos de forma inversamente proporcional à frequência de cada " + "classe. Use None para não aplicar ponderação." + ), + de=( + "Gewichte, die den Klassen zugeordnet sind, um " + "Klassenungleichgewichte auszugleichen. 'balanced' passt die " + "Gewichte automatisch umgekehrt proportional zur " + "Klassenhäufigkeit an. Verwenden Sie None für keine Gewichtung." + ), + zh=( + "与类别关联的权重,用于纠正类别不平衡。'balanced'会根据类别频率的" + "反比自动调整权重。使用None表示不加权。" + ), + ), + alias=MultilingualString( + en="Class weight", + es="Peso de clase", + pt="Peso da classe", + de="Klassengewicht", + zh="类别权重", + ), + ) # type: ignore class LogisticRegression( diff --git a/DashAI/back/models/scikit_learn/random_forest_classifier.py b/DashAI/back/models/scikit_learn/random_forest_classifier.py index 7c6104b1a..1b808b886 100644 --- a/DashAI/back/models/scikit_learn/random_forest_classifier.py +++ b/DashAI/back/models/scikit_learn/random_forest_classifier.py @@ -1,6 +1,12 @@ from sklearn.ensemble import RandomForestClassifier as _RandomForestClassifier -from DashAI.back.core.schema_fields import BaseSchema, optimizer_int_field, schema_field +from DashAI.back.core.schema_fields import ( + BaseSchema, + enum_field, + none_type, + optimizer_int_field, + schema_field, +) from DashAI.back.core.utils import MultilingualString from DashAI.back.models.scikit_learn.sklearn_like_classifier import ( SklearnLikeClassifier, @@ -224,6 +230,54 @@ class RandomForestClassifierSchema(BaseSchema): zh="随机状态", ), ) # type: ignore + class_weight: schema_field( + none_type(enum_field(enum=["balanced", "balanced_subsample"])), + placeholder=None, + description=MultilingualString( + en=( + "Weights associated with classes, used to correct for class " + "imbalance. 'balanced' adjusts weights inversely proportional to " + "class frequencies in the whole dataset; 'balanced_subsample' does " + "the same but per bootstrap sample of each tree. Use None for no " + "weighting." + ), + es=( + "Pesos asociados a las clases, usados para corregir el desbalance " + "de clases. 'balanced' ajusta los pesos de forma inversamente " + "proporcional a la frecuencia de cada clase en todo el conjunto de " + "datos; 'balanced_subsample' hace lo mismo pero por cada muestra " + "bootstrap de cada árbol. Use None para no aplicar ponderación." + ), + pt=( + "Pesos associados às classes, usados para corrigir o " + "desbalanceamento de classes. 'balanced' ajusta os pesos de forma " + "inversamente proporcional à frequência de cada classe em todo o " + "conjunto de dados; 'balanced_subsample' faz o mesmo, mas por " + "amostra bootstrap de cada árvore. Use None para não aplicar " + "ponderação." + ), + de=( + "Gewichte, die den Klassen zugeordnet sind, um " + "Klassenungleichgewichte auszugleichen. 'balanced' passt die " + "Gewichte umgekehrt proportional zur Klassenhäufigkeit im " + "gesamten Datensatz an; 'balanced_subsample' tut dasselbe, jedoch " + "pro Bootstrap-Stichprobe jedes Baums. Verwenden Sie None für " + "keine Gewichtung." + ), + zh=( + "与类别关联的权重,用于纠正类别不平衡。'balanced'根据整个数据集中" + "各类别频率的反比调整权重;'balanced_subsample'则对每棵树的自举" + "采样分别执行相同操作。使用None表示不加权。" + ), + ), + alias=MultilingualString( + en="Class weight", + es="Peso de clase", + pt="Peso da classe", + de="Klassengewicht", + zh="类别权重", + ), + ) # type: ignore class RandomForestClassifier( diff --git a/DashAI/back/models/scikit_learn/sgd_classifier.py b/DashAI/back/models/scikit_learn/sgd_classifier.py index 335471f54..f1a445d46 100644 --- a/DashAI/back/models/scikit_learn/sgd_classifier.py +++ b/DashAI/back/models/scikit_learn/sgd_classifier.py @@ -234,6 +234,47 @@ class SGDClassifierSchema(BaseSchema): ), ) # type: ignore + class_weight: schema_field( + none_type(enum_field(enum=["balanced"])), + placeholder=None, + description=MultilingualString( + en=( + "Weights associated with classes, used to correct for class " + "imbalance. 'balanced' automatically adjusts weights inversely " + "proportional to class frequencies. Use None for no weighting." + ), + es=( + "Pesos asociados a las clases, usados para corregir el desbalance " + "de clases. 'balanced' ajusta automáticamente los pesos de forma " + "inversamente proporcional a la frecuencia de cada clase. Use None " + "para no aplicar ponderación." + ), + pt=( + "Pesos associados às classes, usados para corrigir o " + "desbalanceamento de classes. 'balanced' ajusta automaticamente os " + "pesos de forma inversamente proporcional à frequência de cada " + "classe. Use None para não aplicar ponderação." + ), + de=( + "Gewichte, die den Klassen zugeordnet sind, um " + "Klassenungleichgewichte auszugleichen. 'balanced' passt die " + "Gewichte automatisch umgekehrt proportional zur " + "Klassenhäufigkeit an. Verwenden Sie None für keine Gewichtung." + ), + zh=( + "与类别关联的权重,用于纠正类别不平衡。'balanced'会根据类别频率的" + "反比自动调整权重。使用None表示不加权。" + ), + ), + alias=MultilingualString( + en="Class weight", + es="Peso de clase", + pt="Peso da classe", + de="Klassengewicht", + zh="类别权重", + ), + ) # type: ignore + class SGDClassifier(TabularClassificationModel, SklearnLikeClassifier, _SGDClassifier): """SGD classifier with probability calibration for consistent predict_proba output. @@ -318,6 +359,7 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): "tol", "learning_rate", "random_state", + "class_weight", ] if hasattr(self, k) } diff --git a/DashAI/back/models/scikit_learn/svc.py b/DashAI/back/models/scikit_learn/svc.py index 34b08dc15..bba3ecf0b 100644 --- a/DashAI/back/models/scikit_learn/svc.py +++ b/DashAI/back/models/scikit_learn/svc.py @@ -4,6 +4,7 @@ BaseSchema, bool_field, enum_field, + none_type, optimizer_float_field, optimizer_int_field, schema_field, @@ -222,6 +223,46 @@ class SVCSchema(BaseSchema): en="tolerance", es="tolerancia", pt="tolerância", de="Toleranz", zh="容差" ), ) # type: ignore + class_weight: schema_field( + none_type(enum_field(enum=["balanced"])), + placeholder=None, + description=MultilingualString( + en=( + "Weights associated with classes, used to correct for class " + "imbalance. 'balanced' automatically adjusts weights inversely " + "proportional to class frequencies. Use None for no weighting." + ), + es=( + "Pesos asociados a las clases, usados para corregir el desbalance " + "de clases. 'balanced' ajusta automáticamente los pesos de forma " + "inversamente proporcional a la frecuencia de cada clase. Use None " + "para no aplicar ponderación." + ), + pt=( + "Pesos associados às classes, usados para corrigir o " + "desbalanceamento de classes. 'balanced' ajusta automaticamente os " + "pesos de forma inversamente proporcional à frequência de cada " + "classe. Use None para não aplicar ponderação." + ), + de=( + "Gewichte, die den Klassen zugeordnet sind, um " + "Klassenungleichgewichte auszugleichen. 'balanced' passt die " + "Gewichte automatisch umgekehrt proportional zur " + "Klassenhäufigkeit an. Verwenden Sie None für keine Gewichtung." + ), + zh=( + "与类别关联的权重,用于纠正类别不平衡。'balanced'会根据类别频率的" + "反比自动调整权重。使用None表示不加权。" + ), + ), + alias=MultilingualString( + en="Class weight", + es="Peso de clase", + pt="Peso da classe", + de="Klassengewicht", + zh="类别权重", + ), + ) # type: ignore class SVC(TabularClassificationModel, SklearnLikeClassifier, _SVC): diff --git a/DashAI/back/models/scikit_learn/xgboost_classifier.py b/DashAI/back/models/scikit_learn/xgboost_classifier.py new file mode 100644 index 000000000..6deda2a41 --- /dev/null +++ b/DashAI/back/models/scikit_learn/xgboost_classifier.py @@ -0,0 +1,402 @@ +from xgboost import XGBClassifier as _XGBClassifier + +from DashAI.back.core.schema_fields import ( + BaseSchema, + optimizer_float_field, + optimizer_int_field, + schema_field, +) +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.scikit_learn.sklearn_like_classifier import ( + SklearnLikeClassifier, +) +from DashAI.back.models.tabular_classification_model import TabularClassificationModel + + +class XGBClassifierSchema(BaseSchema): + """Schema that configures the XGBoost Classifier. + + XGBoost (Extreme Gradient Boosting) is a regularized gradient boosting + ensemble method that sequentially fits decision trees to the residual + errors of the previous trees. It is widely used in tabular machine + learning competitions for its speed and accuracy. The underlying + implementation is ``xgboost.XGBClassifier``. + """ + + n_estimators: schema_field( + optimizer_int_field(ge=1), + placeholder={ + "optimize": False, + "fixed_value": 100, + "lower_bound": 10, + "upper_bound": 500, + }, + description=MultilingualString( + en=( + "The number of boosting rounds, i.e. the number of trees to fit " + "sequentially. Must be an integer greater than or equal to 1." + ), + es=( + "El número de rondas de boosting, es decir, la cantidad de árboles " + "que se ajustan secuencialmente. Debe ser un entero mayor o igual a 1." + ), + pt=( + "O número de rodadas de boosting, ou seja, a quantidade de árvores " + "ajustadas sequencialmente. Deve ser um inteiro maior ou igual a 1." + ), + de=( + "Die Anzahl der Boosting-Runden, d.h. die Anzahl der sequenziell " + "angepassten Bäume. Muss eine ganze Zahl größer oder gleich 1 sein." + ), + zh="提升轮数,即依次拟合的树的数量。必须为大于或等于1的整数。", + ), + alias=MultilingualString( + en="N estimators", + es="N estimadores", + pt="N estimadores", + de="Anzahl Schätzer", + zh="估计器数量", + ), + ) # type: ignore + max_depth: schema_field( + optimizer_int_field(ge=1), + placeholder={ + "optimize": False, + "fixed_value": 6, + "lower_bound": 1, + "upper_bound": 10, + }, + description=MultilingualString( + en=( + "The maximum depth of each tree. Deeper trees model more complex " + "interactions but are more prone to overfitting." + ), + es=( + "La profundidad máxima de cada árbol. Los árboles más profundos " + "modelan interacciones más complejas pero son más propensos al " + "sobreajuste." + ), + pt=( + "A profundidade máxima de cada árvore. Árvores mais profundas " + "modelam interações mais complexas, mas são mais propensas ao " + "overfitting." + ), + de=( + "Die maximale Tiefe jedes Baums. Tiefere Bäume modellieren " + "komplexere Interaktionen, neigen aber eher zu Overfitting." + ), + zh="每棵树的最大深度。更深的树能建模更复杂的交互,但更容易过拟合。", + ), + alias=MultilingualString( + en="Max depth", + es="Profundidad máxima", + pt="Profundidade máxima", + de="Maximale Tiefe", + zh="最大深度", + ), + ) # type: ignore + learning_rate: schema_field( + optimizer_float_field(gt=0.0), + placeholder={ + "optimize": False, + "fixed_value": 0.3, + "lower_bound": 0.01, + "upper_bound": 1.0, + }, + description=MultilingualString( + en=( + "Step size shrinkage applied to each tree's contribution, also " + "known as 'eta'. Lower values need more estimators but generalise " + "better." + ), + es=( + "Reducción del tamaño de paso aplicada a la contribución de cada " + "árbol, también conocida como 'eta'. Valores menores requieren más " + "estimadores pero generalizan mejor." + ), + pt=( + "Redução do tamanho do passo aplicada à contribuição de cada " + "árvore, também conhecida como 'eta'. Valores menores exigem mais " + "estimadores, mas generalizam melhor." + ), + de=( + "Schrittweitenreduktion, die auf den Beitrag jedes Baums angewendet " + "wird, auch als 'eta' bekannt. Kleinere Werte benötigen mehr " + "Schätzer, generalisieren aber besser." + ), + zh="应用于每棵树贡献的步长收缩,也称为'eta'。较小的值需要更多的估计器,但泛化效果更好。", + ), + alias=MultilingualString( + en="Learning rate", + es="Tasa de aprendizaje", + pt="Taxa de aprendizado", + de="Lernrate", + zh="学习率", + ), + ) # type: ignore + subsample: schema_field( + optimizer_float_field(gt=0.0, le=1.0), + placeholder={ + "optimize": False, + "fixed_value": 1.0, + "lower_bound": 0.5, + "upper_bound": 1.0, + }, + description=MultilingualString( + en=( + "Fraction of training samples randomly drawn to grow each tree. " + "Values below 1.0 introduce randomness that helps prevent " + "overfitting." + ), + es=( + "Fracción de muestras de entrenamiento tomadas aleatoriamente para " + "construir cada árbol. Valores menores a 1.0 introducen aleatoriedad " + "que ayuda a prevenir el sobreajuste." + ), + pt=( + "Fração de amostras de treinamento sorteadas aleatoriamente para " + "construir cada árvore. Valores abaixo de 1.0 introduzem " + "aleatoriedade que ajuda a prevenir o overfitting." + ), + de=( + "Anteil der Trainingsstichproben, die zufällig zum Aufbau jedes " + "Baums gezogen werden. Werte unter 1,0 führen Zufälligkeit ein, die " + "Overfitting vorbeugt." + ), + zh="随机抽取用于构建每棵树的训练样本比例。小于1.0的值会引入随机性,有助于防止过拟合。", + ), + alias=MultilingualString( + en="Subsample", + es="Submuestra", + pt="Subamostra", + de="Teilstichprobe", + zh="子采样比例", + ), + ) # type: ignore + colsample_bytree: schema_field( + optimizer_float_field(gt=0.0, le=1.0), + placeholder={ + "optimize": False, + "fixed_value": 1.0, + "lower_bound": 0.5, + "upper_bound": 1.0, + }, + description=MultilingualString( + en=( + "Fraction of features randomly sampled when building each tree. " + "Values below 1.0 decorrelate the trees, reducing overfitting." + ), + es=( + "Fracción de características muestreadas aleatoriamente al " + "construir cada árbol. Valores menores a 1.0 decorrelacionan los " + "árboles, reduciendo el sobreajuste." + ), + pt=( + "Fração de atributos amostrados aleatoriamente ao construir cada " + "árvore. Valores abaixo de 1.0 descorrelacionam as árvores, " + "reduzindo o overfitting." + ), + de=( + "Anteil der Merkmale, die zufällig beim Aufbau jedes Baums " + "ausgewählt werden. Werte unter 1,0 dekorrelieren die Bäume und " + "verringern Overfitting." + ), + zh="构建每棵树时随机采样的特征比例。小于1.0的值可以降低树之间的相关性,减少过拟合。", + ), + alias=MultilingualString( + en="Column subsample by tree", + es="Submuestra de columnas por árbol", + pt="Subamostra de colunas por árvore", + de="Spalten-Teilstichprobe pro Baum", + zh="每棵树的列子采样比例", + ), + ) # type: ignore + reg_alpha: schema_field( + optimizer_float_field(ge=0.0), + placeholder={ + "optimize": False, + "fixed_value": 0.0, + "lower_bound": 0.0, + "upper_bound": 5.0, + }, + description=MultilingualString( + en="L1 regularization term on the tree leaf weights. Use 0 for none.", + es=( + "Término de regularización L1 sobre los pesos de las hojas del " + "árbol. Use 0 para no aplicar regularización." + ), + pt=( + "Termo de regularização L1 sobre os pesos das folhas da árvore. " + "Use 0 para não aplicar regularização." + ), + de=( + "L1-Regularisierungsterm für die Blattgewichte des Baums. " + "Verwenden Sie 0 für keine Regularisierung." + ), + zh="树叶节点权重的L1正则化项。使用0表示不正则化。", + ), + alias=MultilingualString( + en="L1 regularization", + es="Regularización L1", + pt="Regularização L1", + de="L1-Regularisierung", + zh="L1正则化", + ), + ) # type: ignore + reg_lambda: schema_field( + optimizer_float_field(ge=0.0), + placeholder={ + "optimize": False, + "fixed_value": 1.0, + "lower_bound": 0.0, + "upper_bound": 5.0, + }, + description=MultilingualString( + en="L2 regularization term on the tree leaf weights. Use 0 for none.", + es=( + "Término de regularización L2 sobre los pesos de las hojas del " + "árbol. Use 0 para no aplicar regularización." + ), + pt=( + "Termo de regularização L2 sobre os pesos das folhas da árvore. " + "Use 0 para não aplicar regularização." + ), + de=( + "L2-Regularisierungsterm für die Blattgewichte des Baums. " + "Verwenden Sie 0 für keine Regularisierung." + ), + zh="树叶节点权重的L2正则化项。使用0表示不正则化。", + ), + alias=MultilingualString( + en="L2 regularization", + es="Regularización L2", + pt="Regularização L2", + de="L2-Regularisierung", + zh="L2正则化", + ), + ) # type: ignore + min_child_weight: schema_field( + optimizer_float_field(ge=0.0), + placeholder={ + "optimize": False, + "fixed_value": 1.0, + "lower_bound": 0.0, + "upper_bound": 10.0, + }, + description=MultilingualString( + en=( + "Minimum sum of instance weight needed in a child node. Larger " + "values make the algorithm more conservative, helping prevent " + "overfitting on small partitions." + ), + es=( + "Suma mínima de peso de instancia necesaria en un nodo hijo. " + "Valores mayores hacen que el algoritmo sea más conservador, " + "ayudando a prevenir el sobreajuste en particiones pequeñas." + ), + pt=( + "Soma mínima de peso de instância necessária em um nó filho. " + "Valores maiores tornam o algoritmo mais conservador, ajudando a " + "prevenir o overfitting em partições pequenas." + ), + de=( + "Minimale Summe des Instanzgewichts, die in einem Kindknoten " + "benötigt wird. Größere Werte machen den Algorithmus " + "konservativer und beugen Overfitting bei kleinen Partitionen vor." + ), + zh="子节点所需的最小实例权重和。较大的值会使算法更加保守,有助于防止在小分区上过拟合。", + ), + alias=MultilingualString( + en="Min child weight", + es="Peso mínimo del hijo", + pt="Peso mínimo do filho", + de="Minimales Kindgewicht", + zh="最小子节点权重", + ), + ) # type: ignore + + +class _XGBoostDashAIMixin(TabularClassificationModel, SklearnLikeClassifier): + """Combines DashAI's two mixins into a single class. + + ``xgboost.XGBModel.get_params()`` does not use cooperative ``super()`` + dispatch; it inspects ``type(self).__bases__`` directly and assumes + exactly two entries: a mixin without ``get_params`` (normally + ``ClassifierMixin``) followed by the real estimator class. With three + separate bases the lookup mis-resolves to a mixin that lacks + ``get_params`` and raises ``AttributeError``. Folding both DashAI mixins + into one intermediate class keeps that assumption satisfied. + + Note: this class must not have "Base" in its name — DashAI's + ``ComponentRegistry._get_base_type`` matches ancestor classes by that + substring, and a match here would collide with ``BaseModel``. + """ + + +class XGBClassifier(_XGBoostDashAIMixin, _XGBClassifier): + """Extreme Gradient Boosting classifier for tabular data. + + XGBoost is a regularized gradient boosting ensemble that sequentially fits + decision trees to correct the residual errors of the previous trees. It + combines shrinkage (``learning_rate``), row/column subsampling, and L1/L2 + regularization on leaf weights to control overfitting, and is widely used + in tabular machine learning competitions for its speed and accuracy. + + Key hyperparameters include ``n_estimators`` (number of boosting rounds), + ``max_depth``, ``learning_rate``, ``subsample``, ``colsample_bytree``, + ``reg_alpha``, ``reg_lambda``, and ``min_child_weight``. The implementation + wraps ``xgboost.XGBClassifier``. + + References + ---------- + - [1] Chen, T., & Guestrin, C. (2016). "XGBoost: A Scalable Tree Boosting + System." Proceedings of the 22nd ACM SIGKDD International Conference + on Knowledge Discovery and Data Mining, 785-794. + https://doi.org/10.1145/2939672.2939785 + - [2] https://xgboost.readthedocs.io/en/stable/python/python_api.html#xgboost.XGBClassifier + """ + + SCHEMA = XGBClassifierSchema + DISPLAY_NAME: str = MultilingualString( + en="XGBoost", + es="XGBoost", + pt="XGBoost", + de="XGBoost", + zh="XGBoost", + ) + DESCRIPTION: str = MultilingualString( + en=( + "Extreme Gradient Boosting: a fast, regularized tree ensemble widely " + "used in tabular machine learning competitions." + ), + es=( + "Extreme Gradient Boosting: un ensamble de árboles rápido y " + "regularizado, ampliamente usado en competencias de aprendizaje " + "automático tabular." + ), + pt=( + "Extreme Gradient Boosting: um ensemble de árvores rápido e " + "regularizado, amplamente usado em competições de aprendizado de " + "máquina tabular." + ), + de=( + "Extreme Gradient Boosting: ein schnelles, regularisiertes " + "Baum-Ensemble, das häufig in tabellarischen " + "Machine-Learning-Wettbewerben eingesetzt wird." + ), + zh="极端梯度提升:一种快速、正则化的树集成方法,广泛应用于表格化机器学习竞赛。", + ) + COLOR: str = "#D32F2F" + ICON: str = "Whatshot" + + 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 XGBoost wrapper. See + the associated schema class for available keys and their defaults. + """ + kwargs["n_jobs"] = 1 + super().__init__(**kwargs) diff --git a/DashAI/back/tasks/classification_task.py b/DashAI/back/tasks/classification_task.py index 5dc347d5a..b12c3a17f 100644 --- a/DashAI/back/tasks/classification_task.py +++ b/DashAI/back/tasks/classification_task.py @@ -23,6 +23,7 @@ class ClassificationTask(BaseTask): COMPATIBLE_COMPONENTS = [ "Accuracy", + "BalancedAccuracy", "Precision", "Recall", "F1", diff --git a/pyproject.toml b/pyproject.toml index b7b221db6..31a22a7d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ dependencies = [ "pydantic-settings", "starlette", "scikit-learn<1.8.0", + "lightgbm", + "xgboost", "datasets", "diffusers", "evaluate", diff --git a/tests/back/metrics/test_classification_metrics.py b/tests/back/metrics/test_classification_metrics.py index eec97a1e8..b95f8d964 100644 --- a/tests/back/metrics/test_classification_metrics.py +++ b/tests/back/metrics/test_classification_metrics.py @@ -5,6 +5,7 @@ from datasets import Dataset from DashAI.back.metrics.classification.accuracy import Accuracy +from DashAI.back.metrics.classification.balanced_accuracy import BalancedAccuracy from DashAI.back.metrics.classification.f1 import F1 from DashAI.back.metrics.classification.matthews_corrcoef import MatthewsCorrCoef from DashAI.back.metrics.classification.precision import Precision @@ -28,6 +29,32 @@ def test_accuracy(metric_input: Dict[str, List[int]]): assert score <= 1.0 +def test_balanced_accuracy(metric_input: Dict[str, List[int]]): + score = BalancedAccuracy.score( + metric_input["true_labels"], metric_input["pred_labels"] + ) + + assert isinstance(score, float) + assert score >= 0.0 + assert score <= 1.0 + + +def test_balanced_accuracy_matches_sklearn_reference( + metric_input: Dict[str, List[int]], +): + from sklearn.metrics import balanced_accuracy_score + + true = np.array(metric_input["true_labels"]["foo"]) + pred = np.argmax(metric_input["pred_labels"], axis=1) + expected = balanced_accuracy_score(true, pred) + + score = BalancedAccuracy.score( + metric_input["true_labels"], metric_input["pred_labels"] + ) + + assert score == pytest.approx(expected) + + def test_precision(metric_input: Dict[str, List[int]]): score = Precision.score(metric_input["true_labels"], metric_input["pred_labels"]) @@ -90,6 +117,14 @@ def test_metrics_different_input_sizes(metric_input: Dict[str, List[int]]): ): Accuracy.score(metric_input["true_labels"], metric_input["wrong_size_labels"]) + with pytest.raises( + ValueError, + match=error_pattern, + ): + BalancedAccuracy.score( + metric_input["true_labels"], metric_input["wrong_size_labels"] + ) + with pytest.raises( ValueError, match=error_pattern, diff --git a/tests/back/models/test_tabular_class_models.py b/tests/back/models/test_tabular_class_models.py index 3d99798d0..fe67d1737 100644 --- a/tests/back/models/test_tabular_class_models.py +++ b/tests/back/models/test_tabular_class_models.py @@ -24,6 +24,7 @@ GradientBoostingClassifier, ) from DashAI.back.models.scikit_learn.k_neighbors_classifier import KNeighborsClassifier +from DashAI.back.models.scikit_learn.lightgbm_classifier import LGBMClassifier from DashAI.back.models.scikit_learn.linear_svc_classifier import LinearSVCClassifier from DashAI.back.models.scikit_learn.mlp_classifier import MLPClassifier from DashAI.back.models.scikit_learn.random_forest_classifier import ( @@ -31,6 +32,7 @@ ) from DashAI.back.models.scikit_learn.sgd_classifier import SGDClassifier from DashAI.back.models.scikit_learn.svc import SVC +from DashAI.back.models.scikit_learn.xgboost_classifier import XGBClassifier from DashAI.back.types.categorical import Categorical from DashAI.back.types.utils import save_types_in_arrow_metadata from DashAI.back.types.value_types import Float @@ -188,6 +190,26 @@ def fixture_model_params() -> dict: "learning_rate": "optimal", "random_state": 42, }, + "xgboost": { + "n_estimators": 5, + "max_depth": 3, + "learning_rate": 0.3, + "subsample": 1.0, + "colsample_bytree": 1.0, + "reg_alpha": 0.0, + "reg_lambda": 1.0, + "min_child_weight": 1.0, + }, + "lightgbm": { + "n_estimators": 5, + "num_leaves": 15, + "learning_rate": 0.1, + "subsample": 1.0, + "colsample_bytree": 1.0, + "reg_alpha": 0.0, + "reg_lambda": 0.0, + "min_child_samples": 5, + }, } @@ -303,6 +325,12 @@ def test_check_is_fitted_new_classifiers( sgd_model = SGDClassifier(**model_params["sgd"]) sgd_model.train(divided_dataset[0]["train"], divided_dataset[1]["train"]) + xgb_model = XGBClassifier(**model_params["xgboost"]) + xgb_model.train(divided_dataset[0]["train"], divided_dataset[1]["train"]) + + lgbm_model = LGBMClassifier(**model_params["lightgbm"]) + lgbm_model.train(divided_dataset[0]["train"], divided_dataset[1]["train"]) + try: check_is_fitted(gb_model) check_is_fitted(et_model) @@ -312,6 +340,8 @@ def test_check_is_fitted_new_classifiers( check_is_fitted(mlp_model) check_is_fitted(lsvc_model) check_is_fitted(sgd_model) + check_is_fitted(xgb_model) + check_is_fitted(lgbm_model) except Exception as e: pytest.fail( f"Unexpected error in test_check_is_fitted_new_classifiers: {repr(e)}" @@ -353,6 +383,14 @@ def test_predict_new_classifiers( sgd_model.train(divided_dataset[0]["train"], divided_dataset[1]["train"]) y_pred_sgd = sgd_model.predict(divided_dataset[0]["test"]) + xgb_model = XGBClassifier(**model_params["xgboost"]) + xgb_model.train(divided_dataset[0]["train"], divided_dataset[1]["train"]) + y_pred_xgb = xgb_model.predict(divided_dataset[0]["test"]) + + lgbm_model = LGBMClassifier(**model_params["lightgbm"]) + lgbm_model.train(divided_dataset[0]["train"], divided_dataset[1]["train"]) + y_pred_lgbm = lgbm_model.predict(divided_dataset[0]["test"]) + n_test = divided_dataset[0]["test"].num_rows for y_pred in ( y_pred_gb, @@ -363,6 +401,8 @@ def test_predict_new_classifiers( y_pred_mlp, y_pred_lsvc, y_pred_sgd, + y_pred_xgb, + y_pred_lgbm, ): assert isinstance(y_pred, np.ndarray) assert len(y_pred) == n_test @@ -384,6 +424,12 @@ def test_not_fitted_new_classifiers( with pytest.raises(NotFittedError): SGDClassifier(**model_params["sgd"]).predict(divided_dataset[0]["test"]) + with pytest.raises(NotFittedError): + XGBClassifier(**model_params["xgboost"]).predict(divided_dataset[0]["test"]) + + with pytest.raises(NotFittedError): + LGBMClassifier(**model_params["lightgbm"]).predict(divided_dataset[0]["test"]) + def test_get_schema_from_new_classifier_classes(): new_models = ( @@ -395,6 +441,8 @@ def test_get_schema_from_new_classifier_classes(): MLPClassifier, LinearSVCClassifier, SGDClassifier, + XGBClassifier, + LGBMClassifier, ) for model_cls in new_models: schema = model_cls.get_schema() diff --git a/uv.lock b/uv.lock index 75de2dd8d..909e1085d 100644 --- a/uv.lock +++ b/uv.lock @@ -1444,6 +1444,7 @@ dependencies = [ { name = "imblearn" }, { name = "joblib" }, { name = "kink" }, + { name = "lightgbm" }, { name = "llvmlite" }, { name = "numba", version = "0.47.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (platform_machine != 'x86_64' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, { name = "numba", version = "0.66.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or platform_machine != 'x86_64' or sys_platform != 'darwin' or (extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, @@ -1487,6 +1488,8 @@ dependencies = [ { name = "transformers" }, { name = "typer" }, { name = "wordcloud" }, + { name = "xgboost", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, + { name = "xgboost", version = "3.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, { name = "xlrd" }, ] @@ -1539,6 +1542,7 @@ requires-dist = [ { name = "imblearn" }, { name = "joblib" }, { name = "kink" }, + { name = "lightgbm" }, { name = "llama-cpp-python", marker = "extra == 'cpu'", index = "https://abetlen.github.io/llama-cpp-python/whl/cpu", conflict = { package = "dashai", extra = "cpu" } }, { name = "llama-cpp-python", marker = "extra == 'cuda'" }, { name = "llvmlite" }, @@ -1577,6 +1581,7 @@ requires-dist = [ { name = "transformers" }, { name = "typer" }, { name = "wordcloud" }, + { name = "xgboost" }, { name = "xlrd" }, ] provides-extras = ["cpu", "cuda"] @@ -3061,6 +3066,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, ] +[[package]] +name = "lightgbm" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { 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' and platform_machine != 'x86_64') or (python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.11' and platform_machine != 'x86_64' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (python_full_version != '3.11.*' and platform_machine == 'x86_64' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (python_full_version >= '3.11' and sys_platform != 'darwin') or (sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (platform_machine != 'x86_64' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, + { name = "scipy", version = "1.15.3", 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 = "scipy", version = "1.17.1", 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 = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/8e/4db5e29290d7e619c307fdb8dab0a0514090af2ce3ec483050e024ec6126/lightgbm-4.7.0.tar.gz", hash = "sha256:f8e20f682c9aabd000bcf4a7ed8aa6f473c1adfecccae34ec24e823d156f4af0", size = 1792896, upload-time = "2026-07-18T21:00:56.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/05/7213965863cba1ed0150ad045bceed6276a1afaaaedbaeff4699ec4f0ccb/lightgbm-4.7.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:dfc1cfe8e760387be1e7ba7a214688be21fdff96e4ed9749188f83e1877c2477", size = 1877851, upload-time = "2026-07-18T21:00:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/b2/86/f4fe714f2e0bf3941705a20d7f6849dc476276d71236e82ea6b0d6539b86/lightgbm-4.7.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:129535462686f274df179133643118c5c5c5667167fe6c3a28d955f0b3c8e868", size = 1498914, upload-time = "2026-07-18T21:00:36.549Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/b29580948b92e8c2f84dea70118ac702ff067dc52ec4ffb5d73c953536a5/lightgbm-4.7.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4529acec5c6fefe4768302a529707d0ead90f6a6f42df694b856212e09695b8", size = 3349492, upload-time = "2026-07-18T21:00:37.943Z" }, + { url = "https://files.pythonhosted.org/packages/15/eb/837ea3b40cc36e22eeebb9785c01e42b2c255d033eea1d2d9ee8e2540e55/lightgbm-4.7.0-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d23e922acd891e77212e4d0fbcee9ba973c96dee479491341d05ba595357ebb7", size = 3476028, upload-time = "2026-07-18T21:00:39.331Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0b/c5c17d862b12ce292f24cd85d40f2f8f8981668fbdbd43fdc2625eccbc79/lightgbm-4.7.0-py3-none-win_amd64.whl", hash = "sha256:f42d1e5b32b6f170e606d7c689c6165671da98d7bf37f1addec2623efc8740c9", size = 1360833, upload-time = "2026-07-18T21:00:40.865Z" }, +] + [[package]] name = "lightning-utilities" version = "0.15.3" @@ -8922,6 +8949,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/d4/225027a913621a879b429a043674aa35220e6ce67785acad4f7bd0c4ff33/xarray_einstats-0.10.0-py3-none-any.whl", hash = "sha256:fa3169b46cee29092db820d8bbc203148bada4fc970ee75e62cbf3dd7c5a8945", size = 39099, upload-time = "2026-02-19T18:13:53.174Z" }, ] +[[package]] +name = "xgboost" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda'", + "python_full_version < '3.11' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda'", + "python_full_version == '3.11.*' and sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.11.*' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version < '3.11' and sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.11.*' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version < '3.11' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", +] +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 = "nvidia-nccl-cu12", marker = "(python_full_version < '3.12' and sys_platform == 'linux') or (python_full_version >= '3.12' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (sys_platform != 'linux' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, + { name = "scipy", version = "1.15.3", 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 = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*' or (extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/bb/1eb0242409d22db725d7a88088e6cfd6556829fb0736f9ff69aa9f1e9455/xgboost-3.2.0.tar.gz", hash = "sha256:99b0e9a2a64896cdaf509c5e46372d336c692406646d20f2af505003c0c5d70d", size = 1263936, upload-time = "2026-02-10T11:03:05.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/49/6e4cdd877c24adf56cb3586bc96d93d4dcd780b5ea1efb32e1ee0de08bae/xgboost-3.2.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:2f661966d3e322536d9c448090a870fcba1e32ee5760c10b7c46bac7a342079a", size = 2507014, upload-time = "2026-02-10T10:50:57.44Z" }, + { url = "https://files.pythonhosted.org/packages/93/f1/c09ef1add609453aa3ba5bafcd0d1c1a805c1263c0b60138ec968f8ec296/xgboost-3.2.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:eabbd40d474b8dbf6cb3536325f9150b9e6f0db32d18de9914fb3227d0bef5b7", size = 2328527, upload-time = "2026-02-10T10:51:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/96/9f/d9914a7b8df842832850b1a18e5f47aaa071c217cdd1da2ae9deb291018b/xgboost-3.2.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:852eabc6d3b3702a59bf78dbfdcd1cb9c4d3a3b6e5ed1f8781d8b9512354fdd2", size = 131100954, upload-time = "2026-02-10T11:02:42.704Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/679de17c2caa4fd3b0b4386ecf7377301702cb0afb22930a07c142fcb1d8/xgboost-3.2.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:99b4a6bbcb47212fec5cf5fbe12347215f073c08967431b0122cfbd1ee70312c", size = 131748579, upload-time = "2026-02-10T10:54:40.424Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1661dd114a914a67e3f7ab66fa1382e7599c2a8c340f314ad30a3e2b4d08/xgboost-3.2.0-py3-none-win_amd64.whl", hash = "sha256:0d169736fd836fc13646c7ab787167b3a8110351c2c6bc770c755ee1618f0442", size = 101681668, upload-time = "2026-02-10T10:59:31.202Z" }, +] + +[[package]] +name = "xgboost" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda'", + "python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda'", + "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda'", + "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda'", + "(python_full_version >= '3.15' and platform_machine != 'x86_64' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (python_full_version >= '3.15' and sys_platform != 'darwin' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')", + "(python_full_version == '3.14.*' and platform_machine != 'x86_64' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (python_full_version == '3.14.*' and sys_platform != 'darwin' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')", + "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and extra != 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')", + "python_full_version >= '3.15' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version >= '3.15' and sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.14.*' and sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.13.*' and sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.12.*' and sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version >= '3.15' and platform_machine != 'x86_64' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.14.*' and platform_machine != 'x86_64' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.12.*' and platform_machine != 'x86_64' and sys_platform == 'darwin' and extra == 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version >= '3.15' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda'", + "(python_full_version >= '3.15' and platform_machine != 'x86_64' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda') or (python_full_version >= '3.15' and sys_platform != 'darwin' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda')", + "(python_full_version == '3.14.*' and platform_machine != 'x86_64' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda') or (python_full_version == '3.14.*' and sys_platform != 'darwin' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda')", + "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda')", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and extra != 'extra-6-dashai-cpu' and extra != 'extra-6-dashai-cuda')", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and platform_machine != 'x86_64') or (python_full_version < '3.12' and platform_machine != 'x86_64' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (python_full_version >= '3.12' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, + { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (platform_machine != 'x86_64' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (sys_platform != 'darwin' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, + { name = "nvidia-nccl-cu12", marker = "(python_full_version >= '3.12' and sys_platform == 'linux') or (python_full_version < '3.12' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda') or (sys_platform != 'linux' and extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-6-dashai-cpu' and extra == 'extra-6-dashai-cuda')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/41/846d4de2b8fc694073fd3ac5052caf68caa1ea11cb7fa32d7ad9c049b232/xgboost-3.3.0.tar.gz", hash = "sha256:58bcb8a4cace648cdab7b94fa4f16d2c9ff26d90dd4d26907168106fa06d8746", size = 1224702, upload-time = "2026-06-17T21:26:50.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/72/3b68983c0215ef65d48e9eeb1f168c3c6e3d62a61ece605de3209c79cae1/xgboost-3.3.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:07688a377046b8640897b62421150bf73c6cc7101823474ec6ad08b93290f587", size = 2553505, upload-time = "2026-06-17T21:21:32.146Z" }, + { url = "https://files.pythonhosted.org/packages/c9/62/b49e756822b29909d0c95ed334662dc6c7c81a99ec6bc10dc18e69f3d6e7/xgboost-3.3.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:af7cea10f418b7c251ddc8da440f57bdab2990b5fc9f74a35a92b0f150ea287d", size = 2376040, upload-time = "2026-06-17T21:22:01.981Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/a0adcd1ee28f525bd5c9dc3ebe78a7599bf97c22866d6449f967b829e338/xgboost-3.3.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:624a83aeb1e7ba081719795db179f4ce6fff12e79de05cd9baf15ee48fd22f0e", size = 98180629, upload-time = "2026-06-17T21:24:00.804Z" }, + { url = "https://files.pythonhosted.org/packages/47/1f/8b3e578cfd8e3bcdb4374e2bbe0b40b4e5320accb5cbdcf535ecc512eb5c/xgboost-3.3.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f59edaf28eccd1c519788607c72ed907ee6cedfa933d706620bc1612d24b354e", size = 98716607, upload-time = "2026-06-17T21:26:21.058Z" }, + { url = "https://files.pythonhosted.org/packages/07/6b/087fd5d28fdbb90d385c50ee9308a820241b82feebdf42e72e19a48e4b32/xgboost-3.3.0-py3-none-win_amd64.whl", hash = "sha256:b06057f6a018fc04e6b3e0c15568ca636b8151a5b5f333478e500fcaf4fc7594", size = 69522696, upload-time = "2026-06-17T21:20:53.707Z" }, +] + [[package]] name = "xlrd" version = "2.0.2"