datadoc-cli 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.
- datadoc/__init__.py +0 -0
- datadoc/cli/__init__.py +0 -0
- datadoc/cli/app.py +539 -0
- datadoc/core/__init__.py +0 -0
- datadoc/core/engine.py +142 -0
- datadoc/plugins/__init__.py +0 -0
- datadoc/plugins/base.py +70 -0
- datadoc/plugins/datetime_feat.py +102 -0
- datadoc/plugins/encoders.py +73 -0
- datadoc/plugins/missing_values.py +74 -0
- datadoc/plugins/outliers.py +93 -0
- datadoc/plugins/scaling.py +103 -0
- datadoc_cli-0.1.0.dist-info/METADATA +254 -0
- datadoc_cli-0.1.0.dist-info/RECORD +17 -0
- datadoc_cli-0.1.0.dist-info/WHEEL +4 -0
- datadoc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- datadoc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
datadoc/plugins/base.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import polars as pl
|
|
3
|
+
|
|
4
|
+
class BasePlugin(ABC):
|
|
5
|
+
@property
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def name(self) -> str:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def version(self) -> str:
|
|
12
|
+
return "0.1.0"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def description(self) -> str:
|
|
16
|
+
return "No description provided."
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def priority(self) -> int:
|
|
20
|
+
"""Lower number = runs first. Default is 50."""
|
|
21
|
+
return 50
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def supported_datatypes(self) -> list:
|
|
25
|
+
return ["numeric", "categorical"]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def dependencies(self) -> list:
|
|
29
|
+
"""List of plugin names that must run before this one."""
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def analyze(self, df: pl.DataFrame) -> dict:
|
|
34
|
+
"""Analyze the dataframe and return metadata/flags if the plugin should run."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def recommend(self, analysis_result: dict) -> list[str]:
|
|
39
|
+
"""Return a list of recommendations based on the analysis."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def generate_code(self, analysis_result: dict) -> str:
|
|
44
|
+
"""Return the Python code string to replicate this plugin's transformation."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def apply(self, df: pl.DataFrame) -> pl.DataFrame:
|
|
49
|
+
"""Apply the engineering transformation and return the new dataframe."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def validate(self, df: pl.DataFrame) -> bool:
|
|
53
|
+
"""Validate that the transformation produced a valid result."""
|
|
54
|
+
if df.is_empty():
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
def rollback(self, original_df: pl.DataFrame) -> pl.DataFrame:
|
|
59
|
+
"""Return the original dataframe, undoing any transformation."""
|
|
60
|
+
return original_df.clone()
|
|
61
|
+
|
|
62
|
+
def explain(self) -> str:
|
|
63
|
+
"""Return a human-readable explanation of what this plugin does."""
|
|
64
|
+
return f"{self.name} (v{self.version}): {self.description}"
|
|
65
|
+
|
|
66
|
+
def estimate_runtime(self, df: pl.DataFrame) -> float:
|
|
67
|
+
"""Estimate runtime in seconds based on dataset size."""
|
|
68
|
+
rows = df.height
|
|
69
|
+
# Simple linear estimate: ~1ms per 1000 rows
|
|
70
|
+
return round(rows / 1_000_000, 4)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
from datadoc.plugins.base import BasePlugin
|
|
3
|
+
|
|
4
|
+
class DatetimePlugin(BasePlugin):
|
|
5
|
+
@property
|
|
6
|
+
def name(self) -> str:
|
|
7
|
+
return "DatetimePlugin"
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def version(self) -> str:
|
|
11
|
+
return "0.1.0"
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def description(self) -> str:
|
|
15
|
+
return "Detects datetime-like columns and extracts year, month, day, dayofweek features."
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def priority(self) -> int:
|
|
19
|
+
return 30 # After missing values and outliers, before encoding
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def supported_datatypes(self) -> list:
|
|
23
|
+
return ["datetime"]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def dependencies(self) -> list:
|
|
27
|
+
return ["MissingValuePlugin"]
|
|
28
|
+
|
|
29
|
+
def analyze(self, df: pl.DataFrame) -> dict:
|
|
30
|
+
datetime_cols = []
|
|
31
|
+
for col in df.columns:
|
|
32
|
+
if df[col].dtype in [pl.Date, pl.Datetime]:
|
|
33
|
+
datetime_cols.append(col)
|
|
34
|
+
elif df[col].dtype == pl.String:
|
|
35
|
+
try:
|
|
36
|
+
parsed = df.select(pl.col(col).str.to_datetime(strict=False))
|
|
37
|
+
# if more than 50% are successfully parsed
|
|
38
|
+
if parsed.height > 0:
|
|
39
|
+
not_null_ratio = 1 - (parsed[col].null_count() / parsed.height)
|
|
40
|
+
if not_null_ratio > 0.5:
|
|
41
|
+
datetime_cols.append(col)
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"has_datetime": len(datetime_cols) > 0,
|
|
47
|
+
"datetime_columns": datetime_cols,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def recommend(self, analysis_result: dict) -> list[str]:
|
|
51
|
+
recs = []
|
|
52
|
+
if analysis_result.get("has_datetime"):
|
|
53
|
+
cols = analysis_result["datetime_columns"]
|
|
54
|
+
recs.append(
|
|
55
|
+
f"Datetime columns detected: {', '.join(cols)}. "
|
|
56
|
+
f"Recommendation: Extract year, month, day, day_of_week features and drop original."
|
|
57
|
+
)
|
|
58
|
+
return recs
|
|
59
|
+
|
|
60
|
+
def generate_code(self, analysis_result: dict) -> str:
|
|
61
|
+
if not analysis_result.get("has_datetime"):
|
|
62
|
+
return ""
|
|
63
|
+
cols_str = str(analysis_result.get("datetime_columns", []))
|
|
64
|
+
return f"""# Datetime Feature Extraction
|
|
65
|
+
datetime_cols = {cols_str}
|
|
66
|
+
for col in datetime_cols:
|
|
67
|
+
if df[col].dtype == pl.String:
|
|
68
|
+
df = df.with_columns(pl.col(col).str.to_datetime(strict=False).alias(col))
|
|
69
|
+
|
|
70
|
+
df = df.with_columns([
|
|
71
|
+
pl.col(col).dt.year().alias(col + '_year'),
|
|
72
|
+
pl.col(col).dt.month().alias(col + '_month'),
|
|
73
|
+
pl.col(col).dt.day().alias(col + '_day'),
|
|
74
|
+
pl.col(col).dt.weekday().alias(col + '_dayofweek')
|
|
75
|
+
]).drop(col)"""
|
|
76
|
+
|
|
77
|
+
def apply(self, df: pl.DataFrame) -> pl.DataFrame:
|
|
78
|
+
df_clean = df.clone()
|
|
79
|
+
dt_cols = self.analyze(df_clean).get("datetime_columns", [])
|
|
80
|
+
|
|
81
|
+
for col in dt_cols:
|
|
82
|
+
if df_clean[col].dtype == pl.String:
|
|
83
|
+
df_clean = df_clean.with_columns(pl.col(col).str.to_datetime(strict=False).alias(col))
|
|
84
|
+
|
|
85
|
+
df_clean = df_clean.with_columns([
|
|
86
|
+
pl.col(col).dt.year().alias(col + '_year'),
|
|
87
|
+
pl.col(col).dt.month().alias(col + '_month'),
|
|
88
|
+
pl.col(col).dt.day().alias(col + '_day'),
|
|
89
|
+
pl.col(col).dt.weekday().alias(col + '_dayofweek')
|
|
90
|
+
]).drop(col)
|
|
91
|
+
|
|
92
|
+
return df_clean
|
|
93
|
+
|
|
94
|
+
def validate(self, df: pl.DataFrame) -> bool:
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def explain(self) -> str:
|
|
98
|
+
return (
|
|
99
|
+
"DatetimePlugin detects columns containing dates/times and extracts "
|
|
100
|
+
"year, month, day, and day_of_week as new numeric features. "
|
|
101
|
+
"The original datetime column is dropped."
|
|
102
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
from datadoc.plugins.base import BasePlugin
|
|
3
|
+
|
|
4
|
+
class CategoricalEncoderPlugin(BasePlugin):
|
|
5
|
+
@property
|
|
6
|
+
def name(self) -> str:
|
|
7
|
+
return "CategoricalEncoderPlugin"
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def version(self) -> str:
|
|
11
|
+
return "0.1.0"
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def description(self) -> str:
|
|
15
|
+
return "Detects categorical columns and applies One-Hot Encoding (drop_first=True)."
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def priority(self) -> int:
|
|
19
|
+
return 40 # After missing values and outliers
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def supported_datatypes(self) -> list:
|
|
23
|
+
return ["categorical"]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def dependencies(self) -> list:
|
|
27
|
+
return ["MissingValuePlugin"]
|
|
28
|
+
|
|
29
|
+
def analyze(self, df: pl.DataFrame) -> dict:
|
|
30
|
+
cat_cols = [col for col in df.columns if df[col].dtype == pl.String]
|
|
31
|
+
valid_cats = [col for col in cat_cols if df[col].n_unique() < 10 and df[col].n_unique() > 1]
|
|
32
|
+
cardinality = {col: df[col].n_unique() for col in valid_cats}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
"has_categorical": len(valid_cats) > 0,
|
|
36
|
+
"categorical_columns": valid_cats,
|
|
37
|
+
"cardinality": cardinality,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def recommend(self, analysis_result: dict) -> list[str]:
|
|
41
|
+
recs = []
|
|
42
|
+
if analysis_result.get("has_categorical"):
|
|
43
|
+
info = analysis_result.get("cardinality", {})
|
|
44
|
+
detail = ", ".join([f"{c} ({v} unique)" for c, v in info.items()])
|
|
45
|
+
recs.append(
|
|
46
|
+
f"Categorical columns found: {detail}. "
|
|
47
|
+
f"Recommendation: Apply One-Hot Encoding."
|
|
48
|
+
)
|
|
49
|
+
return recs
|
|
50
|
+
|
|
51
|
+
def generate_code(self, analysis_result: dict) -> str:
|
|
52
|
+
if not analysis_result.get("has_categorical"):
|
|
53
|
+
return ""
|
|
54
|
+
cols_str = str(analysis_result.get("categorical_columns", []))
|
|
55
|
+
return f"""# Categorical Encoding (One-Hot)
|
|
56
|
+
cat_cols = {cols_str}
|
|
57
|
+
df = df.to_dummies(columns=cat_cols, drop_first=True)"""
|
|
58
|
+
|
|
59
|
+
def apply(self, df: pl.DataFrame) -> pl.DataFrame:
|
|
60
|
+
df_clean = df.clone()
|
|
61
|
+
cat_cols = self.analyze(df_clean).get("categorical_columns", [])
|
|
62
|
+
if cat_cols:
|
|
63
|
+
df_clean = df_clean.to_dummies(columns=cat_cols, drop_first=True)
|
|
64
|
+
return df_clean
|
|
65
|
+
|
|
66
|
+
def validate(self, df: pl.DataFrame) -> bool:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def explain(self) -> str:
|
|
70
|
+
return (
|
|
71
|
+
"CategoricalEncoderPlugin detects text/string columns with fewer than 10 unique values "
|
|
72
|
+
"and applies One-Hot Encoding with drop_first=True to avoid multicollinearity."
|
|
73
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
from datadoc.plugins.base import BasePlugin
|
|
3
|
+
|
|
4
|
+
class MissingValuePlugin(BasePlugin):
|
|
5
|
+
@property
|
|
6
|
+
def name(self) -> str:
|
|
7
|
+
return "MissingValuePlugin"
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def version(self) -> str:
|
|
11
|
+
return "0.1.0"
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def description(self) -> str:
|
|
15
|
+
return "Detects and imputes missing values using Median (numeric) and Mode (categorical)."
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def priority(self) -> int:
|
|
19
|
+
return 10 # Should run first
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def supported_datatypes(self) -> list:
|
|
23
|
+
return ["numeric", "categorical"]
|
|
24
|
+
|
|
25
|
+
def analyze(self, df: pl.DataFrame) -> dict:
|
|
26
|
+
cols_with_missing = {c: df[c].null_count() for c in df.columns if df[c].null_count() > 0}
|
|
27
|
+
total_missing = sum(cols_with_missing.values())
|
|
28
|
+
return {
|
|
29
|
+
"has_missing_values": bool(total_missing > 0),
|
|
30
|
+
"total_missing": int(total_missing),
|
|
31
|
+
"columns_affected": cols_with_missing,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def recommend(self, analysis_result: dict) -> list[str]:
|
|
35
|
+
recs = []
|
|
36
|
+
if analysis_result.get("has_missing_values"):
|
|
37
|
+
total = analysis_result["total_missing"]
|
|
38
|
+
cols = analysis_result.get("columns_affected", {})
|
|
39
|
+
col_detail = ", ".join([f"{c} ({v})" for c, v in cols.items()])
|
|
40
|
+
recs.append(
|
|
41
|
+
f"Found {total} missing values in: {col_detail}. "
|
|
42
|
+
f"Recommendation: Impute numeric with Median and categorical with Mode."
|
|
43
|
+
)
|
|
44
|
+
return recs
|
|
45
|
+
|
|
46
|
+
def generate_code(self, analysis_result: dict) -> str:
|
|
47
|
+
if not analysis_result.get("has_missing_values"):
|
|
48
|
+
return ""
|
|
49
|
+
return """# Missing Value Imputation
|
|
50
|
+
for col in df.columns:
|
|
51
|
+
if df[col].null_count() > 0:
|
|
52
|
+
if df[col].dtype.is_numeric():
|
|
53
|
+
df = df.with_columns(pl.col(col).fill_null(pl.col(col).median()))
|
|
54
|
+
else:
|
|
55
|
+
df = df.with_columns(pl.col(col).fill_null(pl.col(col).drop_nulls().mode().first()))"""
|
|
56
|
+
|
|
57
|
+
def apply(self, df: pl.DataFrame) -> pl.DataFrame:
|
|
58
|
+
df_clean = df.clone()
|
|
59
|
+
for col in df_clean.columns:
|
|
60
|
+
if df_clean[col].null_count() > 0:
|
|
61
|
+
if df_clean[col].dtype.is_numeric():
|
|
62
|
+
df_clean = df_clean.with_columns(pl.col(col).fill_null(pl.col(col).median()))
|
|
63
|
+
else:
|
|
64
|
+
df_clean = df_clean.with_columns(pl.col(col).fill_null(pl.col(col).drop_nulls().mode().first()))
|
|
65
|
+
return df_clean
|
|
66
|
+
|
|
67
|
+
def validate(self, df: pl.DataFrame) -> bool:
|
|
68
|
+
return bool(sum(df[c].null_count() for c in df.columns) == 0)
|
|
69
|
+
|
|
70
|
+
def explain(self) -> str:
|
|
71
|
+
return (
|
|
72
|
+
"MissingValuePlugin fills missing numeric values with the column median "
|
|
73
|
+
"and missing categorical values with the column mode (most frequent value)."
|
|
74
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
from datadoc.plugins.base import BasePlugin
|
|
3
|
+
|
|
4
|
+
class OutlierPlugin(BasePlugin):
|
|
5
|
+
@property
|
|
6
|
+
def name(self) -> str:
|
|
7
|
+
return "OutlierPlugin"
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def version(self) -> str:
|
|
11
|
+
return "0.1.0"
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def description(self) -> str:
|
|
15
|
+
return "Detects outliers using IQR and clips extreme values at 5th/95th percentiles."
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def priority(self) -> int:
|
|
19
|
+
return 20 # After missing values
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def supported_datatypes(self) -> list:
|
|
23
|
+
return ["numeric"]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def dependencies(self) -> list:
|
|
27
|
+
return ["MissingValuePlugin"]
|
|
28
|
+
|
|
29
|
+
def analyze(self, df: pl.DataFrame) -> dict:
|
|
30
|
+
outlier_info = {}
|
|
31
|
+
num_cols = [c for c in df.columns if df[c].dtype.is_numeric()]
|
|
32
|
+
|
|
33
|
+
for col in num_cols:
|
|
34
|
+
Q1 = df[col].quantile(0.25)
|
|
35
|
+
Q3 = df[col].quantile(0.75)
|
|
36
|
+
if Q1 is None or Q3 is None:
|
|
37
|
+
continue
|
|
38
|
+
IQR = Q3 - Q1
|
|
39
|
+
lower = Q1 - 1.5 * IQR
|
|
40
|
+
upper = Q3 + 1.5 * IQR
|
|
41
|
+
|
|
42
|
+
# Count outliers
|
|
43
|
+
count = df.select(((pl.col(col) < lower) | (pl.col(col) > upper)).sum())[col][0]
|
|
44
|
+
if count and count > 0:
|
|
45
|
+
outlier_info[col] = int(count)
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"has_outliers": len(outlier_info) > 0,
|
|
49
|
+
"outlier_columns": list(outlier_info.keys()),
|
|
50
|
+
"outlier_counts": outlier_info,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def recommend(self, analysis_result: dict) -> list[str]:
|
|
54
|
+
recs = []
|
|
55
|
+
if analysis_result.get("has_outliers"):
|
|
56
|
+
info = analysis_result.get("outlier_counts", {})
|
|
57
|
+
detail = ", ".join([f"{c} ({v} outliers)" for c, v in info.items()])
|
|
58
|
+
recs.append(
|
|
59
|
+
f"Outliers detected in: {detail}. "
|
|
60
|
+
f"Recommendation: Clip values at 5th and 95th percentiles."
|
|
61
|
+
)
|
|
62
|
+
return recs
|
|
63
|
+
|
|
64
|
+
def generate_code(self, analysis_result: dict) -> str:
|
|
65
|
+
if not analysis_result.get("has_outliers"):
|
|
66
|
+
return ""
|
|
67
|
+
cols_str = str(analysis_result.get("outlier_columns", []))
|
|
68
|
+
return f"""# Outlier Clipping
|
|
69
|
+
outlier_cols = {cols_str}
|
|
70
|
+
for col in outlier_cols:
|
|
71
|
+
lower = df[col].quantile(0.05)
|
|
72
|
+
upper = df[col].quantile(0.95)
|
|
73
|
+
if lower is not None and upper is not None:
|
|
74
|
+
df = df.with_columns(pl.col(col).clip(lower, upper))"""
|
|
75
|
+
|
|
76
|
+
def apply(self, df: pl.DataFrame) -> pl.DataFrame:
|
|
77
|
+
df_clean = df.clone()
|
|
78
|
+
outlier_cols = self.analyze(df_clean).get("outlier_columns", [])
|
|
79
|
+
for col in outlier_cols:
|
|
80
|
+
lower = df_clean[col].quantile(0.05)
|
|
81
|
+
upper = df_clean[col].quantile(0.95)
|
|
82
|
+
if lower is not None and upper is not None:
|
|
83
|
+
df_clean = df_clean.with_columns(pl.col(col).clip(lower, upper))
|
|
84
|
+
return df_clean
|
|
85
|
+
|
|
86
|
+
def validate(self, df: pl.DataFrame) -> bool:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def explain(self) -> str:
|
|
90
|
+
return (
|
|
91
|
+
"OutlierPlugin detects statistical outliers using the IQR (Interquartile Range) "
|
|
92
|
+
"method and clips extreme values to the 5th and 95th percentile boundaries."
|
|
93
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
from datadoc.plugins.base import BasePlugin
|
|
3
|
+
|
|
4
|
+
class ScalingPlugin(BasePlugin):
|
|
5
|
+
@property
|
|
6
|
+
def name(self) -> str:
|
|
7
|
+
return "ScalingPlugin"
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def version(self) -> str:
|
|
11
|
+
return "0.1.0"
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def description(self) -> str:
|
|
15
|
+
return "Detects numeric columns with vastly different scales and applies StandardScaler normalization."
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def priority(self) -> int:
|
|
19
|
+
return 45 # Near the end, after encoding
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def supported_datatypes(self) -> list:
|
|
23
|
+
return ["numeric"]
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def dependencies(self) -> list:
|
|
27
|
+
return ["MissingValuePlugin", "OutlierPlugin"]
|
|
28
|
+
|
|
29
|
+
def analyze(self, df: pl.DataFrame) -> dict:
|
|
30
|
+
num_cols = [c for c in df.columns if df[c].dtype.is_numeric()]
|
|
31
|
+
if len(num_cols) < 2:
|
|
32
|
+
return {"has_scale_issues": False, "columns_to_scale": []}
|
|
33
|
+
|
|
34
|
+
stds = {}
|
|
35
|
+
for col in num_cols:
|
|
36
|
+
std = df[col].std()
|
|
37
|
+
if std is not None:
|
|
38
|
+
stds[col] = std
|
|
39
|
+
|
|
40
|
+
if not stds:
|
|
41
|
+
return {"has_scale_issues": False, "columns_to_scale": []}
|
|
42
|
+
|
|
43
|
+
min_std = min(stds.values())
|
|
44
|
+
max_std = max(stds.values())
|
|
45
|
+
|
|
46
|
+
if min_std == 0:
|
|
47
|
+
cols_to_scale = [c for c, s in stds.items() if s > 0]
|
|
48
|
+
ratio = float('inf')
|
|
49
|
+
else:
|
|
50
|
+
ratio = max_std / min_std
|
|
51
|
+
cols_to_scale = num_cols if ratio > 10 else []
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"has_scale_issues": len(cols_to_scale) > 0,
|
|
55
|
+
"columns_to_scale": cols_to_scale,
|
|
56
|
+
"scale_ratio": round(ratio, 2) if ratio != float('inf') else ratio,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def recommend(self, analysis_result: dict) -> list[str]:
|
|
60
|
+
recs = []
|
|
61
|
+
if analysis_result.get("has_scale_issues"):
|
|
62
|
+
ratio = analysis_result.get("scale_ratio", 0)
|
|
63
|
+
cols = analysis_result["columns_to_scale"]
|
|
64
|
+
recs.append(
|
|
65
|
+
f"Numeric columns have a scale ratio of {ratio}x across {len(cols)} columns. "
|
|
66
|
+
f"Recommendation: Apply Standard Scaling (zero mean, unit variance)."
|
|
67
|
+
)
|
|
68
|
+
return recs
|
|
69
|
+
|
|
70
|
+
def generate_code(self, analysis_result: dict) -> str:
|
|
71
|
+
if not analysis_result.get("has_scale_issues"):
|
|
72
|
+
return ""
|
|
73
|
+
cols_str = str(analysis_result.get("columns_to_scale", []))
|
|
74
|
+
return f"""# Standard Scaling
|
|
75
|
+
scale_cols = {cols_str}
|
|
76
|
+
exprs = [((pl.col(c) - pl.col(c).mean()) / pl.col(c).std()).alias(c) for c in scale_cols]
|
|
77
|
+
if exprs:
|
|
78
|
+
df = df.with_columns(exprs)"""
|
|
79
|
+
|
|
80
|
+
def apply(self, df: pl.DataFrame) -> pl.DataFrame:
|
|
81
|
+
df_clean = df.clone()
|
|
82
|
+
cols_to_scale = self.analyze(df_clean).get("columns_to_scale", [])
|
|
83
|
+
|
|
84
|
+
exprs = []
|
|
85
|
+
for col in cols_to_scale:
|
|
86
|
+
exprs.append(
|
|
87
|
+
((pl.col(col) - pl.col(col).mean()) / pl.col(col).std()).alias(col)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if exprs:
|
|
91
|
+
df_clean = df_clean.with_columns(exprs)
|
|
92
|
+
|
|
93
|
+
return df_clean
|
|
94
|
+
|
|
95
|
+
def validate(self, df: pl.DataFrame) -> bool:
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
def explain(self) -> str:
|
|
99
|
+
return (
|
|
100
|
+
"ScalingPlugin detects when numeric columns have vastly different scales "
|
|
101
|
+
"(std ratio > 10x) and applies Standard Scaling: (value - mean) / std, "
|
|
102
|
+
"resulting in zero-mean, unit-variance features."
|
|
103
|
+
)
|