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.
Files changed (46) hide show
  1. {flasheda-0.3.0 → flasheda-0.4.0}/PKG-INFO +34 -5
  2. {flasheda-0.3.0 → flasheda-0.4.0}/README.md +22 -2
  3. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/__init__.py +3 -1
  4. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/clean/__init__.py +4 -1
  5. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/clean/detectors.py +1 -2
  6. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/__init__.py +7 -2
  7. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/steps.py +107 -3
  8. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/duckdb_writer.py +27 -4
  9. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/pandas_writer.py +21 -3
  10. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/polars_writer.py +31 -4
  11. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/pyspark_writer.py +26 -3
  12. flasheda-0.4.0/flasheda/features/__init__.py +169 -0
  13. flasheda-0.4.0/flasheda/modeling/__init__.py +230 -0
  14. flasheda-0.4.0/flasheda/modeling/script.py +138 -0
  15. flasheda-0.4.0/flasheda/modeling/specs.py +79 -0
  16. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/report.py +24 -5
  17. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/PKG-INFO +34 -5
  18. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/SOURCES.txt +5 -0
  19. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/requires.txt +11 -0
  20. {flasheda-0.3.0 → flasheda-0.4.0}/setup.py +8 -5
  21. flasheda-0.4.0/tests/test_features_model.py +138 -0
  22. {flasheda-0.3.0 → flasheda-0.4.0}/tests/test_writers.py +55 -3
  23. {flasheda-0.3.0 → flasheda-0.4.0}/MIT.License +0 -0
  24. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/__init__.py +0 -0
  25. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/categorical.py +0 -0
  26. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/correlation.py +0 -0
  27. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/missing.py +0 -0
  28. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/numeric.py +0 -0
  29. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/overview.py +0 -0
  30. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/analyzers/target.py +0 -0
  31. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/planner.py +0 -0
  32. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/validate.py +0 -0
  33. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/__init__.py +0 -0
  34. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/codegen/writers/base.py +0 -0
  35. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/engine/__init__.py +0 -0
  36. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/engine/backends.py +0 -0
  37. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/engine/sampler.py +0 -0
  38. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/engine/source.py +0 -0
  39. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda/sampler.py +0 -0
  40. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/dependency_links.txt +0 -0
  41. {flasheda-0.3.0 → flasheda-0.4.0}/flasheda.egg-info/top_level.txt +0 -0
  42. {flasheda-0.3.0 → flasheda-0.4.0}/pyproject.toml +0 -0
  43. {flasheda-0.3.0 → flasheda-0.4.0}/setup.cfg +0 -0
  44. {flasheda-0.3.0 → flasheda-0.4.0}/tests/test_clean_target.py +0 -0
  45. {flasheda-0.3.0 → flasheda-0.4.0}/tests/test_codegen.py +0 -0
  46. {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.3.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
  [![Tests](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml/badge.svg)](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
64
73
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT.License)
65
74
 
66
- **Current version: 0.3.0** · [Changelog](CHANGELOG.md)
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
  [![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.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.3.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, **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
@@ -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 = (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}"
@@ -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}, ' '))"
@@ -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, GroupRare, Log1p,
7
- MissingIndicator, NormalizeText, Plan, ToNumeric,
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.append(self._line(step))
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, GroupRare, Log1p,
7
- MissingIndicator, NormalizeText, Plan, ToNumeric,
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
- e = exprs.get(step.column, f"pl.col({step.column!r})")
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, ToNumeric):
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, GroupRare, Log1p,
7
- MissingIndicator, NormalizeText, Plan, ToNumeric,
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.append(self._line(step))
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})"