|
| 1 | +from typing import TYPE_CHECKING, Union |
| 2 | + |
1 | 3 | from sklearn.impute import MissingIndicator as MissingIndicatorOperation |
2 | 4 |
|
3 | 5 | from DashAI.back.converters.category.basic_preprocessing import ( |
|
9 | 11 | from DashAI.back.types.dashai_data_type import DashAIDataType |
10 | 12 | from DashAI.back.types.value_types import Integer |
11 | 13 |
|
| 14 | +if TYPE_CHECKING: |
| 15 | + import pandas as pd |
| 16 | + |
| 17 | + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset |
| 18 | + |
12 | 19 |
|
13 | 20 | class MissingIndicatorSchema(BaseSchema): |
14 | 21 | """Schema for configuring the MissingIndicator converter. |
@@ -75,7 +82,122 @@ def __init__(self, **kwargs): |
75 | 82 | Configuration keyword arguments matching the converter's |
76 | 83 | schema fields. Forwarded to the underlying scikit-learn class. |
77 | 84 | """ |
| 85 | + # Force indicators for all selected features so the user always sees the |
| 86 | + # new column, even when a feature has no missing values (all-False indicator). |
| 87 | + kwargs.setdefault("features", "all") |
78 | 88 | super().__init__(**kwargs) |
| 89 | + # SklearnWrapper.__init__ sets set_output(transform="pandas"), which causes |
| 90 | + # sklearn's __init_subclass__ wrapper to intercept our custom transform and |
| 91 | + # attempt to rename its output using get_feature_names_out() (which returns |
| 92 | + # only the indicator column count, not the combined output count). |
| 93 | + # Reset to "default" so the wrapper returns our DashAIDataset as-is. |
| 94 | + if hasattr(self, "set_output"): |
| 95 | + self.set_output(transform="default") |
| 96 | + |
| 97 | + @staticmethod |
| 98 | + def _normalize_missing(frame: "pd.DataFrame") -> "pd.DataFrame": |
| 99 | + """Return a copy of *frame* where object-column missing values are float NaN. |
| 100 | +
|
| 101 | + HuggingFace/PyArrow stores missing strings as ``None`` (Python), but |
| 102 | + sklearn's ``_get_mask`` uses ``x != x`` which is ``False`` for ``None`` |
| 103 | + (only ``float('nan') != float('nan')`` is ``True``). We also treat |
| 104 | + empty strings as missing to match the dataset-filter behaviour. |
| 105 | + """ |
| 106 | + import numpy as np |
| 107 | + |
| 108 | + frame = frame.copy() |
| 109 | + for col in frame.select_dtypes(include="object").columns: |
| 110 | + frame[col] = frame[col].replace("", np.nan) |
| 111 | + frame[col] = frame[col].where(frame[col].notna(), np.nan) |
| 112 | + return frame |
| 113 | + |
| 114 | + def fit( |
| 115 | + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None |
| 116 | + ) -> "MissingIndicator": |
| 117 | + """Fit after normalising missing values so sklearn detects them.""" |
| 118 | + from DashAI.back.dataloaders.classes.dashai_dataset import to_dashai_dataset |
| 119 | + |
| 120 | + x_pandas = x.to_pandas() if hasattr(x, "to_pandas") else x |
| 121 | + x_clean_ds = to_dashai_dataset(self._normalize_missing(x_pandas)) |
| 122 | + if hasattr(x, "types"): |
| 123 | + x_clean_ds.types = x.types.copy() |
| 124 | + return super().fit(x_clean_ds, y) |
| 125 | + |
| 126 | + def transform( |
| 127 | + self, x: "DashAIDataset", y: Union["DashAIDataset", None] = None |
| 128 | + ) -> "DashAIDataset": |
| 129 | + """Transform x by appending missing-value indicator columns. |
| 130 | +
|
| 131 | + Keeps the original columns intact and appends one boolean indicator |
| 132 | + column per feature that had missing values during fit. Indicator |
| 133 | + columns are named ``missingindicator_<original_col_name>`` so that |
| 134 | + ``_rebuild_dataset_with_transformed_columns`` treats them as *new* |
| 135 | + columns rather than replacements, preserving the original data. |
| 136 | + """ |
| 137 | + import numpy as np |
| 138 | + import pandas as pd |
| 139 | + |
| 140 | + from DashAI.back.dataloaders.classes.dashai_dataset import to_dashai_dataset |
| 141 | + |
| 142 | + x_pandas = x.to_pandas() if hasattr(x, "to_pandas") else x |
| 143 | + |
| 144 | + # Normalise missing values before sklearn sees the data (None and "" |
| 145 | + # are both treated as missing, matching dataset-filter behaviour). |
| 146 | + x_for_sklearn = self._normalize_missing(x_pandas) |
| 147 | + |
| 148 | + sklearn_cls = next( |
| 149 | + ( |
| 150 | + cls |
| 151 | + for cls in type(self).__mro__ |
| 152 | + if "sklearn" in cls.__module__ |
| 153 | + and "DashAI" not in cls.__module__ |
| 154 | + and "transform" in cls.__dict__ |
| 155 | + ), |
| 156 | + None, |
| 157 | + ) |
| 158 | + if sklearn_cls is None: |
| 159 | + raise RuntimeError( |
| 160 | + "No sklearn class with a 'transform' method found in the MRO." |
| 161 | + ) |
| 162 | + |
| 163 | + indicators = sklearn_cls.__dict__["transform"](self, x_for_sklearn) |
| 164 | + |
| 165 | + # features_ contains the column indices for which indicators are produced. |
| 166 | + # With features='all' (default), this always equals all input column indices. |
| 167 | + if hasattr(self, "features_") and len(self.features_) > 0: |
| 168 | + indicator_col_names = [ |
| 169 | + f"missingindicator_{x_pandas.columns[i]}" for i in self.features_ |
| 170 | + ] |
| 171 | + else: |
| 172 | + indicator_col_names = [ |
| 173 | + f"missingindicator_{col}" for col in x_pandas.columns |
| 174 | + ] |
| 175 | + |
| 176 | + if isinstance(indicators, np.ndarray): |
| 177 | + indicators_df = pd.DataFrame( |
| 178 | + indicators, |
| 179 | + columns=indicator_col_names, |
| 180 | + index=x_pandas.index, |
| 181 | + ) |
| 182 | + else: |
| 183 | + indicators_df = indicators.copy() |
| 184 | + indicators_df.columns = indicator_col_names |
| 185 | + |
| 186 | + combined_df = pd.concat([x_pandas, indicators_df], axis=1) |
| 187 | + converted_dataset = to_dashai_dataset(combined_df) |
| 188 | + |
| 189 | + output_type = self.get_output_type() |
| 190 | + for col in indicator_col_names: |
| 191 | + if col in converted_dataset.column_names: |
| 192 | + converted_dataset.types[col] = output_type |
| 193 | + |
| 194 | + # Preserve original column types from the input dataset |
| 195 | + if hasattr(x, "types"): |
| 196 | + for col in x_pandas.columns: |
| 197 | + if col in x.types and col in converted_dataset.column_names: |
| 198 | + converted_dataset.types[col] = x.types[col] |
| 199 | + |
| 200 | + return converted_dataset |
79 | 201 |
|
80 | 202 | def get_output_type(self, column_name: str = None) -> DashAIDataType: |
81 | 203 | """Return the DashAI data type produced by this converter for a column. |
|
0 commit comments