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.
Files changed (56) hide show
  1. {flasheda-0.3.0 → flasheda-0.5.0}/PKG-INFO +67 -5
  2. {flasheda-0.3.0 → flasheda-0.5.0}/README.md +49 -2
  3. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/__init__.py +15 -4
  4. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/clean/__init__.py +4 -1
  5. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/clean/detectors.py +1 -2
  6. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/__init__.py +10 -3
  7. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/steps.py +107 -3
  8. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/base.py +12 -2
  9. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/duckdb_writer.py +27 -4
  10. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/pandas_writer.py +40 -3
  11. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/polars_writer.py +31 -4
  12. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/pyspark_writer.py +26 -3
  13. flasheda-0.5.0/flasheda/compare/__init__.py +224 -0
  14. flasheda-0.5.0/flasheda/contract/__init__.py +29 -0
  15. flasheda-0.5.0/flasheda/contract/check.py +251 -0
  16. flasheda-0.5.0/flasheda/contract/rules.py +216 -0
  17. flasheda-0.5.0/flasheda/contract/script.py +227 -0
  18. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/engine/__init__.py +3 -0
  19. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/engine/sampler.py +4 -1
  20. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/engine/source.py +13 -0
  21. flasheda-0.5.0/flasheda/engine/sql.py +129 -0
  22. flasheda-0.5.0/flasheda/features/__init__.py +169 -0
  23. flasheda-0.5.0/flasheda/modeling/__init__.py +230 -0
  24. flasheda-0.5.0/flasheda/modeling/script.py +138 -0
  25. flasheda-0.5.0/flasheda/modeling/specs.py +79 -0
  26. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/report.py +32 -6
  27. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/PKG-INFO +67 -5
  28. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/SOURCES.txt +15 -0
  29. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/requires.txt +18 -0
  30. {flasheda-0.3.0 → flasheda-0.5.0}/setup.py +9 -5
  31. flasheda-0.5.0/tests/test_compare.py +90 -0
  32. flasheda-0.5.0/tests/test_contract.py +140 -0
  33. flasheda-0.5.0/tests/test_contract_script.py +81 -0
  34. flasheda-0.5.0/tests/test_features_model.py +138 -0
  35. flasheda-0.5.0/tests/test_sql.py +81 -0
  36. {flasheda-0.3.0 → flasheda-0.5.0}/tests/test_writers.py +55 -3
  37. {flasheda-0.3.0 → flasheda-0.5.0}/MIT.License +0 -0
  38. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/__init__.py +0 -0
  39. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/categorical.py +0 -0
  40. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/correlation.py +0 -0
  41. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/missing.py +0 -0
  42. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/numeric.py +0 -0
  43. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/overview.py +0 -0
  44. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/analyzers/target.py +0 -0
  45. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/planner.py +0 -0
  46. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/validate.py +0 -0
  47. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/codegen/writers/__init__.py +0 -0
  48. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/engine/backends.py +0 -0
  49. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda/sampler.py +0 -0
  50. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/dependency_links.txt +0 -0
  51. {flasheda-0.3.0 → flasheda-0.5.0}/flasheda.egg-info/top_level.txt +0 -0
  52. {flasheda-0.3.0 → flasheda-0.5.0}/pyproject.toml +0 -0
  53. {flasheda-0.3.0 → flasheda-0.5.0}/setup.cfg +0 -0
  54. {flasheda-0.3.0 → flasheda-0.5.0}/tests/test_clean_target.py +0 -0
  55. {flasheda-0.3.0 → flasheda-0.5.0}/tests/test_codegen.py +0 -0
  56. {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.3.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
  [![Tests](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml/badge.svg)](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
64
79
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT.License)
65
80
 
66
- **Current version: 0.3.0** · [Changelog](CHANGELOG.md)
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
  [![Tests](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml/badge.svg)](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT.License)
9
9
 
10
- **Current version: 0.3.0** · [Changelog](CHANGELOG.md)
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 .engine import available_engines, choose_engine, inspect_source, sample, get_sample_size, set_sample_size
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.3.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` (DataFrame, CSV/TSV or Parquet path).
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
- recommended_engine=choose_engine(info.n_rows, prefer=engine),
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, **plan_options)
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[:RARE_MAX_KEEP]
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, GroupRare, Log1p,
20
- MissingIndicator, NormalizeText, Plan, ToNumeric,
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 = (ToNumeric, NormalizeText, GroupRare, MissingIndicator, FillNull, ClipOutliers, Log1p)
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
- return f"log transform {step.column!r}"
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, GroupRare, Log1p,
7
- MissingIndicator, NormalizeText, Plan, Step, ToNumeric,
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
- col = replaced.get(step.column, ident(step.column))
69
- if isinstance(step, ToNumeric):
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}, ' '))"