File size: 11,879 Bytes
a4a265d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
import pandas as pd
import numpy as np
from typing import Dict, List, Tuple, Any
from src.monitoring.logger import get_logger

logger = get_logger(__name__)

# Cardinality thresholds
HIGH_CARDINALITY_THRESHOLD = 20
MAX_CARDINALITY_LIMIT = 200
NAN_DROP_THRESHOLD = 0.9     # drop columns with >90% NaN
MIN_VARIANCE_THRESHOLD = 1e-8  # drop near-constant numeric columns


class DataProfiler:
    """Intelligently profiles and optimizes DataFrames for machine learning pipelines."""

    @staticmethod
    def suggest_target_column(df: pd.DataFrame) -> str:
        """Heuristically guesses the target column (the 'y' variable)."""
        cols_lower = [str(c).lower() for c in df.columns]
        
        # 1. Exact matches for common target names (highest priority)
        priority_keywords = ['target', 'label', 'y', 'class', 'status', 'default']
        for kw in priority_keywords:
            if kw in cols_lower:
                return df.columns[cols_lower.index(kw)]
                
        # 2. Substring matches for prediction targets
        substring_keywords = ['price', 'output', 'prediction', 'result', 'revenue', 'score', 'outcome', 'value']
        for kw in substring_keywords:
            for i, col in enumerate(cols_lower):
                if kw in col:
                    return df.columns[i]
        
        # 3. Default to the last column (Standard ML practice)
        return df.columns[-1]

    @staticmethod
    def extract_datetime_features(df: pd.DataFrame, target_col: str) -> pd.DataFrame:
        """
        Detects date-like strings and extracts numeric features (Year, Month, Day, DayOfWeek).
        """
        for col in df.columns:
            if col == target_col:
                continue
            
            if df[col].dtype == object:
                # Heuristic: try to parse as datetime if string looks like it contains dates
                # (simple check: at least 30% of the data should be parseable)
                sample = df[col].dropna().head(100)
                if len(sample) == 0: continue
                
                try:
                    # Attempt to convert to datetime
                    dates = pd.to_datetime(df[col], errors='coerce')
                    valid_ratio = dates.notna().sum() / len(df)
                    
                    if valid_ratio > 0.3:
                        logger.info(f"Extracting temporal features from '{col}' (valid date ratio: {valid_ratio:.1%})")
                        df[f"{col}_year"] = dates.dt.year.fillna(dates.dt.year.median() if not dates.dt.year.isna().all() else 0).astype(int)
                        df[f"{col}_month"] = dates.dt.month.fillna(0).astype(int)
                        df[f"{col}_day"] = dates.dt.day.fillna(0).astype(int)
                        df[f"{col}_dow"] = dates.dt.dayofweek.fillna(0).astype(int)
                        df = df.drop(columns=[col])
                except Exception as e:
                    logger.debug(f"Failed to parse '{col}' as datetime: {e}")
        
        return df

    @staticmethod
    def drop_bad_columns(df: pd.DataFrame, target_col: str) -> pd.DataFrame:
        """
        Drops columns that are useless or harmful before training:
        - >90% NaN
        - Constant / near-zero variance (numeric)
        - Likely ID columns (unique ratio > 95% AND integer/object)
        """
        to_drop = []
        n = len(df)

        for col in df.columns:
            if col == target_col:
                continue

            nan_ratio = df[col].isna().sum() / max(n, 1)
            if nan_ratio > NAN_DROP_THRESHOLD:
                logger.warning(f"Dropping '{col}': {nan_ratio:.1%} NaN values.")
                to_drop.append(col)
                continue

            if pd.api.types.is_numeric_dtype(df[col]):
                filled = df[col].dropna()
                if len(filled) > 0:
                    # Near-constant columns (zero useful information)
                    if filled.std() < MIN_VARIANCE_THRESHOLD:
                        logger.warning(f"Dropping '{col}': near-constant (std≈0).")
                        to_drop.append(col)
                        continue

                    # True ID columns: sequential integers (1,2,3...) OR name says "id/index"
                    # NOT high-cardinality real features like sqft, price, score
                    is_id_named = any(kw in col.lower() for kw in ["_id", "id_", " id", "index", "rownum", "row_num"])
                    if pd.api.types.is_integer_dtype(df[col]) and not is_id_named:
                        col_min, col_max = int(filled.min()), int(filled.max())
                        unique_count = filled.nunique()
                        # Sequential IDs have max-min+1 ≈ count (range matches count)
                        is_sequential = (col_max - col_min + 1) <= n * 1.05 and unique_count >= n * 0.95
                        if is_sequential:
                            logger.warning(f"Dropping '{col}': sequential integer ID column (range={col_min}-{col_max}).")
                            to_drop.append(col)
                            continue
                    elif is_id_named:
                        unique_ratio = filled.nunique() / max(n, 1)
                        if unique_ratio > 0.95:
                            logger.warning(f"Dropping '{col}': ID-named column with unique={unique_ratio:.1%}.")
                            to_drop.append(col)
                            continue
            else:
                # Object/string columns where every row is unique → IDs or free text
                unique_ratio = df[col].nunique() / max(n, 1)
                is_id_named = any(kw in col.lower() for kw in ["_id", "id_", " id", "index", "name", "uuid"])
                if unique_ratio > 0.95 and is_id_named:
                    logger.warning(f"Dropping '{col}': unique string ID column (unique={unique_ratio:.1%}).")
                    to_drop.append(col)
                    continue

        if to_drop:
            df = df.drop(columns=to_drop)
            logger.info(f"Dropped {len(to_drop)} low-quality columns: {to_drop}")
        return df


    @staticmethod
    def optimize_memory(df: pd.DataFrame) -> pd.DataFrame:
        start_mem = df.memory_usage(deep=True).sum() / 1024 ** 2
        logger.info(f"Memory processing started: {start_mem:.2f} MB")

        for col in df.columns:
            col_type = df[col].dtype

            if col_type == object:
                n_unique = df[col].nunique()
                n_total = len(df[col])
                if n_unique / max(n_total, 1) < 0.5:
                    df[col] = df[col].astype("category")
            elif str(col_type).startswith("int"):
                c_min, c_max = df[col].min(), df[col].max()
                if c_min > np.iinfo(np.int8).min and c_max < np.iinfo(np.int8).max:
                    df[col] = df[col].astype(np.int8)
                elif c_min > np.iinfo(np.int16).min and c_max < np.iinfo(np.int16).max:
                    df[col] = df[col].astype(np.int16)
                elif c_min > np.iinfo(np.int32).min and c_max < np.iinfo(np.int32).max:
                    df[col] = df[col].astype(np.int32)
            elif str(col_type).startswith("float"):
                df[col] = df[col].astype(np.float32)

        end_mem = df.memory_usage(deep=True).sum() / 1024 ** 2
        pct = (start_mem - end_mem) / max(start_mem, 1e-9) * 100
        logger.info(f"Memory processing completed: {end_mem:.2f} MB (↓{pct:.1f}%)")
        return df

    @staticmethod
    def identify_feature_types(df: pd.DataFrame, target_col: str) -> Dict[str, List[str]]:
        """Infers feature types and returns numerical / low_cardinality / high_cardinality / drop lists."""
        features = [col for col in df.columns if col != target_col]

        numerical, low_cardinality, high_cardinality, to_drop = [], [], [], []

        for col in features:
            # Treat category dtype as low cardinality categorical
            if pd.api.types.is_categorical_dtype(df[col]):
                n_unique = df[col].nunique()
                if n_unique > MAX_CARDINALITY_LIMIT:
                    logger.warning(f"'{col}' exceeds cardinality limit ({n_unique}). Dropping.")
                    to_drop.append(col)
                elif n_unique > HIGH_CARDINALITY_THRESHOLD:
                    high_cardinality.append(col)
                else:
                    low_cardinality.append(col)
                continue

            if pd.api.types.is_numeric_dtype(df[col]):
                numerical.append(col)
                continue

            # object dtype
            n_unique = df[col].nunique()
            if n_unique > MAX_CARDINALITY_LIMIT:
                logger.warning(f"'{col}' exceeds cardinality limit ({n_unique}). Dropping.")
                to_drop.append(col)
            elif n_unique > HIGH_CARDINALITY_THRESHOLD:
                high_cardinality.append(col)
            else:
                low_cardinality.append(col)

        return {
            "numerical": numerical,
            "low_cardinality": low_cardinality,
            "high_cardinality": high_cardinality,
            "drop": to_drop,
        }

    @staticmethod
    def profile_and_prepare(df: pd.DataFrame, target_col: str) -> Tuple[pd.DataFrame, pd.Series, Dict[str, List[str]], str]:
        """Full pipeline: drop bad cols → memory optimize → profile → X/y split."""
        # Step 0: Extract temporal features from dates
        df = DataProfiler.extract_datetime_features(df, target_col)

        # Step 1: drop useless columns
        df = DataProfiler.drop_bad_columns(df, target_col)

        # Step 2: fill NaNs in target with mode/median before splitting
        if df[target_col].isna().any():
            if pd.api.types.is_numeric_dtype(df[target_col]):
                df[target_col] = df[target_col].fillna(df[target_col].median())
            else:
                df[target_col] = df[target_col].fillna(df[target_col].mode()[0])

        # Step 3: memory optimize (features only, keep target pristine)
        target_series = df[target_col].copy()
        df_features = df.drop(columns=[target_col])
        df_features = DataProfiler.optimize_memory(df_features)
        df = pd.concat([df_features, target_series], axis=1)

        # Step 4: identify feature types
        feature_layout = DataProfiler.identify_feature_types(df, target_col)

        # Drop extreme cardinality columns
        if feature_layout["drop"]:
            df = df.drop(columns=feature_layout["drop"], errors="ignore")

        # SAVE CLEAN DATA FOR DOWNLOAD
        cleaned_csv = df.to_csv(index=False)

        y = df[target_col]
        X = df.drop(columns=[target_col])
        return X, y, feature_layout, cleaned_csv

    @staticmethod
    def build_feature_schema(df_raw: pd.DataFrame, target_col: str) -> Dict[str, Dict]:
        """
        Builds a feature schema dict for the frontend dynamic form.
        Each feature: { "type": "number"|"text", "values": [...] | null, "sample": any }
        """
        schema = {}
        for col in df_raw.columns:
            if col == target_col:
                continue
            series = df_raw[col].dropna()
            if len(series) == 0:
                continue
            if pd.api.types.is_numeric_dtype(series):
                schema[col] = {
                    "type": "number",
                    "values": None,
                    "sample": float(round(series.median(), 4)),
                }
            else:
                unique_vals = [str(v) for v in series.unique().tolist()[:20]]
                schema[col] = {
                    "type": "text",
                    "values": unique_vals,
                    "sample": str(series.mode()[0]) if len(series) > 0 else "",
                }
        return schema