flasheda 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {flasheda-0.3.0 → flasheda-0.4.0}/PKG-INFO +34 -5
- {flasheda-0.3.0 → flasheda-0.4.0}/README.md +22 -2
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/__init__.py +3 -1
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/clean/__init__.py +4 -1
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/clean/detectors.py +1 -2
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/__init__.py +7 -2
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/steps.py +107 -3
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/duckdb_writer.py +27 -4
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/pandas_writer.py +21 -3
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/polars_writer.py +31 -4
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/pyspark_writer.py +26 -3
- flasheda-0.4.0/flasheda/features/__init__.py +169 -0
- flasheda-0.4.0/flasheda/modeling/__init__.py +230 -0
- flasheda-0.4.0/flasheda/modeling/script.py +138 -0
- flasheda-0.4.0/flasheda/modeling/specs.py +79 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/report.py +24 -5
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/PKG-INFO +34 -5
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/SOURCES.txt +5 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/requires.txt +11 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/setup.py +8 -5
- flasheda-0.4.0/tests/test_features_model.py +138 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/tests/test_writers.py +55 -3
- {flasheda-0.3.0 → flasheda-0.4.0}/MIT.License +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/__init__.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/categorical.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/correlation.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/missing.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/numeric.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/overview.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/target.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/planner.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/validate.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/__init__.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/base.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/engine/__init__.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/engine/backends.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/engine/sampler.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/engine/source.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/sampler.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/dependency_links.txt +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/top_level.txt +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/pyproject.toml +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/setup.cfg +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/tests/test_clean_target.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/tests/test_codegen.py +0 -0
- {flasheda-0.3.0 → flasheda-0.4.0}/tests/test_engine.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flasheda
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Fixed-time EDA for any dataset size, plus validated cleaning code for pandas, polars, DuckDB and PySpark.
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Fixed-time EDA for any dataset size, plus validated cleaning, feature and training code for pandas, polars, DuckDB and PySpark.
|
|
5
5
|
Home-page: https://github.com/sjapanjots/Flash-EDA
|
|
6
6
|
Author: Japanjot Singh
|
|
7
7
|
Author-email: sjapanjots@gmail.com
|
|
@@ -9,7 +9,7 @@ License: MIT
|
|
|
9
9
|
Project-URL: Source, https://github.com/sjapanjots/Flash-EDA
|
|
10
10
|
Project-URL: Changelog, https://github.com/sjapanjots/Flash-EDA/blob/main/CHANGELOG.md
|
|
11
11
|
Project-URL: Issues, https://github.com/sjapanjots/Flash-EDA/issues
|
|
12
|
-
Keywords: eda,data-cleaning,pandas,polars,duckdb,pyspark,code-generation
|
|
12
|
+
Keywords: eda,data-cleaning,pandas,polars,duckdb,pyspark,code-generation,feature-engineering,automl
|
|
13
13
|
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Intended Audience :: Science/Research
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
@@ -32,11 +32,20 @@ Requires-Dist: polars>=0.20; extra == "fast"
|
|
|
32
32
|
Requires-Dist: pyarrow>=12; extra == "fast"
|
|
33
33
|
Provides-Extra: spark
|
|
34
34
|
Requires-Dist: pyspark>=3.4; extra == "spark"
|
|
35
|
+
Provides-Extra: ml
|
|
36
|
+
Requires-Dist: scikit-learn>=1.2; extra == "ml"
|
|
37
|
+
Requires-Dist: pyarrow>=12; extra == "ml"
|
|
38
|
+
Provides-Extra: all
|
|
39
|
+
Requires-Dist: duckdb>=0.10; extra == "all"
|
|
40
|
+
Requires-Dist: polars>=0.20; extra == "all"
|
|
41
|
+
Requires-Dist: pyarrow>=12; extra == "all"
|
|
42
|
+
Requires-Dist: scikit-learn>=1.2; extra == "all"
|
|
35
43
|
Provides-Extra: dev
|
|
36
44
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
37
45
|
Requires-Dist: duckdb>=0.10; extra == "dev"
|
|
38
46
|
Requires-Dist: polars>=0.20; extra == "dev"
|
|
39
47
|
Requires-Dist: pyarrow>=12; extra == "dev"
|
|
48
|
+
Requires-Dist: scikit-learn>=1.2; extra == "dev"
|
|
40
49
|
Requires-Dist: build; extra == "dev"
|
|
41
50
|
Requires-Dist: twine; extra == "dev"
|
|
42
51
|
Dynamic: author
|
|
@@ -63,9 +72,9 @@ Dynamic: summary
|
|
|
63
72
|
[](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
|
|
64
73
|
[](MIT.License)
|
|
65
74
|
|
|
66
|
-
**Current version: 0.
|
|
75
|
+
**Current version: 0.4.0** · [Changelog](CHANGELOG.md)
|
|
67
76
|
|
|
68
|
-
Analyse any dataset in a few seconds, then get **validated cleaning code** that runs on the full data, in the engine that fits its size: pandas, polars, DuckDB or PySpark.
|
|
77
|
+
Analyse any dataset in a few seconds, then get **validated cleaning, feature and training code** that runs on the full data, in the engine that fits its size: pandas, polars, DuckDB or PySpark.
|
|
69
78
|
|
|
70
79
|
FlashEDA analyses a fixed-size random sample, so analysis time barely changes between 100 thousand and 100 million rows.
|
|
71
80
|
|
|
@@ -74,7 +83,9 @@ FlashEDA analyses a fixed-size random sample, so analysis time barely changes be
|
|
|
74
83
|
```bash
|
|
75
84
|
pip install flasheda # core
|
|
76
85
|
pip install "flasheda[fast]" # + polars, DuckDB, pyarrow (recommended for big files)
|
|
86
|
+
pip install "flasheda[ml]" # + scikit-learn, for flasheda.train
|
|
77
87
|
pip install "flasheda[spark]" # + PySpark
|
|
88
|
+
pip install "flasheda[all]" # everything except PySpark
|
|
78
89
|
```
|
|
79
90
|
|
|
80
91
|
## Quick start
|
|
@@ -90,6 +101,11 @@ report.save_html("report.html") # or report.save_pdf("report.pdf")
|
|
|
90
101
|
# 2. Clean: get a script for the full data, already checked on the sample
|
|
91
102
|
code = fe.clean("orders.csv", target="churn", engine="polars")
|
|
92
103
|
code.save("clean_orders.py") # then: python clean_orders.py
|
|
104
|
+
|
|
105
|
+
# 3. Model: compare models on the sample, get prep + training scripts
|
|
106
|
+
result = fe.train("orders.csv", target="churn", engine="polars")
|
|
107
|
+
result.show() # leaderboard
|
|
108
|
+
result.save("prep.py", "train.py") # python prep.py && python train.py → model.joblib
|
|
93
109
|
```
|
|
94
110
|
|
|
95
111
|
## What it does
|
|
@@ -102,6 +118,8 @@ code.save("clean_orders.py") # then: python clean_orders.py
|
|
|
102
118
|
| **Correlations** | Pearson (numeric) and Cramér's V (categorical) |
|
|
103
119
|
| **Reports** | console, HTML, PDF, JSON |
|
|
104
120
|
| **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
|
|
121
|
+
| **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
|
|
122
|
+
| **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
|
|
105
123
|
| **Validation** | generated code is run on the sample and compared with a reference result before you get it |
|
|
106
124
|
|
|
107
125
|
### Cleaning steps
|
|
@@ -115,6 +133,14 @@ Each step in the generated code has a comment saying why it was added:
|
|
|
115
133
|
- add `<col>_was_missing` flags when missingness predicts the target
|
|
116
134
|
- fill nulls (median / mode), log-transform skewed columns, clip outliers
|
|
117
135
|
|
|
136
|
+
### Features and models
|
|
137
|
+
`fe.train()` (or `report.train()`) adds features on top of the cleaning steps, compares models on the sample, and writes two scripts:
|
|
138
|
+
|
|
139
|
+
- **`prep.py`**: cleaning + features for the full data, in the chosen engine
|
|
140
|
+
- **`train.py`**: scikit-learn training with cross-validation, using the same metric and split, that saves `model.joblib`
|
|
141
|
+
|
|
142
|
+
The metric and split follow the target: PR-AUC for imbalanced binary targets, MAE for skewed regression targets, time-based splits when there's a date column, and so on.
|
|
143
|
+
|
|
118
144
|
### Engine choice
|
|
119
145
|
|
|
120
146
|
| Rows | Engine |
|
|
@@ -135,11 +161,14 @@ Measured on synthetic messy data:
|
|
|
135
161
|
| Sample a 1M-row CSV (0.1.x → 0.2.0) | 37.3 s → 0.65 s |
|
|
136
162
|
| Analyse + plan + generate + validate, 1M-row CSV with target | 2.5 s |
|
|
137
163
|
| Run the generated polars script on 1M rows | 0.9 s |
|
|
164
|
+
| `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
|
|
165
|
+
| Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
|
|
138
166
|
|
|
139
167
|
## Examples
|
|
140
168
|
|
|
141
169
|
- [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
|
|
142
170
|
- [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
|
|
171
|
+
- [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
|
|
143
172
|
|
|
144
173
|
## Development
|
|
145
174
|
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
[](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
|
|
8
8
|
[](MIT.License)
|
|
9
9
|
|
|
10
|
-
**Current version: 0.
|
|
10
|
+
**Current version: 0.4.0** · [Changelog](CHANGELOG.md)
|
|
11
11
|
|
|
12
|
-
Analyse any dataset in a few seconds, then get **validated cleaning code** that runs on the full data, in the engine that fits its size: pandas, polars, DuckDB or PySpark.
|
|
12
|
+
Analyse any dataset in a few seconds, then get **validated cleaning, feature and training code** that runs on the full data, in the engine that fits its size: pandas, polars, DuckDB or PySpark.
|
|
13
13
|
|
|
14
14
|
FlashEDA analyses a fixed-size random sample, so analysis time barely changes between 100 thousand and 100 million rows.
|
|
15
15
|
|
|
@@ -18,7 +18,9 @@ FlashEDA analyses a fixed-size random sample, so analysis time barely changes be
|
|
|
18
18
|
```bash
|
|
19
19
|
pip install flasheda # core
|
|
20
20
|
pip install "flasheda[fast]" # + polars, DuckDB, pyarrow (recommended for big files)
|
|
21
|
+
pip install "flasheda[ml]" # + scikit-learn, for flasheda.train
|
|
21
22
|
pip install "flasheda[spark]" # + PySpark
|
|
23
|
+
pip install "flasheda[all]" # everything except PySpark
|
|
22
24
|
```
|
|
23
25
|
|
|
24
26
|
## Quick start
|
|
@@ -34,6 +36,11 @@ report.save_html("report.html") # or report.save_pdf("report.pdf")
|
|
|
34
36
|
# 2. Clean: get a script for the full data, already checked on the sample
|
|
35
37
|
code = fe.clean("orders.csv", target="churn", engine="polars")
|
|
36
38
|
code.save("clean_orders.py") # then: python clean_orders.py
|
|
39
|
+
|
|
40
|
+
# 3. Model: compare models on the sample, get prep + training scripts
|
|
41
|
+
result = fe.train("orders.csv", target="churn", engine="polars")
|
|
42
|
+
result.show() # leaderboard
|
|
43
|
+
result.save("prep.py", "train.py") # python prep.py && python train.py → model.joblib
|
|
37
44
|
```
|
|
38
45
|
|
|
39
46
|
## What it does
|
|
@@ -46,6 +53,8 @@ code.save("clean_orders.py") # then: python clean_orders.py
|
|
|
46
53
|
| **Correlations** | Pearson (numeric) and Cramér's V (categorical) |
|
|
47
54
|
| **Reports** | console, HTML, PDF, JSON |
|
|
48
55
|
| **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
|
|
56
|
+
| **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
|
|
57
|
+
| **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
|
|
49
58
|
| **Validation** | generated code is run on the sample and compared with a reference result before you get it |
|
|
50
59
|
|
|
51
60
|
### Cleaning steps
|
|
@@ -59,6 +68,14 @@ Each step in the generated code has a comment saying why it was added:
|
|
|
59
68
|
- add `<col>_was_missing` flags when missingness predicts the target
|
|
60
69
|
- fill nulls (median / mode), log-transform skewed columns, clip outliers
|
|
61
70
|
|
|
71
|
+
### Features and models
|
|
72
|
+
`fe.train()` (or `report.train()`) adds features on top of the cleaning steps, compares models on the sample, and writes two scripts:
|
|
73
|
+
|
|
74
|
+
- **`prep.py`**: cleaning + features for the full data, in the chosen engine
|
|
75
|
+
- **`train.py`**: scikit-learn training with cross-validation, using the same metric and split, that saves `model.joblib`
|
|
76
|
+
|
|
77
|
+
The metric and split follow the target: PR-AUC for imbalanced binary targets, MAE for skewed regression targets, time-based splits when there's a date column, and so on.
|
|
78
|
+
|
|
62
79
|
### Engine choice
|
|
63
80
|
|
|
64
81
|
| Rows | Engine |
|
|
@@ -79,11 +96,14 @@ Measured on synthetic messy data:
|
|
|
79
96
|
| Sample a 1M-row CSV (0.1.x → 0.2.0) | 37.3 s → 0.65 s |
|
|
80
97
|
| Analyse + plan + generate + validate, 1M-row CSV with target | 2.5 s |
|
|
81
98
|
| Run the generated polars script on 1M rows | 0.9 s |
|
|
99
|
+
| `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
|
|
100
|
+
| Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
|
|
82
101
|
|
|
83
102
|
## Examples
|
|
84
103
|
|
|
85
104
|
- [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
|
|
86
105
|
- [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
|
|
106
|
+
- [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
|
|
87
107
|
|
|
88
108
|
## Development
|
|
89
109
|
|
|
@@ -9,12 +9,13 @@ import pandas as pd
|
|
|
9
9
|
|
|
10
10
|
from . import codegen, engine
|
|
11
11
|
from .clean import clean
|
|
12
|
+
from .modeling import train
|
|
12
13
|
from .engine import available_engines, choose_engine, inspect_source, sample, get_sample_size, set_sample_size
|
|
13
14
|
from .analyzers import overview, missing, numeric, categorical, correlation
|
|
14
15
|
from .analyzers import target as target_analyzer
|
|
15
16
|
from .report import EDAReport
|
|
16
17
|
|
|
17
|
-
__version__ = "0.
|
|
18
|
+
__version__ = "0.4.0"
|
|
18
19
|
__all__ = [
|
|
19
20
|
"analyze",
|
|
20
21
|
"available_engines",
|
|
@@ -24,6 +25,7 @@ __all__ = [
|
|
|
24
25
|
"get_sample_size",
|
|
25
26
|
"inspect_source",
|
|
26
27
|
"set_sample_size",
|
|
28
|
+
"train",
|
|
27
29
|
"EDAReport",
|
|
28
30
|
]
|
|
29
31
|
|
|
@@ -26,6 +26,7 @@ def clean(
|
|
|
26
26
|
n: Optional[int] = None,
|
|
27
27
|
random_state: int = 42,
|
|
28
28
|
validate: bool = True,
|
|
29
|
+
features: bool = False,
|
|
29
30
|
**plan_options,
|
|
30
31
|
):
|
|
31
32
|
"""
|
|
@@ -35,11 +36,13 @@ def clean(
|
|
|
35
36
|
target : column to predict; enables leakage drops, null-target
|
|
36
37
|
row drops and missing-value flags
|
|
37
38
|
engine : "pandas" | "polars" | "duckdb" | "pyspark" (default: by size)
|
|
39
|
+
features : also add model-ready features (date parts, encodings, ratios)
|
|
38
40
|
plan_options : switch planner phases off, e.g. group_rare=False
|
|
39
41
|
"""
|
|
40
42
|
from .. import analyze # local import: flasheda/__init__ imports this package
|
|
41
43
|
|
|
42
44
|
report = analyze(source, n=n, target=target, random_state=random_state, save_pdf=False)
|
|
43
|
-
code = report.codegen(engine, output=output, validate=validate,
|
|
45
|
+
code = report.codegen(engine, output=output, validate=validate, features=features,
|
|
46
|
+
**plan_options)
|
|
44
47
|
code.report = report
|
|
45
48
|
return code
|
|
@@ -17,7 +17,6 @@ NUMERIC_TEXT_MIN_RATIO = 0.95 # share of values that must parse as numbers
|
|
|
17
17
|
LEADING_ZERO_MAX_RATIO = 0.10 # more than this looks like codes (zip, phone)
|
|
18
18
|
RARE_MIN_SHARE = 0.01 # categories below 1% of rows are "rare"
|
|
19
19
|
RARE_MIN_UNIQUE = 10 # only group columns with more distinct values
|
|
20
|
-
RARE_MAX_KEEP = 25
|
|
21
20
|
MISSING_MIN_ROWS = 30
|
|
22
21
|
MISSING_MIN_GAP = 0.10 # class-share gap between missing / present rows
|
|
23
22
|
MISSING_MIN_EFFECT = 0.20 # regression: mean gap in target std units
|
|
@@ -78,7 +77,7 @@ def rare_categories(s: pd.Series) -> Optional[Tuple[tuple, str]]:
|
|
|
78
77
|
shares = values.value_counts(normalize=True)
|
|
79
78
|
if len(shares) <= RARE_MIN_UNIQUE:
|
|
80
79
|
return None
|
|
81
|
-
keep = shares[shares >= RARE_MIN_SHARE].index
|
|
80
|
+
keep = shares[shares >= RARE_MIN_SHARE].index # at most 100 values can each be ≥ 1%
|
|
82
81
|
if len(keep) == 0 or len(keep) == len(shares):
|
|
83
82
|
return None
|
|
84
83
|
other = "other"
|
|
@@ -16,8 +16,9 @@ import pandas as pd
|
|
|
16
16
|
|
|
17
17
|
from .planner import plan_from_report
|
|
18
18
|
from .steps import (
|
|
19
|
-
ClipOutliers, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
20
|
-
MissingIndicator, NormalizeText, Plan,
|
|
19
|
+
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
20
|
+
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio,
|
|
21
|
+
ToNumeric,
|
|
21
22
|
)
|
|
22
23
|
from .validate import ValidationResult, compare_frames, run_generated, validate_code
|
|
23
24
|
from .writers import WRITERS, get_writer
|
|
@@ -66,16 +67,20 @@ def generate(
|
|
|
66
67
|
|
|
67
68
|
__all__ = [
|
|
68
69
|
"ClipOutliers",
|
|
70
|
+
"DateParts",
|
|
69
71
|
"DropColumns",
|
|
70
72
|
"DropDuplicates",
|
|
71
73
|
"DropNullRows",
|
|
72
74
|
"FillNull",
|
|
75
|
+
"FrequencyEncode",
|
|
73
76
|
"GeneratedCode",
|
|
74
77
|
"GroupRare",
|
|
75
78
|
"Log1p",
|
|
76
79
|
"MissingIndicator",
|
|
77
80
|
"NormalizeText",
|
|
81
|
+
"OneHot",
|
|
78
82
|
"Plan",
|
|
83
|
+
"Ratio",
|
|
79
84
|
"ToNumeric",
|
|
80
85
|
"ValidationResult",
|
|
81
86
|
"WRITERS",
|
|
@@ -105,15 +105,87 @@ class Log1p:
|
|
|
105
105
|
reason: str = ""
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
# ── Feature steps ───────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
DATE_PARTS = ("year", "month", "day", "dayofweek", "hour")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class DateParts:
|
|
115
|
+
"""
|
|
116
|
+
Add `<column>_<part>` columns from a date/timestamp column.
|
|
117
|
+
dayofweek is ISO: Monday = 1 ... Sunday = 7. Unparseable values give null.
|
|
118
|
+
`is_text` says the column holds ISO-8601 text that must be parsed first.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
column: str
|
|
122
|
+
parts: tuple
|
|
123
|
+
names: tuple
|
|
124
|
+
is_text: bool = True
|
|
125
|
+
reason: str = ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Ratio:
|
|
130
|
+
"""Add `name` = numerator / denominator (null where the denominator is 0)."""
|
|
131
|
+
|
|
132
|
+
numerator: str
|
|
133
|
+
denominator: str
|
|
134
|
+
name: str
|
|
135
|
+
reason: str = ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class OneHot:
|
|
140
|
+
"""Add one boolean column per value: names[i] = (column == values[i])."""
|
|
141
|
+
|
|
142
|
+
column: str
|
|
143
|
+
values: tuple
|
|
144
|
+
names: tuple
|
|
145
|
+
reason: str = ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class FrequencyEncode:
|
|
150
|
+
"""Replace each value with its share of the sample; unseen or null → `default`."""
|
|
151
|
+
|
|
152
|
+
column: str
|
|
153
|
+
mapping: tuple # ((value, share), ...)
|
|
154
|
+
default: float = 0.0
|
|
155
|
+
reason: str = ""
|
|
156
|
+
|
|
157
|
+
|
|
108
158
|
ROW_STEPS = (DropColumns, DropDuplicates, DropNullRows)
|
|
109
|
-
COLUMN_STEPS = (
|
|
159
|
+
COLUMN_STEPS = (
|
|
160
|
+
ToNumeric, NormalizeText, GroupRare, MissingIndicator, FillNull, ClipOutliers, Log1p,
|
|
161
|
+
DateParts, Ratio, OneHot, FrequencyEncode,
|
|
162
|
+
)
|
|
110
163
|
|
|
111
164
|
Step = Union[
|
|
112
165
|
DropColumns, DropDuplicates, DropNullRows,
|
|
113
166
|
ToNumeric, NormalizeText, GroupRare, MissingIndicator, FillNull, ClipOutliers, Log1p,
|
|
167
|
+
DateParts, Ratio, OneHot, FrequencyEncode,
|
|
114
168
|
]
|
|
115
169
|
|
|
116
170
|
|
|
171
|
+
def inputs(step) -> tuple:
|
|
172
|
+
"""Columns a column step reads."""
|
|
173
|
+
if isinstance(step, Ratio):
|
|
174
|
+
return (step.numerator, step.denominator)
|
|
175
|
+
return (step.column,)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def outputs(step) -> tuple:
|
|
179
|
+
"""New columns a column step adds (empty when it only replaces values)."""
|
|
180
|
+
if isinstance(step, MissingIndicator):
|
|
181
|
+
return (step.name,)
|
|
182
|
+
if isinstance(step, Ratio):
|
|
183
|
+
return (step.name,)
|
|
184
|
+
if isinstance(step, (DateParts, OneHot)):
|
|
185
|
+
return step.names
|
|
186
|
+
return ()
|
|
187
|
+
|
|
188
|
+
|
|
117
189
|
@dataclass
|
|
118
190
|
class Plan:
|
|
119
191
|
steps: List[Step] = field(default_factory=list)
|
|
@@ -134,13 +206,20 @@ class Plan:
|
|
|
134
206
|
them together; row steps keep their position in the order.
|
|
135
207
|
"""
|
|
136
208
|
pending: List[Step] = []
|
|
209
|
+
added: set = set()
|
|
137
210
|
for step in self.steps:
|
|
138
211
|
if isinstance(step, COLUMN_STEPS):
|
|
212
|
+
# A step reading a column added in this segment needs that
|
|
213
|
+
# column to exist first, so it starts a new segment.
|
|
214
|
+
if added.intersection(inputs(step)):
|
|
215
|
+
yield "columns", pending
|
|
216
|
+
pending, added = [], set()
|
|
139
217
|
pending.append(step)
|
|
218
|
+
added.update(outputs(step))
|
|
140
219
|
continue
|
|
141
220
|
if pending:
|
|
142
221
|
yield "columns", pending
|
|
143
|
-
pending = []
|
|
222
|
+
pending, added = [], set()
|
|
144
223
|
yield "row", step
|
|
145
224
|
if pending:
|
|
146
225
|
yield "columns", pending
|
|
@@ -175,6 +254,23 @@ class Plan:
|
|
|
175
254
|
elif isinstance(step, Log1p):
|
|
176
255
|
x = out[step.column]
|
|
177
256
|
out[step.column] = np.sign(x) * np.log1p(np.abs(x))
|
|
257
|
+
elif isinstance(step, DateParts):
|
|
258
|
+
ts = out[step.column]
|
|
259
|
+
if step.is_text:
|
|
260
|
+
ts = pd.to_datetime(ts, errors="coerce", format="ISO8601")
|
|
261
|
+
for part, name in zip(step.parts, step.names):
|
|
262
|
+
value = ts.dt.dayofweek + 1 if part == "dayofweek" else getattr(ts.dt, part)
|
|
263
|
+
out[name] = value.astype("float64")
|
|
264
|
+
elif isinstance(step, Ratio):
|
|
265
|
+
den = out[step.denominator].astype("float64")
|
|
266
|
+
out[step.name] = out[step.numerator].astype("float64") / den.where(den != 0)
|
|
267
|
+
elif isinstance(step, OneHot):
|
|
268
|
+
s = out[step.column]
|
|
269
|
+
for value, name in zip(step.values, step.names):
|
|
270
|
+
out[name] = s.eq(value).fillna(False).astype(bool)
|
|
271
|
+
elif isinstance(step, FrequencyEncode):
|
|
272
|
+
mapping = dict(step.mapping)
|
|
273
|
+
out[step.column] = out[step.column].map(mapping).astype("float64").fillna(step.default)
|
|
178
274
|
return out
|
|
179
275
|
|
|
180
276
|
|
|
@@ -197,4 +293,12 @@ def _label(step: Step) -> str:
|
|
|
197
293
|
return f"fill nulls in {step.column!r} with {step.value!r}"
|
|
198
294
|
if isinstance(step, ClipOutliers):
|
|
199
295
|
return f"clip {step.column!r} to [{step.lower}, {step.upper}]"
|
|
200
|
-
|
|
296
|
+
if isinstance(step, Log1p):
|
|
297
|
+
return f"log transform {step.column!r}"
|
|
298
|
+
if isinstance(step, DateParts):
|
|
299
|
+
return f"add {', '.join(step.parts)} from {step.column!r}"
|
|
300
|
+
if isinstance(step, Ratio):
|
|
301
|
+
return f"add {step.name!r} = {step.numerator!r} / {step.denominator!r}"
|
|
302
|
+
if isinstance(step, OneHot):
|
|
303
|
+
return f"one-hot encode {step.column!r} ({len(step.values)} columns)"
|
|
304
|
+
return f"frequency-encode {step.column!r}"
|
|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import List, Optional
|
|
4
4
|
|
|
5
5
|
from ..steps import (
|
|
6
|
-
ClipOutliers, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
|
-
MissingIndicator, NormalizeText, Plan,
|
|
6
|
+
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
|
+
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio, Step,
|
|
8
|
+
ToNumeric,
|
|
8
9
|
)
|
|
9
10
|
from ...engine.source import DUCKDB_CSV_TYPES
|
|
10
11
|
from .base import Writer, source_format
|
|
@@ -64,9 +65,31 @@ class DuckDBWriter(Writer):
|
|
|
64
65
|
def _columns(steps: List[Step]) -> str:
|
|
65
66
|
replaced = {}
|
|
66
67
|
added = {}
|
|
68
|
+
def current(name: str) -> str:
|
|
69
|
+
return replaced.get(name, ident(name))
|
|
70
|
+
|
|
67
71
|
for step in steps:
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
if isinstance(step, Ratio):
|
|
73
|
+
num = f"CAST({current(step.numerator)} AS DOUBLE)"
|
|
74
|
+
den = f"CAST({current(step.denominator)} AS DOUBLE)"
|
|
75
|
+
added[step.name] = f"{num} / NULLIF({den}, 0)"
|
|
76
|
+
continue
|
|
77
|
+
col = current(step.column)
|
|
78
|
+
if isinstance(step, DateParts):
|
|
79
|
+
ts = f"TRY_CAST({col} AS TIMESTAMP)" if step.is_text else col
|
|
80
|
+
for part, name in zip(step.parts, step.names):
|
|
81
|
+
fn = {"year": "year", "month": "month", "day": "day",
|
|
82
|
+
"dayofweek": "isodow", "hour": "hour"}[part]
|
|
83
|
+
added[name] = f"CAST({fn}({ts}) AS DOUBLE)"
|
|
84
|
+
continue
|
|
85
|
+
if isinstance(step, OneHot):
|
|
86
|
+
for value, name in zip(step.values, step.names):
|
|
87
|
+
added[name] = f"coalesce({col} = {literal(value)}, false)"
|
|
88
|
+
continue
|
|
89
|
+
if isinstance(step, FrequencyEncode):
|
|
90
|
+
cases = " ".join(f"WHEN {literal(v)} THEN {literal(float(f))}" for v, f in step.mapping)
|
|
91
|
+
col = f"CASE {col} {cases} ELSE {literal(float(step.default))} END"
|
|
92
|
+
elif isinstance(step, ToNumeric):
|
|
70
93
|
col = f"TRY_CAST(trim(CAST({col} AS VARCHAR), ' ') AS DOUBLE)"
|
|
71
94
|
elif isinstance(step, NormalizeText):
|
|
72
95
|
col = f"lower(trim({col}, ' '))"
|
|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
5
|
from ..steps import (
|
|
6
|
-
ClipOutliers, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
|
-
MissingIndicator, NormalizeText, Plan,
|
|
6
|
+
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
|
+
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio,
|
|
8
|
+
ToNumeric,
|
|
8
9
|
)
|
|
9
10
|
from .base import Writer, source_format
|
|
10
11
|
|
|
@@ -20,7 +21,7 @@ class PandasWriter(Writer):
|
|
|
20
21
|
body = []
|
|
21
22
|
for step in plan:
|
|
22
23
|
body.append(self.comment(step))
|
|
23
|
-
body.
|
|
24
|
+
body.extend(self._line(step).split("\n"))
|
|
24
25
|
body.append("return df")
|
|
25
26
|
return (
|
|
26
27
|
"def transform(df: pd.DataFrame) -> pd.DataFrame:\n"
|
|
@@ -34,6 +35,10 @@ class PandasWriter(Writer):
|
|
|
34
35
|
return f"df = df.drop(columns={list(step.columns)!r})"
|
|
35
36
|
if isinstance(step, DropDuplicates):
|
|
36
37
|
return "df = df.drop_duplicates().reset_index(drop=True)"
|
|
38
|
+
if isinstance(step, Ratio):
|
|
39
|
+
den = f'df[{step.denominator!r}].astype("float64")'
|
|
40
|
+
return (f'df[{step.name!r}] = df[{step.numerator!r}].astype("float64") / '
|
|
41
|
+
f"{den}.where({den} != 0)")
|
|
37
42
|
c = f"df[{step.column!r}]"
|
|
38
43
|
if isinstance(step, DropNullRows):
|
|
39
44
|
return f"df = df[{c}.notna()].reset_index(drop=True)"
|
|
@@ -52,6 +57,19 @@ class PandasWriter(Writer):
|
|
|
52
57
|
return f"{c} = {c}.clip({step.lower!r}, {step.upper!r})"
|
|
53
58
|
if isinstance(step, Log1p):
|
|
54
59
|
return f"{c} = np.sign({c}) * np.log1p(np.abs({c}))"
|
|
60
|
+
if isinstance(step, DateParts):
|
|
61
|
+
src = f'pd.to_datetime({c}, errors="coerce", format="ISO8601")' if step.is_text else c
|
|
62
|
+
lines = [f"ts = {src}"]
|
|
63
|
+
for part, name in zip(step.parts, step.names):
|
|
64
|
+
value = "ts.dt.dayofweek + 1" if part == "dayofweek" else f"ts.dt.{part}"
|
|
65
|
+
lines.append(f'df[{name!r}] = ({value}).astype("float64")')
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
if isinstance(step, OneHot):
|
|
68
|
+
return "\n".join(
|
|
69
|
+
f"df[{name!r}] = {c}.eq({value!r}).fillna(False).astype(bool)"
|
|
70
|
+
for value, name in zip(step.values, step.names))
|
|
71
|
+
if isinstance(step, FrequencyEncode):
|
|
72
|
+
return f'{c} = {c}.map({dict(step.mapping)!r}).astype("float64").fillna({step.default!r})'
|
|
55
73
|
raise TypeError(f"Unsupported step {step!r}")
|
|
56
74
|
|
|
57
75
|
def main(self, source: Optional[str]) -> str:
|
|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Dict, List, Optional
|
|
4
4
|
|
|
5
5
|
from ..steps import (
|
|
6
|
-
ClipOutliers, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
|
-
MissingIndicator, NormalizeText, Plan,
|
|
6
|
+
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
|
+
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio,
|
|
8
|
+
ToNumeric,
|
|
8
9
|
)
|
|
9
10
|
from .base import Writer, source_format
|
|
10
11
|
|
|
@@ -47,10 +48,36 @@ class PolarsWriter(Writer):
|
|
|
47
48
|
# same column are composed into a single expression, in order.
|
|
48
49
|
exprs: Dict[str, str] = {}
|
|
49
50
|
reasons: Dict[str, List[str]] = {}
|
|
51
|
+
def current(name: str) -> str:
|
|
52
|
+
return exprs.get(name, f"pl.col({name!r})")
|
|
53
|
+
|
|
54
|
+
def add(name: str, expr: str, reason: str) -> None:
|
|
55
|
+
exprs[name] = f"({expr}).alias({name!r})"
|
|
56
|
+
reasons.setdefault(name, []).append(reason)
|
|
57
|
+
|
|
50
58
|
for step in steps:
|
|
51
|
-
|
|
59
|
+
if isinstance(step, Ratio):
|
|
60
|
+
num = f"{current(step.numerator)}.cast(pl.Float64)"
|
|
61
|
+
den = f"{current(step.denominator)}.cast(pl.Float64)"
|
|
62
|
+
add(step.name, f"pl.when({den} != 0).then({num} / {den})", step.reason)
|
|
63
|
+
continue
|
|
64
|
+
e = current(step.column)
|
|
52
65
|
out = step.column
|
|
53
|
-
if isinstance(step,
|
|
66
|
+
if isinstance(step, DateParts):
|
|
67
|
+
ts = f"{e}.str.to_datetime(strict=False)" if step.is_text else e
|
|
68
|
+
for part, name in zip(step.parts, step.names):
|
|
69
|
+
fn = {"year": "year", "month": "month", "day": "day",
|
|
70
|
+
"dayofweek": "weekday", "hour": "hour"}[part]
|
|
71
|
+
add(name, f"{ts}.dt.{fn}().cast(pl.Float64)", step.reason)
|
|
72
|
+
continue
|
|
73
|
+
if isinstance(step, OneHot):
|
|
74
|
+
for value, name in zip(step.values, step.names):
|
|
75
|
+
add(name, f"({e} == {value!r}).fill_null(False)", step.reason)
|
|
76
|
+
continue
|
|
77
|
+
if isinstance(step, FrequencyEncode):
|
|
78
|
+
e = (f"{e}.replace_strict({dict(step.mapping)!r}, default={step.default!r}, "
|
|
79
|
+
f"return_dtype=pl.Float64).fill_null({step.default!r})")
|
|
80
|
+
elif isinstance(step, ToNumeric):
|
|
54
81
|
e += '.cast(pl.String).str.strip_chars(" ").cast(pl.Float64, strict=False)'
|
|
55
82
|
elif isinstance(step, NormalizeText):
|
|
56
83
|
e += '.str.strip_chars(" ").str.to_lowercase()'
|
|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
5
|
from ..steps import (
|
|
6
|
-
ClipOutliers, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
|
-
MissingIndicator, NormalizeText, Plan,
|
|
6
|
+
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
|
+
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio,
|
|
8
|
+
ToNumeric,
|
|
8
9
|
)
|
|
9
10
|
from .base import Writer, source_format
|
|
10
11
|
|
|
@@ -26,7 +27,7 @@ class PySparkWriter(Writer):
|
|
|
26
27
|
body = []
|
|
27
28
|
for step in plan:
|
|
28
29
|
body.append(self.comment(step))
|
|
29
|
-
body.
|
|
30
|
+
body.extend(self._line(step).split("\n"))
|
|
30
31
|
body.append("return df")
|
|
31
32
|
return "def transform(df: DataFrame) -> DataFrame:\n" + self.indent(
|
|
32
33
|
[line for line in body if line]
|
|
@@ -38,11 +39,30 @@ class PySparkWriter(Writer):
|
|
|
38
39
|
return f"df = df.drop(*{list(step.columns)!r})"
|
|
39
40
|
if isinstance(step, DropDuplicates):
|
|
40
41
|
return "df = df.dropDuplicates()"
|
|
42
|
+
if isinstance(step, Ratio):
|
|
43
|
+
den = f'{col(step.denominator)}.cast("double")'
|
|
44
|
+
return (f"df = df.withColumn({step.name!r}, "
|
|
45
|
+
f'{col(step.numerator)}.cast("double") / F.when({den} != 0, {den}))')
|
|
41
46
|
c = col(step.column)
|
|
42
47
|
if isinstance(step, DropNullRows):
|
|
43
48
|
return f"df = df.filter({c}.isNotNull())"
|
|
44
49
|
if isinstance(step, MissingIndicator):
|
|
45
50
|
return f"df = df.withColumn({step.name!r}, {c}.isNull())"
|
|
51
|
+
if isinstance(step, DateParts):
|
|
52
|
+
ts = f"F.to_timestamp({c})" if step.is_text else c
|
|
53
|
+
spark = {"year": "F.year", "month": "F.month", "day": "F.dayofmonth", "hour": "F.hour"}
|
|
54
|
+
lines = []
|
|
55
|
+
for part, name in zip(step.parts, step.names):
|
|
56
|
+
if part == "dayofweek": # Spark: Sunday = 1; ISO: Monday = 1
|
|
57
|
+
value = f"((F.dayofweek({ts}) + 5) % 7) + 1"
|
|
58
|
+
else:
|
|
59
|
+
value = f"{spark[part]}({ts})"
|
|
60
|
+
lines.append(f'df = df.withColumn({name!r}, ({value}).cast("double"))')
|
|
61
|
+
return "\n".join(lines)
|
|
62
|
+
if isinstance(step, OneHot):
|
|
63
|
+
return "\n".join(
|
|
64
|
+
f"df = df.withColumn({name!r}, F.coalesce({c} == {value!r}, F.lit(False)))"
|
|
65
|
+
for value, name in zip(step.values, step.names))
|
|
46
66
|
if isinstance(step, ToNumeric):
|
|
47
67
|
e = f'F.trim({c}.cast("string")).cast("double")'
|
|
48
68
|
elif isinstance(step, NormalizeText):
|
|
@@ -57,6 +77,9 @@ class PySparkWriter(Writer):
|
|
|
57
77
|
f".when({c} > {step.upper!r}, F.lit({step.upper!r})).otherwise({c})")
|
|
58
78
|
elif isinstance(step, Log1p):
|
|
59
79
|
e = f"F.signum({c}) * F.log1p(F.abs({c}))"
|
|
80
|
+
elif isinstance(step, FrequencyEncode):
|
|
81
|
+
pairs = ", ".join(f"F.lit({v!r}), F.lit({float(f)!r})" for v, f in step.mapping)
|
|
82
|
+
e = f"F.coalesce(F.create_map({pairs})[{c}], F.lit({step.default!r}))"
|
|
60
83
|
else:
|
|
61
84
|
raise TypeError(f"Unsupported step {step!r}")
|
|
62
85
|
return f"df = df.withColumn({step.column!r}, {e})"
|