data-advisor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data_advisor/rules.py ADDED
@@ -0,0 +1,380 @@
1
+ """
2
+ rules.py - Heuristic decision engine for automated data diagnostics and modeling strategy.
3
+
4
+ Translates raw profiling metrics into high-impact, exam-winning recommendations
5
+ for cleaning, transformations, feature engineering, and baseline modeling.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass
11
+ from typing import Any, Dict, List, Optional
12
+
13
+
14
+ @dataclass
15
+ class Recommendation:
16
+ """Standardized recommendation container."""
17
+ category: str # 'cleaning', 'transformation', 'encoding', 'modeling', 'testing'
18
+ severity: str # 'CRITICAL', 'WARNING', 'RECOMMENDED', 'INFO'
19
+ column: Optional[str]
20
+ issue: str
21
+ action: str
22
+ code_hint: str
23
+ rationale: str
24
+
25
+ def to_dict(self) -> Dict[str, Any]:
26
+ return asdict(self)
27
+
28
+
29
+ class RuleEngine:
30
+ """
31
+ Evaluates scan results against industry-standard data science heuristics
32
+ and produces prioritized, actionable advice.
33
+ """
34
+
35
+ def __init__(self, scan_results: Dict[str, Any]) -> None:
36
+ self.scan = scan_results
37
+ self.overview = scan_results.get("overview", {})
38
+ self.missing = scan_results.get("missing_values", {})
39
+ self.dtypes = scan_results.get("data_types", {})
40
+ self.distributions = scan_results.get("distributions", {})
41
+ self.categoricals = scan_results.get("categoricals", {})
42
+ self.target = scan_results.get("target")
43
+ self.hypothesis_tests = scan_results.get("hypothesis_tests", [])
44
+
45
+ # -------------------------------------------------------------------------
46
+ # 1. Cleaning & Auditing Rules
47
+ # -------------------------------------------------------------------------
48
+ def evaluate_cleaning_rules(self) -> List[Recommendation]:
49
+ recs: List[Recommendation] = []
50
+
51
+ # Duplicate Rows
52
+ dup_count = self.overview.get("duplicate_rows", 0)
53
+ dup_pct = self.overview.get("duplicate_pct", 0.0)
54
+ if dup_count > 0:
55
+ severity = "CRITICAL" if dup_pct > 5.0 else "WARNING"
56
+ recs.append(Recommendation(
57
+ category="cleaning",
58
+ severity=severity,
59
+ column=None,
60
+ issue=f"Found {dup_count} duplicate rows ({dup_pct}% of dataset).",
61
+ action="Deduplicate dataset before analysis or modeling.",
62
+ code_hint="df = df.drop_duplicates().reset_index(drop=True)",
63
+ rationale="Duplicate rows cause artificial data leakage and bias model evaluations.",
64
+ ))
65
+
66
+ # Constant Columns
67
+ for col in self.dtypes.get("constant_columns", []):
68
+ recs.append(Recommendation(
69
+ category="cleaning",
70
+ severity="WARNING",
71
+ column=col,
72
+ issue=f"Column '{col}' has zero variance (constant value).",
73
+ action=f"Drop column '{col}'.",
74
+ code_hint=f"df = df.drop(columns=['{col}'])",
75
+ rationale="Zero variance features provide zero predictive signal and cause multicollinearity.",
76
+ ))
77
+
78
+ # High-Risk / High-Cardinality ID Columns
79
+ for col in self.dtypes.get("potential_ids", []):
80
+ recs.append(Recommendation(
81
+ category="cleaning",
82
+ severity="WARNING",
83
+ column=col,
84
+ issue=f"Column '{col}' appears to be a unique identifier / primary key.",
85
+ action=f"Exclude '{col}' from model training features.",
86
+ code_hint=f"id_col = '{col}'\ndf_features = df.drop(columns=['{col}'])",
87
+ rationale="Identifiers cause spurious correlation, extreme overfitting, and data leakage.",
88
+ ))
89
+
90
+ # Missing Value Strategies
91
+ for col, info in self.missing.items():
92
+ pct = info.get("null_pct", 0.0)
93
+ count = info.get("null_count", 0)
94
+ is_num = info.get("is_numeric", False)
95
+
96
+ if pct == 0.0:
97
+ continue
98
+
99
+ if pct >= 60.0:
100
+ recs.append(Recommendation(
101
+ category="cleaning",
102
+ severity="CRITICAL",
103
+ column=col,
104
+ issue=f"Column '{col}' has {pct}% missing values ({count} rows).",
105
+ action=f"Drop column '{col}' due to high sparsity (>60%).",
106
+ code_hint=f"df = df.drop(columns=['{col}'])",
107
+ rationale="Imputing >60% missing data injects massive noise and distorts distributions.",
108
+ ))
109
+ elif pct < 5.0:
110
+ action_text = "Impute with median or drop missing rows." if is_num else "Impute with mode or drop missing rows."
111
+ code_text = (
112
+ f"df['{col}'] = df['{col}'].fillna(df['{col}'].median())"
113
+ if is_num
114
+ else f"df['{col}'] = df['{col}'].fillna(df['{col}'].mode()[0])"
115
+ )
116
+ recs.append(Recommendation(
117
+ category="cleaning",
118
+ severity="INFO",
119
+ column=col,
120
+ issue=f"Column '{col}' has minor missing values: {pct}% ({count} rows).",
121
+ action=action_text,
122
+ code_hint=code_text,
123
+ rationale="Low missingness (<5%) can be safely imputed with median/mode without altering distribution shape.",
124
+ ))
125
+ else:
126
+ # 5% to 60% missing
127
+ if is_num:
128
+ dist = self.distributions.get(col, {})
129
+ skew = abs(dist.get("skewness", 0.0))
130
+ strategy = "median" if skew > 1.0 else "mean"
131
+ recs.append(Recommendation(
132
+ category="cleaning",
133
+ severity="WARNING",
134
+ column=col,
135
+ issue=f"Column '{col}' has moderate missing values: {pct}% ({count} rows).",
136
+ action=f"Impute with {strategy} (or use KNNImputer).",
137
+ code_hint=f"from sklearn.impute import SimpleImputer\nimputer = SimpleImputer(strategy='{strategy}')\ndf['{col}'] = imputer.fit_transform(df[['{col}']])",
138
+ rationale=f"Skewness is {dist.get('skewness', 0.0):.2f}. Median is robust to skew; mean is suitable for symmetric data.",
139
+ ))
140
+ else:
141
+ recs.append(Recommendation(
142
+ category="cleaning",
143
+ severity="WARNING",
144
+ column=col,
145
+ issue=f"Categorical column '{col}' has {pct}% missing values.",
146
+ action="Impute with mode or treat missingness as an explicit category ('Unknown').",
147
+ code_hint=f"df['{col}'] = df['{col}'].fillna('Missing')",
148
+ rationale="Preserving missingness as 'Missing' allows models to capture potential informative missingness bias.",
149
+ ))
150
+
151
+ # Date string mismatches
152
+ for col in self.dtypes.get("potential_dates", []):
153
+ recs.append(Recommendation(
154
+ category="cleaning",
155
+ severity="RECOMMENDED",
156
+ column=col,
157
+ issue=f"Column '{col}' contains date-like strings but is stored as object/string.",
158
+ action=f"Convert '{col}' to datetime and extract temporal components.",
159
+ code_hint=(
160
+ f"df['{col}'] = pd.to_datetime(df['{col}'], errors='coerce')\n"
161
+ f"df['{col}_year'] = df['{col}'].dt.year\n"
162
+ f"df['{col}_month'] = df['{col}'].dt.month\n"
163
+ f"df['{col}_dayofweek'] = df['{col}'].dt.dayofweek"
164
+ ),
165
+ rationale="Machine learning models cannot process raw date strings; cyclic/temporal features extract high predictive value.",
166
+ ))
167
+
168
+ return recs
169
+
170
+ # -------------------------------------------------------------------------
171
+ # 2. Distribution & Outlier Transformation Rules
172
+ # -------------------------------------------------------------------------
173
+ def evaluate_distribution_rules(self) -> List[Recommendation]:
174
+ recs: List[Recommendation] = []
175
+
176
+ for col, dist in self.distributions.items():
177
+ skew = dist.get("skewness", 0.0)
178
+ kurt = dist.get("kurtosis", 0.0)
179
+ is_non_neg = dist.get("is_non_negative", True)
180
+ iqr_pct = dist.get("iqr_outlier_pct", 0.0)
181
+ iqr_cnt = dist.get("iqr_outliers", 0)
182
+ lower_fence = dist.get("lower_fence", 0.0)
183
+ upper_fence = dist.get("upper_fence", 0.0)
184
+
185
+ # Heavy Positive Skewness
186
+ if skew > 1.2:
187
+ if is_non_neg:
188
+ recs.append(Recommendation(
189
+ category="transformation",
190
+ severity="RECOMMENDED",
191
+ column=col,
192
+ issue=f"Column '{col}' is heavily right-skewed (skewness = {skew:.2f}).",
193
+ action="Apply log1p transformation to normalize distribution.",
194
+ code_hint=f"df['{col}_log'] = np.log1p(df['{col}'])",
195
+ rationale="Log transformation stabilizes variance and minimizes leverage of extreme positive values in linear/distance models.",
196
+ ))
197
+ else:
198
+ recs.append(Recommendation(
199
+ category="transformation",
200
+ severity="RECOMMENDED",
201
+ column=col,
202
+ issue=f"Column '{col}' is right-skewed (skewness = {skew:.2f}) and contains negative values.",
203
+ action="Apply Yeo-Johnson power transformation.",
204
+ code_hint=(
205
+ f"from sklearn.preprocessing import PowerTransformer\n"
206
+ f"pt = PowerTransformer(method='yeo-johnson')\n"
207
+ f"df['{col}_transformed'] = pt.fit_transform(df[['{col}']])"
208
+ ),
209
+ rationale="Yeo-Johnson supports zero and negative values while normalizing non-Gaussian distributions.",
210
+ ))
211
+ elif skew < -1.2:
212
+ recs.append(Recommendation(
213
+ category="transformation",
214
+ severity="RECOMMENDED",
215
+ column=col,
216
+ issue=f"Column '{col}' is heavily left-skewed (skewness = {skew:.2f}).",
217
+ action="Apply Yeo-Johnson transformation or upper/lower bound clipping.",
218
+ code_hint=(
219
+ f"from sklearn.preprocessing import PowerTransformer\n"
220
+ f"pt = PowerTransformer(method='yeo-johnson')\n"
221
+ f"df['{col}_transformed'] = pt.fit_transform(df[['{col}']])"
222
+ ),
223
+ rationale="Left-skewed distributions violate normality assumptions in parametric tests and models.",
224
+ ))
225
+
226
+ # Severe Outliers
227
+ if iqr_pct > 3.0:
228
+ recs.append(Recommendation(
229
+ category="transformation",
230
+ severity="WARNING",
231
+ column=col,
232
+ issue=f"Column '{col}' has {iqr_cnt} IQR outliers ({iqr_pct}%).",
233
+ action=f"Clip values to IQR fences [{lower_fence:.2f}, {upper_fence:.2f}] or scale with RobustScaler.",
234
+ code_hint=(
235
+ f"df['{col}_clipped'] = df['{col}'].clip(lower={lower_fence}, upper={upper_fence})"
236
+ ),
237
+ rationale="Extreme outliers distort mean, inflate standard error, and destabilize gradient-based estimators.",
238
+ ))
239
+
240
+ return recs
241
+
242
+ # -------------------------------------------------------------------------
243
+ # 3. Categorical Processing Rules
244
+ # -------------------------------------------------------------------------
245
+ def evaluate_categorical_rules(self) -> List[Recommendation]:
246
+ recs: List[Recommendation] = []
247
+
248
+ for col, cat in self.categoricals.items():
249
+ cardinality = cat.get("cardinality", 0)
250
+
251
+ if cardinality == 2:
252
+ samples = cat.get("sample_values", [])
253
+ recs.append(Recommendation(
254
+ category="encoding",
255
+ severity="RECOMMENDED",
256
+ column=col,
257
+ issue=f"Binary categorical column '{col}' (values: {samples}).",
258
+ action="Map directly to binary 0/1 integers.",
259
+ code_hint=(
260
+ f"mapping = {{{repr(samples[0])}: 0, {repr(samples[1])}: 1}}\n"
261
+ f"df['{col}_encoded'] = df['{col}'].map(mapping)"
262
+ ),
263
+ rationale="Binary integer mapping avoids redundant dimensions while maintaining zero information loss.",
264
+ ))
265
+ elif 3 <= cardinality <= 10:
266
+ recs.append(Recommendation(
267
+ category="encoding",
268
+ severity="RECOMMENDED",
269
+ column=col,
270
+ issue=f"Low-cardinality categorical column '{col}' ({cardinality} levels).",
271
+ action="Use One-Hot Encoding with drop_first=True.",
272
+ code_hint=f"df = pd.get_dummies(df, columns=['{col}'], drop_first=True, dtype=int)",
273
+ rationale="One-hot encoding with drop_first prevents the dummy variable trap (multicollinearity).",
274
+ ))
275
+ elif cardinality > 10:
276
+ recs.append(Recommendation(
277
+ category="encoding",
278
+ severity="WARNING",
279
+ column=col,
280
+ issue=f"High-cardinality categorical column '{col}' ({cardinality} unique levels).",
281
+ action="Apply Frequency Encoding, Target Encoding, or collapse rare categories into 'Other'.",
282
+ code_hint=(
283
+ f"# Frequency Encoding\n"
284
+ f"freq_map = df['{col}'].value_counts(normalize=True)\n"
285
+ f"df['{col}_freq'] = df['{col}'].map(freq_map)\n\n"
286
+ f"# Or Scikit-Learn Target Encoding\n"
287
+ f"# from sklearn.preprocessing import TargetEncoder\n"
288
+ f"# te = TargetEncoder(smooth='auto')\n"
289
+ f"# df['{col}_te'] = te.fit_transform(df[['{col}']], y)"
290
+ ),
291
+ rationale="One-hot encoding high-cardinality columns explodes feature space, causing the curse of dimensionality.",
292
+ ))
293
+
294
+ return recs
295
+
296
+ # -------------------------------------------------------------------------
297
+ # 4. Target Variable & Baseline Modeling Strategy
298
+ # -------------------------------------------------------------------------
299
+ def evaluate_modeling_rules(self) -> List[Recommendation]:
300
+ recs: List[Recommendation] = []
301
+ if not self.target:
302
+ return recs
303
+
304
+ target_col = self.target.get("target_column")
305
+ prob_type = self.target.get("problem_type")
306
+
307
+ if prob_type == "regression":
308
+ recs.append(Recommendation(
309
+ category="modeling",
310
+ severity="INFO",
311
+ column=target_col,
312
+ issue=f"Target '{target_col}' is continuous ({self.target.get('n_samples')} valid samples).",
313
+ action="Deploy Regression Pipeline (Baseline Linear -> Ridge -> RandomForestRegressor).",
314
+ code_hint=(
315
+ "from sklearn.linear_model import LinearRegression, Ridge\n"
316
+ "from sklearn.ensemble import RandomForestRegressor\n"
317
+ "from sklearn.metrics import root_mean_squared_error, mean_absolute_error, r2_score\n\n"
318
+ "models = {\n"
319
+ " 'LinearRegression': LinearRegression(),\n"
320
+ " 'Ridge': Ridge(alpha=1.0),\n"
321
+ " 'RandomForest': RandomForestRegressor(n_estimators=100, random_state=42)\n"
322
+ "}"
323
+ ),
324
+ rationale="Provides both interpretable parametric baselines (OLS/Ridge) and robust non-linear tree ensembles.",
325
+ ))
326
+ elif prob_type == "classification":
327
+ is_imbalanced = self.target.get("is_imbalanced", False)
328
+ imbalance_ratio = self.target.get("imbalance_ratio", 1.0)
329
+ minority_pct = self.target.get("minority_percentage", 50.0)
330
+
331
+ severity = "CRITICAL" if is_imbalanced else "INFO"
332
+ issue_msg = (
333
+ f"Severe target imbalance detected! Minority class: {minority_pct}% (Ratio 1:{imbalance_ratio:.1f})."
334
+ if is_imbalanced
335
+ else f"Balanced classification target ({self.target.get('n_classes')} classes)."
336
+ )
337
+
338
+ action_msg = (
339
+ "Use StratifiedKFold, class_weight='balanced', and optimize for PR-AUC/F1-Score."
340
+ if is_imbalanced
341
+ else "Deploy Classification Pipeline (LogisticRegression -> RandomForestClassifier)."
342
+ )
343
+
344
+ code_hint = (
345
+ "from sklearn.linear_model import LogisticRegression\n"
346
+ "from sklearn.ensemble import RandomForestClassifier\n"
347
+ "from sklearn.metrics import classification_report, roc_auc_score\n\n"
348
+ "models = {\n"
349
+ f" 'LogisticRegression': LogisticRegression(class_weight={'\"balanced\"' if is_imbalanced else 'None'}, max_iter=1000),\n"
350
+ f" 'RandomForest': RandomForestClassifier(n_estimators=100, class_weight={'\"balanced\"' if is_imbalanced else 'None'}, random_state=42)\n"
351
+ "}"
352
+ )
353
+
354
+ recs.append(Recommendation(
355
+ category="modeling",
356
+ severity=severity,
357
+ column=target_col,
358
+ issue=issue_msg,
359
+ action=action_msg,
360
+ code_hint=code_hint,
361
+ rationale="Accuracy is deceptive under class imbalance. Balanced loss penalties and precision-recall metrics prevent majority-class collapse.",
362
+ ))
363
+
364
+ return recs
365
+
366
+ # -------------------------------------------------------------------------
367
+ # 5. Master Recommendation Engine
368
+ # -------------------------------------------------------------------------
369
+ def generate_all_recommendations(self) -> List[Recommendation]:
370
+ """Aggregate all rule checks in order of execution priority."""
371
+ recs: List[Recommendation] = []
372
+ recs.extend(self.evaluate_cleaning_rules())
373
+ recs.extend(self.evaluate_distribution_rules())
374
+ recs.extend(self.evaluate_categorical_rules())
375
+ recs.extend(self.evaluate_modeling_rules())
376
+
377
+ # Sort recommendations by severity priority: CRITICAL -> WARNING -> RECOMMENDED -> INFO
378
+ priority_map = {"CRITICAL": 0, "WARNING": 1, "RECOMMENDED": 2, "INFO": 3}
379
+ recs.sort(key=lambda r: priority_map.get(r.severity, 4))
380
+ return recs