flasheda 0.3.0__tar.gz → 0.5.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.5.0}/PKG-INFO +67 -5
- {flasheda-0.3.0 → flasheda-0.5.0}/README.md +49 -2
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/__init__.py +15 -4
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/clean/__init__.py +4 -1
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/clean/detectors.py +1 -2
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/__init__.py +10 -3
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/steps.py +107 -3
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/base.py +12 -2
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/duckdb_writer.py +27 -4
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/pandas_writer.py +40 -3
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/polars_writer.py +31 -4
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/pyspark_writer.py +26 -3
- flasheda-0.5.0/flasheda/compare/__init__.py +224 -0
- flasheda-0.5.0/flasheda/contract/__init__.py +29 -0
- flasheda-0.5.0/flasheda/contract/check.py +251 -0
- flasheda-0.5.0/flasheda/contract/rules.py +216 -0
- flasheda-0.5.0/flasheda/contract/script.py +227 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/engine/__init__.py +3 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/engine/sampler.py +4 -1
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/engine/source.py +13 -0
- flasheda-0.5.0/flasheda/engine/sql.py +129 -0
- flasheda-0.5.0/flasheda/features/__init__.py +169 -0
- flasheda-0.5.0/flasheda/modeling/__init__.py +230 -0
- flasheda-0.5.0/flasheda/modeling/script.py +138 -0
- flasheda-0.5.0/flasheda/modeling/specs.py +79 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/report.py +32 -6
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/PKG-INFO +67 -5
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/SOURCES.txt +15 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/requires.txt +18 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/setup.py +9 -5
- flasheda-0.5.0/tests/test_compare.py +90 -0
- flasheda-0.5.0/tests/test_contract.py +140 -0
- flasheda-0.5.0/tests/test_contract_script.py +81 -0
- flasheda-0.5.0/tests/test_features_model.py +138 -0
- flasheda-0.5.0/tests/test_sql.py +81 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/tests/test_writers.py +55 -3
- {flasheda-0.3.0 → flasheda-0.5.0}/MIT.License +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/__init__.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/categorical.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/correlation.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/missing.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/numeric.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/overview.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/target.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/planner.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/validate.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/__init__.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/engine/backends.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/sampler.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/dependency_links.txt +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/top_level.txt +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/pyproject.toml +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/setup.cfg +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/tests/test_clean_target.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.0}/tests/test_codegen.py +0 -0
- {flasheda-0.3.0 → flasheda-0.5.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.5.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,data-quality,data-drift
|
|
13
13
|
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Intended Audience :: Science/Research
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
@@ -32,11 +32,26 @@ 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: sql
|
|
39
|
+
Requires-Dist: sqlalchemy>=1.4; extra == "sql"
|
|
40
|
+
Requires-Dist: pyarrow>=12; extra == "sql"
|
|
41
|
+
Provides-Extra: all
|
|
42
|
+
Requires-Dist: duckdb>=0.10; extra == "all"
|
|
43
|
+
Requires-Dist: polars>=0.20; extra == "all"
|
|
44
|
+
Requires-Dist: pyarrow>=12; extra == "all"
|
|
45
|
+
Requires-Dist: scikit-learn>=1.2; extra == "all"
|
|
46
|
+
Requires-Dist: sqlalchemy>=1.4; extra == "all"
|
|
35
47
|
Provides-Extra: dev
|
|
36
48
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
37
49
|
Requires-Dist: duckdb>=0.10; extra == "dev"
|
|
38
50
|
Requires-Dist: polars>=0.20; extra == "dev"
|
|
39
51
|
Requires-Dist: pyarrow>=12; extra == "dev"
|
|
52
|
+
Requires-Dist: scikit-learn>=1.2; extra == "dev"
|
|
53
|
+
Requires-Dist: pandera; extra == "dev"
|
|
54
|
+
Requires-Dist: sqlalchemy>=1.4; extra == "dev"
|
|
40
55
|
Requires-Dist: build; extra == "dev"
|
|
41
56
|
Requires-Dist: twine; extra == "dev"
|
|
42
57
|
Dynamic: author
|
|
@@ -63,9 +78,9 @@ Dynamic: summary
|
|
|
63
78
|
[](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
|
|
64
79
|
[](MIT.License)
|
|
65
80
|
|
|
66
|
-
**Current version: 0.
|
|
81
|
+
**Current version: 0.5.0** · [Changelog](CHANGELOG.md)
|
|
67
82
|
|
|
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.
|
|
83
|
+
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
84
|
|
|
70
85
|
FlashEDA analyses a fixed-size random sample, so analysis time barely changes between 100 thousand and 100 million rows.
|
|
71
86
|
|
|
@@ -74,7 +89,10 @@ FlashEDA analyses a fixed-size random sample, so analysis time barely changes be
|
|
|
74
89
|
```bash
|
|
75
90
|
pip install flasheda # core
|
|
76
91
|
pip install "flasheda[fast]" # + polars, DuckDB, pyarrow (recommended for big files)
|
|
92
|
+
pip install "flasheda[ml]" # + scikit-learn, for flasheda.train
|
|
93
|
+
pip install "flasheda[sql]" # + SQLAlchemy, for database tables
|
|
77
94
|
pip install "flasheda[spark]" # + PySpark
|
|
95
|
+
pip install "flasheda[all]" # everything except PySpark
|
|
78
96
|
```
|
|
79
97
|
|
|
80
98
|
## Quick start
|
|
@@ -90,6 +108,23 @@ report.save_html("report.html") # or report.save_pdf("report.pdf")
|
|
|
90
108
|
# 2. Clean: get a script for the full data, already checked on the sample
|
|
91
109
|
code = fe.clean("orders.csv", target="churn", engine="polars")
|
|
92
110
|
code.save("clean_orders.py") # then: python clean_orders.py
|
|
111
|
+
|
|
112
|
+
# 3. Model: compare models on the sample, get prep + training scripts
|
|
113
|
+
result = fe.train("orders.csv", target="churn", engine="polars")
|
|
114
|
+
result.show() # leaderboard
|
|
115
|
+
result.save("prep.py", "train.py") # python prep.py && python train.py → model.joblib
|
|
116
|
+
|
|
117
|
+
# 4. Guard: learn rules from good data, check every new file
|
|
118
|
+
contract = fe.analyze("orders_jan.csv").contract()
|
|
119
|
+
fe.check("orders_feb.csv", contract).show()
|
|
120
|
+
open("check.py", "w").write(contract.script()) # python check.py new.csv → exit 1 on failure
|
|
121
|
+
|
|
122
|
+
# 5. Compare: what changed between two datasets
|
|
123
|
+
fe.compare("orders_jan.csv", "orders_feb.csv").show()
|
|
124
|
+
|
|
125
|
+
# Databases work like files
|
|
126
|
+
src = fe.sql("postgresql://user:pass@host/shop", table="orders")
|
|
127
|
+
fe.analyze(src, target="churn")
|
|
93
128
|
```
|
|
94
129
|
|
|
95
130
|
## What it does
|
|
@@ -102,6 +137,11 @@ code.save("clean_orders.py") # then: python clean_orders.py
|
|
|
102
137
|
| **Correlations** | Pearson (numeric) and Cramér's V (categorical) |
|
|
103
138
|
| **Reports** | console, HTML, PDF, JSON |
|
|
104
139
|
| **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
|
|
140
|
+
| **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
|
|
141
|
+
| **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
|
|
142
|
+
| **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
|
|
143
|
+
| **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
|
|
144
|
+
| **Databases** | any SQLAlchemy URL; sampling runs in the database; passwords never stored |
|
|
105
145
|
| **Validation** | generated code is run on the sample and compared with a reference result before you get it |
|
|
106
146
|
|
|
107
147
|
### Cleaning steps
|
|
@@ -115,6 +155,21 @@ Each step in the generated code has a comment saying why it was added:
|
|
|
115
155
|
- add `<col>_was_missing` flags when missingness predicts the target
|
|
116
156
|
- fill nulls (median / mode), log-transform skewed columns, clip outliers
|
|
117
157
|
|
|
158
|
+
### Features and models
|
|
159
|
+
`fe.train()` (or `report.train()`) adds features on top of the cleaning steps, compares models on the sample, and writes two scripts:
|
|
160
|
+
|
|
161
|
+
- **`prep.py`**: cleaning + features for the full data, in the chosen engine
|
|
162
|
+
- **`train.py`**: scikit-learn training with cross-validation, using the same metric and split, that saves `model.joblib`
|
|
163
|
+
|
|
164
|
+
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.
|
|
165
|
+
|
|
166
|
+
### Data contracts and drift
|
|
167
|
+
A **contract** describes what good data looks like, learned from a sample with tolerances so normal variation passes:
|
|
168
|
+
type, missing %, 1st/99th percentile range (catches unit changes like ×100), no negatives, allowed values, uniqueness of IDs.
|
|
169
|
+
`fe.check()` checks every row of a file in one DuckDB pass; `contract.script()` writes the same check as a standalone script that exits with code 1 on failure, ready for a pipeline or CI job.
|
|
170
|
+
|
|
171
|
+
`fe.compare(old, new)` reports added/removed columns, type changes and how much each column shifted (PSI: < 0.1 stable, 0.1–0.25 moderate, ≥ 0.25 major). Dates and IDs are shown as range shifts, since they always move.
|
|
172
|
+
|
|
118
173
|
### Engine choice
|
|
119
174
|
|
|
120
175
|
| Rows | Engine |
|
|
@@ -135,11 +190,18 @@ Measured on synthetic messy data:
|
|
|
135
190
|
| Sample a 1M-row CSV (0.1.x → 0.2.0) | 37.3 s → 0.65 s |
|
|
136
191
|
| Analyse + plan + generate + validate, 1M-row CSV with target | 2.5 s |
|
|
137
192
|
| Run the generated polars script on 1M rows | 0.9 s |
|
|
193
|
+
| `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
|
|
194
|
+
| Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
|
|
195
|
+
| Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
|
|
196
|
+
| Compare two 1M-row files | 0.6 s |
|
|
197
|
+
| Analyse a 1M-row SQLite table (5,000 rows transferred) | 0.3 s |
|
|
138
198
|
|
|
139
199
|
## Examples
|
|
140
200
|
|
|
141
201
|
- [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
|
|
142
202
|
- [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
|
|
203
|
+
- [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
|
|
204
|
+
- [`examples/phase4_demo.py`](examples/phase4_demo.py): contracts, check script, drift and a database on 1M rows
|
|
143
205
|
|
|
144
206
|
## Development
|
|
145
207
|
|
|
@@ -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.5.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,10 @@ 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
|
|
22
|
+
pip install "flasheda[sql]" # + SQLAlchemy, for database tables
|
|
21
23
|
pip install "flasheda[spark]" # + PySpark
|
|
24
|
+
pip install "flasheda[all]" # everything except PySpark
|
|
22
25
|
```
|
|
23
26
|
|
|
24
27
|
## Quick start
|
|
@@ -34,6 +37,23 @@ report.save_html("report.html") # or report.save_pdf("report.pdf")
|
|
|
34
37
|
# 2. Clean: get a script for the full data, already checked on the sample
|
|
35
38
|
code = fe.clean("orders.csv", target="churn", engine="polars")
|
|
36
39
|
code.save("clean_orders.py") # then: python clean_orders.py
|
|
40
|
+
|
|
41
|
+
# 3. Model: compare models on the sample, get prep + training scripts
|
|
42
|
+
result = fe.train("orders.csv", target="churn", engine="polars")
|
|
43
|
+
result.show() # leaderboard
|
|
44
|
+
result.save("prep.py", "train.py") # python prep.py && python train.py → model.joblib
|
|
45
|
+
|
|
46
|
+
# 4. Guard: learn rules from good data, check every new file
|
|
47
|
+
contract = fe.analyze("orders_jan.csv").contract()
|
|
48
|
+
fe.check("orders_feb.csv", contract).show()
|
|
49
|
+
open("check.py", "w").write(contract.script()) # python check.py new.csv → exit 1 on failure
|
|
50
|
+
|
|
51
|
+
# 5. Compare: what changed between two datasets
|
|
52
|
+
fe.compare("orders_jan.csv", "orders_feb.csv").show()
|
|
53
|
+
|
|
54
|
+
# Databases work like files
|
|
55
|
+
src = fe.sql("postgresql://user:pass@host/shop", table="orders")
|
|
56
|
+
fe.analyze(src, target="churn")
|
|
37
57
|
```
|
|
38
58
|
|
|
39
59
|
## What it does
|
|
@@ -46,6 +66,11 @@ code.save("clean_orders.py") # then: python clean_orders.py
|
|
|
46
66
|
| **Correlations** | Pearson (numeric) and Cramér's V (categorical) |
|
|
47
67
|
| **Reports** | console, HTML, PDF, JSON |
|
|
48
68
|
| **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
|
|
69
|
+
| **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
|
|
70
|
+
| **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
|
|
71
|
+
| **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
|
|
72
|
+
| **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
|
|
73
|
+
| **Databases** | any SQLAlchemy URL; sampling runs in the database; passwords never stored |
|
|
49
74
|
| **Validation** | generated code is run on the sample and compared with a reference result before you get it |
|
|
50
75
|
|
|
51
76
|
### Cleaning steps
|
|
@@ -59,6 +84,21 @@ Each step in the generated code has a comment saying why it was added:
|
|
|
59
84
|
- add `<col>_was_missing` flags when missingness predicts the target
|
|
60
85
|
- fill nulls (median / mode), log-transform skewed columns, clip outliers
|
|
61
86
|
|
|
87
|
+
### Features and models
|
|
88
|
+
`fe.train()` (or `report.train()`) adds features on top of the cleaning steps, compares models on the sample, and writes two scripts:
|
|
89
|
+
|
|
90
|
+
- **`prep.py`**: cleaning + features for the full data, in the chosen engine
|
|
91
|
+
- **`train.py`**: scikit-learn training with cross-validation, using the same metric and split, that saves `model.joblib`
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
### Data contracts and drift
|
|
96
|
+
A **contract** describes what good data looks like, learned from a sample with tolerances so normal variation passes:
|
|
97
|
+
type, missing %, 1st/99th percentile range (catches unit changes like ×100), no negatives, allowed values, uniqueness of IDs.
|
|
98
|
+
`fe.check()` checks every row of a file in one DuckDB pass; `contract.script()` writes the same check as a standalone script that exits with code 1 on failure, ready for a pipeline or CI job.
|
|
99
|
+
|
|
100
|
+
`fe.compare(old, new)` reports added/removed columns, type changes and how much each column shifted (PSI: < 0.1 stable, 0.1–0.25 moderate, ≥ 0.25 major). Dates and IDs are shown as range shifts, since they always move.
|
|
101
|
+
|
|
62
102
|
### Engine choice
|
|
63
103
|
|
|
64
104
|
| Rows | Engine |
|
|
@@ -79,11 +119,18 @@ Measured on synthetic messy data:
|
|
|
79
119
|
| Sample a 1M-row CSV (0.1.x → 0.2.0) | 37.3 s → 0.65 s |
|
|
80
120
|
| Analyse + plan + generate + validate, 1M-row CSV with target | 2.5 s |
|
|
81
121
|
| Run the generated polars script on 1M rows | 0.9 s |
|
|
122
|
+
| `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
|
|
123
|
+
| Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
|
|
124
|
+
| Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
|
|
125
|
+
| Compare two 1M-row files | 0.6 s |
|
|
126
|
+
| Analyse a 1M-row SQLite table (5,000 rows transferred) | 0.3 s |
|
|
82
127
|
|
|
83
128
|
## Examples
|
|
84
129
|
|
|
85
130
|
- [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
|
|
86
131
|
- [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
|
|
132
|
+
- [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
|
|
133
|
+
- [`examples/phase4_demo.py`](examples/phase4_demo.py): contracts, check script, drift and a database on 1M rows
|
|
87
134
|
|
|
88
135
|
## Development
|
|
89
136
|
|
|
@@ -9,21 +9,28 @@ import pandas as pd
|
|
|
9
9
|
|
|
10
10
|
from . import codegen, engine
|
|
11
11
|
from .clean import clean
|
|
12
|
-
from .
|
|
12
|
+
from .modeling import train
|
|
13
|
+
from .contract import check
|
|
14
|
+
from .compare import compare
|
|
15
|
+
from .engine import available_engines, choose_engine, inspect_source, sample, get_sample_size, set_sample_size, sql
|
|
13
16
|
from .analyzers import overview, missing, numeric, categorical, correlation
|
|
14
17
|
from .analyzers import target as target_analyzer
|
|
15
18
|
from .report import EDAReport
|
|
16
19
|
|
|
17
|
-
__version__ = "0.
|
|
20
|
+
__version__ = "0.5.0"
|
|
18
21
|
__all__ = [
|
|
19
22
|
"analyze",
|
|
20
23
|
"available_engines",
|
|
24
|
+
"check",
|
|
21
25
|
"clean",
|
|
26
|
+
"compare",
|
|
22
27
|
"codegen",
|
|
23
28
|
"engine",
|
|
24
29
|
"get_sample_size",
|
|
25
30
|
"inspect_source",
|
|
26
31
|
"set_sample_size",
|
|
32
|
+
"sql",
|
|
33
|
+
"train",
|
|
27
34
|
"EDAReport",
|
|
28
35
|
]
|
|
29
36
|
|
|
@@ -40,7 +47,8 @@ def analyze(
|
|
|
40
47
|
target: Optional[str] = None,
|
|
41
48
|
) -> EDAReport:
|
|
42
49
|
"""
|
|
43
|
-
Analyse a fixed-size sample of `source
|
|
50
|
+
Analyse a fixed-size sample of `source`: a DataFrame, a CSV/TSV or Parquet
|
|
51
|
+
path, or a database table/query from flasheda.sql().
|
|
44
52
|
|
|
45
53
|
`target` names the column you want to predict; it adds task detection,
|
|
46
54
|
class balance, per-feature predictive power and leakage checks.
|
|
@@ -119,7 +127,10 @@ def analyze(
|
|
|
119
127
|
warnings=warnings,
|
|
120
128
|
target=tgt,
|
|
121
129
|
source=str(info.path) if info.path else None,
|
|
122
|
-
|
|
130
|
+
sql_source=info.sql,
|
|
131
|
+
# database sources are read with pandas (or exported with .to_parquet())
|
|
132
|
+
recommended_engine=choose_engine(
|
|
133
|
+
info.n_rows, prefer=engine or ("pandas" if info.sql is not None else None)),
|
|
123
134
|
sample=sampled,
|
|
124
135
|
)
|
|
125
136
|
|
|
@@ -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
|
|
@@ -57,25 +58,31 @@ def generate(
|
|
|
57
58
|
output: str = "cleaned.parquet",
|
|
58
59
|
sample: Optional[pd.DataFrame] = None,
|
|
59
60
|
columns: Optional[List[str]] = None,
|
|
61
|
+
sql: Any = None,
|
|
60
62
|
) -> GeneratedCode:
|
|
63
|
+
"""`sql` is a flasheda.sql() source to read from instead of a file."""
|
|
61
64
|
if columns is None and sample is not None:
|
|
62
65
|
columns = [str(c) for c in sample.columns]
|
|
63
|
-
code = get_writer(engine).render(plan, source, output, columns)
|
|
66
|
+
code = get_writer(engine).render(plan, source, output, columns, sql=sql)
|
|
64
67
|
return GeneratedCode(engine, code, plan, sample)
|
|
65
68
|
|
|
66
69
|
|
|
67
70
|
__all__ = [
|
|
68
71
|
"ClipOutliers",
|
|
72
|
+
"DateParts",
|
|
69
73
|
"DropColumns",
|
|
70
74
|
"DropDuplicates",
|
|
71
75
|
"DropNullRows",
|
|
72
76
|
"FillNull",
|
|
77
|
+
"FrequencyEncode",
|
|
73
78
|
"GeneratedCode",
|
|
74
79
|
"GroupRare",
|
|
75
80
|
"Log1p",
|
|
76
81
|
"MissingIndicator",
|
|
77
82
|
"NormalizeText",
|
|
83
|
+
"OneHot",
|
|
78
84
|
"Plan",
|
|
85
|
+
"Ratio",
|
|
79
86
|
"ToNumeric",
|
|
80
87
|
"ValidationResult",
|
|
81
88
|
"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}"
|
|
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import textwrap
|
|
15
15
|
from abc import ABC, abstractmethod
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import List, Optional
|
|
17
|
+
from typing import Any, List, Optional
|
|
18
18
|
|
|
19
19
|
from ..steps import Plan, Step
|
|
20
20
|
|
|
@@ -23,6 +23,8 @@ class Writer(ABC):
|
|
|
23
23
|
engine: str = ""
|
|
24
24
|
install_hint: str = ""
|
|
25
25
|
columns: Optional[List[str]] = None
|
|
26
|
+
sql: Any = None
|
|
27
|
+
reads_sql = False # writers that can load from a database URL
|
|
26
28
|
|
|
27
29
|
def render(
|
|
28
30
|
self,
|
|
@@ -30,9 +32,17 @@ class Writer(ABC):
|
|
|
30
32
|
source: Optional[str],
|
|
31
33
|
output: str,
|
|
32
34
|
columns: Optional[List[str]] = None,
|
|
35
|
+
sql: Any = None,
|
|
33
36
|
) -> str:
|
|
34
|
-
"""`columns` are the source's column names, when known."""
|
|
37
|
+
"""`columns` are the source's column names, when known; `sql` a database source."""
|
|
35
38
|
self.columns = list(columns) if columns is not None else None
|
|
39
|
+
self.sql = sql
|
|
40
|
+
if sql is not None and not self.reads_sql:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"The {self.engine} writer can't read from a database directly. Use engine='pandas', "
|
|
43
|
+
"or export first: flasheda.sql(...).to_parquet('data.parquet'), then analyze that file.")
|
|
44
|
+
if sql is not None:
|
|
45
|
+
source = sql.display
|
|
36
46
|
parts = [
|
|
37
47
|
self._docstring(plan, source),
|
|
38
48
|
self.imports(),
|
|
@@ -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}, ' '))"
|