flasheda 0.5.0__tar.gz → 0.6.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.5.0 → flasheda-0.6.0}/PKG-INFO +24 -3
- {flasheda-0.5.0 → flasheda-0.6.0}/README.md +21 -1
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/__init__.py +19 -3
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/__init__.py +2 -2
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/categorical.py +4 -3
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/correlation.py +38 -2
- flasheda-0.6.0/flasheda/analyzers/text.py +134 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/clean/detectors.py +13 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/__init__.py +2 -1
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/steps.py +20 -2
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/duckdb_writer.py +5 -1
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/pandas_writer.py +4 -1
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/polars_writer.py +5 -1
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/pyspark_writer.py +5 -1
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/contract/rules.py +15 -4
- flasheda-0.6.0/flasheda/export/__init__.py +9 -0
- flasheda-0.6.0/flasheda/export/notebook.py +154 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/features/__init__.py +20 -1
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/report.py +45 -8
- flasheda-0.6.0/flasheda/timeseries/__init__.py +313 -0
- flasheda-0.6.0/flasheda/timeseries/forecast.py +138 -0
- flasheda-0.6.0/flasheda/timeseries/script.py +117 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/PKG-INFO +24 -3
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/SOURCES.txt +14 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/requires.txt +1 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/setup.py +3 -3
- {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_contract.py +17 -0
- flasheda-0.6.0/tests/test_forecast.py +93 -0
- flasheda-0.6.0/tests/test_html_escape.py +19 -0
- flasheda-0.6.0/tests/test_no_side_effects.py +19 -0
- flasheda-0.6.0/tests/test_notebook.py +70 -0
- flasheda-0.6.0/tests/test_point_biserial.py +41 -0
- flasheda-0.6.0/tests/test_text.py +77 -0
- flasheda-0.6.0/tests/test_text_features.py +76 -0
- flasheda-0.6.0/tests/test_timeseries.py +109 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/MIT.License +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/missing.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/numeric.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/overview.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/target.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/clean/__init__.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/planner.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/validate.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/__init__.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/base.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/compare/__init__.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/contract/__init__.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/contract/check.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/contract/script.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/__init__.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/backends.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/sampler.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/source.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/sql.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/modeling/__init__.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/modeling/script.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/modeling/specs.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/sampler.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/dependency_links.txt +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/top_level.txt +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/pyproject.toml +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/setup.cfg +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_clean_target.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_codegen.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_compare.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_contract_script.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_engine.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_features_model.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_sql.py +0 -0
- {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_writers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flasheda
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
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
|
|
@@ -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,feature-engineering,automl,data-quality,data-drift
|
|
12
|
+
Keywords: eda,data-cleaning,pandas,polars,duckdb,pyspark,code-generation,feature-engineering,automl,data-quality,data-drift,time-series,forecasting,pii
|
|
13
13
|
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Intended Audience :: Science/Research
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
@@ -52,6 +52,7 @@ Requires-Dist: pyarrow>=12; extra == "dev"
|
|
|
52
52
|
Requires-Dist: scikit-learn>=1.2; extra == "dev"
|
|
53
53
|
Requires-Dist: pandera; extra == "dev"
|
|
54
54
|
Requires-Dist: sqlalchemy>=1.4; extra == "dev"
|
|
55
|
+
Requires-Dist: nbformat; extra == "dev"
|
|
55
56
|
Requires-Dist: build; extra == "dev"
|
|
56
57
|
Requires-Dist: twine; extra == "dev"
|
|
57
58
|
Dynamic: author
|
|
@@ -78,7 +79,7 @@ Dynamic: summary
|
|
|
78
79
|
[](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
|
|
79
80
|
[](MIT.License)
|
|
80
81
|
|
|
81
|
-
**Current version: 0.
|
|
82
|
+
**Current version: 0.6.0** · [Changelog](CHANGELOG.md)
|
|
82
83
|
|
|
83
84
|
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.
|
|
84
85
|
|
|
@@ -125,6 +126,15 @@ fe.compare("orders_jan.csv", "orders_feb.csv").show()
|
|
|
125
126
|
# Databases work like files
|
|
126
127
|
src = fe.sql("postgresql://user:pass@host/shop", table="orders")
|
|
127
128
|
fe.analyze(src, target="churn")
|
|
129
|
+
|
|
130
|
+
# 6. Time series: trend, seasonality, forecast
|
|
131
|
+
ts = fe.timeseries("orders.parquet", time="created_at", value="amount")
|
|
132
|
+
ts.show()
|
|
133
|
+
fc = ts.forecast() # backtested baselines
|
|
134
|
+
open("forecast.py", "w").write(fc.script()) # python forecast.py → forecast.csv
|
|
135
|
+
|
|
136
|
+
# 7. Everything as one runnable notebook
|
|
137
|
+
report.to_notebook("orders.ipynb", train=True, time="created_at")
|
|
128
138
|
```
|
|
129
139
|
|
|
130
140
|
## What it does
|
|
@@ -142,6 +152,9 @@ fe.analyze(src, target="churn")
|
|
|
142
152
|
| **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
|
|
143
153
|
| **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
|
|
144
154
|
| **Databases** | any SQLAlchemy URL; sampling runs in the database; passwords never stored |
|
|
155
|
+
| **Text** | personal data (emails, phones, card numbers…) flagged; free-text stats; length/word features |
|
|
156
|
+
| **Time series** | full-data aggregation per hour/day/week; gaps, trend, seasonality, anomalies; backtested forecast + script |
|
|
157
|
+
| **Notebook** | analysis, cleaning, training, forecast and contract check as one runnable `.ipynb` |
|
|
145
158
|
| **Validation** | generated code is run on the sample and compared with a reference result before you get it |
|
|
146
159
|
|
|
147
160
|
### Cleaning steps
|
|
@@ -170,6 +183,10 @@ type, missing %, 1st/99th percentile range (catches unit changes like ×100), no
|
|
|
170
183
|
|
|
171
184
|
`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
185
|
|
|
186
|
+
### Time series and forecasting
|
|
187
|
+
A random sample breaks time order, so `fe.timeseries()` first aggregates **every row** per period (one DuckDB `GROUP BY`, about 0.7 s for 5M rows), then reports gaps, trend (% per year), daily/weekly/yearly seasonality, the busiest weekday or hour, and anomalies.
|
|
188
|
+
`ts.forecast()` backtests five simple baselines (naive, seasonal naive, moving average, drift, trend + season) and keeps the most accurate. These are the numbers any fancier model has to beat. `fc.script()` writes it as a standalone script.
|
|
189
|
+
|
|
173
190
|
### Engine choice
|
|
174
191
|
|
|
175
192
|
| Rows | Engine |
|
|
@@ -195,6 +212,9 @@ Measured on synthetic messy data:
|
|
|
195
212
|
| Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
|
|
196
213
|
| Compare two 1M-row files | 0.6 s |
|
|
197
214
|
| Analyse a 1M-row SQLite table (5,000 rows transferred) | 0.3 s |
|
|
215
|
+
| Time-series profile of a 5.3M-row Parquet file | 0.7 s |
|
|
216
|
+
| Generated forecast script on 5.3M rows | 0.6 s |
|
|
217
|
+
| Notebook with cleaning, features, training, forecast and contract | 5 s |
|
|
198
218
|
|
|
199
219
|
## Examples
|
|
200
220
|
|
|
@@ -202,6 +222,7 @@ Measured on synthetic messy data:
|
|
|
202
222
|
- [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
|
|
203
223
|
- [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
|
|
204
224
|
- [`examples/phase4_demo.py`](examples/phase4_demo.py): contracts, check script, drift and a database on 1M rows
|
|
225
|
+
- [`examples/phase5_demo.py`](examples/phase5_demo.py): text, time series, forecast and notebook on 5M rows
|
|
205
226
|
|
|
206
227
|
## Development
|
|
207
228
|
|
|
@@ -7,7 +7,7 @@
|
|
|
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.6.0** · [Changelog](CHANGELOG.md)
|
|
11
11
|
|
|
12
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
|
|
|
@@ -54,6 +54,15 @@ fe.compare("orders_jan.csv", "orders_feb.csv").show()
|
|
|
54
54
|
# Databases work like files
|
|
55
55
|
src = fe.sql("postgresql://user:pass@host/shop", table="orders")
|
|
56
56
|
fe.analyze(src, target="churn")
|
|
57
|
+
|
|
58
|
+
# 6. Time series: trend, seasonality, forecast
|
|
59
|
+
ts = fe.timeseries("orders.parquet", time="created_at", value="amount")
|
|
60
|
+
ts.show()
|
|
61
|
+
fc = ts.forecast() # backtested baselines
|
|
62
|
+
open("forecast.py", "w").write(fc.script()) # python forecast.py → forecast.csv
|
|
63
|
+
|
|
64
|
+
# 7. Everything as one runnable notebook
|
|
65
|
+
report.to_notebook("orders.ipynb", train=True, time="created_at")
|
|
57
66
|
```
|
|
58
67
|
|
|
59
68
|
## What it does
|
|
@@ -71,6 +80,9 @@ fe.analyze(src, target="churn")
|
|
|
71
80
|
| **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
|
|
72
81
|
| **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
|
|
73
82
|
| **Databases** | any SQLAlchemy URL; sampling runs in the database; passwords never stored |
|
|
83
|
+
| **Text** | personal data (emails, phones, card numbers…) flagged; free-text stats; length/word features |
|
|
84
|
+
| **Time series** | full-data aggregation per hour/day/week; gaps, trend, seasonality, anomalies; backtested forecast + script |
|
|
85
|
+
| **Notebook** | analysis, cleaning, training, forecast and contract check as one runnable `.ipynb` |
|
|
74
86
|
| **Validation** | generated code is run on the sample and compared with a reference result before you get it |
|
|
75
87
|
|
|
76
88
|
### Cleaning steps
|
|
@@ -99,6 +111,10 @@ type, missing %, 1st/99th percentile range (catches unit changes like ×100), no
|
|
|
99
111
|
|
|
100
112
|
`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
113
|
|
|
114
|
+
### Time series and forecasting
|
|
115
|
+
A random sample breaks time order, so `fe.timeseries()` first aggregates **every row** per period (one DuckDB `GROUP BY`, about 0.7 s for 5M rows), then reports gaps, trend (% per year), daily/weekly/yearly seasonality, the busiest weekday or hour, and anomalies.
|
|
116
|
+
`ts.forecast()` backtests five simple baselines (naive, seasonal naive, moving average, drift, trend + season) and keeps the most accurate. These are the numbers any fancier model has to beat. `fc.script()` writes it as a standalone script.
|
|
117
|
+
|
|
102
118
|
### Engine choice
|
|
103
119
|
|
|
104
120
|
| Rows | Engine |
|
|
@@ -124,6 +140,9 @@ Measured on synthetic messy data:
|
|
|
124
140
|
| Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
|
|
125
141
|
| Compare two 1M-row files | 0.6 s |
|
|
126
142
|
| Analyse a 1M-row SQLite table (5,000 rows transferred) | 0.3 s |
|
|
143
|
+
| Time-series profile of a 5.3M-row Parquet file | 0.7 s |
|
|
144
|
+
| Generated forecast script on 5.3M rows | 0.6 s |
|
|
145
|
+
| Notebook with cleaning, features, training, forecast and contract | 5 s |
|
|
127
146
|
|
|
128
147
|
## Examples
|
|
129
148
|
|
|
@@ -131,6 +150,7 @@ Measured on synthetic messy data:
|
|
|
131
150
|
- [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
|
|
132
151
|
- [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
|
|
133
152
|
- [`examples/phase4_demo.py`](examples/phase4_demo.py): contracts, check script, drift and a database on 1M rows
|
|
153
|
+
- [`examples/phase5_demo.py`](examples/phase5_demo.py): text, time series, forecast and notebook on 5M rows
|
|
134
154
|
|
|
135
155
|
## Development
|
|
136
156
|
|
|
@@ -12,12 +12,14 @@ from .clean import clean
|
|
|
12
12
|
from .modeling import train
|
|
13
13
|
from .contract import check
|
|
14
14
|
from .compare import compare
|
|
15
|
+
from .timeseries import timeseries
|
|
15
16
|
from .engine import available_engines, choose_engine, inspect_source, sample, get_sample_size, set_sample_size, sql
|
|
16
17
|
from .analyzers import overview, missing, numeric, categorical, correlation
|
|
17
18
|
from .analyzers import target as target_analyzer
|
|
19
|
+
from .analyzers import text as text_analyzer
|
|
18
20
|
from .report import EDAReport
|
|
19
21
|
|
|
20
|
-
__version__ = "0.
|
|
22
|
+
__version__ = "0.6.0"
|
|
21
23
|
__all__ = [
|
|
22
24
|
"analyze",
|
|
23
25
|
"available_engines",
|
|
@@ -30,6 +32,7 @@ __all__ = [
|
|
|
30
32
|
"inspect_source",
|
|
31
33
|
"set_sample_size",
|
|
32
34
|
"sql",
|
|
35
|
+
"timeseries",
|
|
33
36
|
"train",
|
|
34
37
|
"EDAReport",
|
|
35
38
|
]
|
|
@@ -41,7 +44,7 @@ def analyze(
|
|
|
41
44
|
*,
|
|
42
45
|
show: bool = False,
|
|
43
46
|
random_state: int = 42,
|
|
44
|
-
save_pdf: bool =
|
|
47
|
+
save_pdf: bool = False,
|
|
45
48
|
pdf_path: str = "flasheda_report.pdf",
|
|
46
49
|
engine: Optional[str] = None,
|
|
47
50
|
target: Optional[str] = None,
|
|
@@ -50,6 +53,9 @@ def analyze(
|
|
|
50
53
|
Analyse a fixed-size sample of `source`: a DataFrame, a CSV/TSV or Parquet
|
|
51
54
|
path, or a database table/query from flasheda.sql().
|
|
52
55
|
|
|
56
|
+
Nothing is written to disk unless you ask: pass save_pdf=True (to
|
|
57
|
+
pdf_path) or call report.save_pdf() / report.save_html().
|
|
58
|
+
|
|
53
59
|
`target` names the column you want to predict; it adds task detection,
|
|
54
60
|
class balance, per-feature predictive power and leakage checks.
|
|
55
61
|
|
|
@@ -71,12 +77,13 @@ def analyze(
|
|
|
71
77
|
"numeric": lambda: numeric.analyze(sampled),
|
|
72
78
|
"categorical": lambda: categorical.analyze(sampled),
|
|
73
79
|
"correlation": lambda: correlation.analyze(sampled),
|
|
80
|
+
"text": lambda: text_analyzer.analyze(sampled),
|
|
74
81
|
}
|
|
75
82
|
if target is not None:
|
|
76
83
|
tasks["target"] = lambda: target_analyzer.analyze(sampled, target, random_state)
|
|
77
84
|
|
|
78
85
|
results: dict = {}
|
|
79
|
-
with ThreadPoolExecutor(max_workers=
|
|
86
|
+
with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
|
|
80
87
|
future_map = {pool.submit(fn): name for name, fn in tasks.items()}
|
|
81
88
|
for future in as_completed(future_map):
|
|
82
89
|
name = future_map[future]
|
|
@@ -98,6 +105,14 @@ def analyze(
|
|
|
98
105
|
corr = results.get("correlation", {})
|
|
99
106
|
if corr.get("numeric_strong_pairs"):
|
|
100
107
|
warnings.append(f"{len(corr['numeric_strong_pairs'])} strongly correlated pair(s) found")
|
|
108
|
+
for p in corr.get("numeric_boolean_strong_pairs", []):
|
|
109
|
+
warnings.append(f"{p['numeric']!r} almost determines {p['boolean']!r} "
|
|
110
|
+
f"(point-biserial r = {p['point_biserial_r']})")
|
|
111
|
+
|
|
112
|
+
txt = results.get("text", {})
|
|
113
|
+
for col in txt.get("personal_data_columns", []):
|
|
114
|
+
kinds = ", ".join(f"{k} {v}%" for k, v in txt["columns"][col]["personal_data"].items())
|
|
115
|
+
warnings.append(f"Possible personal data in {col!r}: {kinds} of non-empty values")
|
|
101
116
|
|
|
102
117
|
tgt = results.get("target", {})
|
|
103
118
|
if "error" in tgt:
|
|
@@ -121,6 +136,7 @@ def analyze(
|
|
|
121
136
|
numeric=results.get("numeric", {}),
|
|
122
137
|
categorical=results.get("categorical", {}),
|
|
123
138
|
correlation=results.get("correlation", {}),
|
|
139
|
+
text=txt,
|
|
124
140
|
sample_size=len(sampled),
|
|
125
141
|
elapsed_seconds=elapsed,
|
|
126
142
|
original_shape=original_shape,
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
from . import overview, missing, numeric, categorical, correlation, target
|
|
1
|
+
from . import overview, missing, numeric, categorical, correlation, target, text
|
|
2
2
|
|
|
3
|
-
__all__ = ["overview", "missing", "numeric", "categorical", "correlation", "target"]
|
|
3
|
+
__all__ = ["overview", "missing", "numeric", "categorical", "correlation", "target", "text"]
|
|
@@ -11,7 +11,7 @@ import pandas as pd
|
|
|
11
11
|
import numpy as np
|
|
12
12
|
from typing import Dict, Any, List
|
|
13
13
|
|
|
14
|
-
from ..clean.detectors import looks_like_datetime
|
|
14
|
+
from ..clean.detectors import is_free_text, looks_like_datetime
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
HIGH_CARDINALITY_RATIO = 0.5 # unique/total > 50% → high cardinality warning
|
|
@@ -54,8 +54,9 @@ def analyze(df: pd.DataFrame) -> Dict[str, Any]:
|
|
|
54
54
|
mode_pct = round(vc.iloc[0] / n * 100, 2) if len(vc) > 0 else 0.0
|
|
55
55
|
|
|
56
56
|
# Detect potential ID columns (very high cardinality); unique
|
|
57
|
-
# timestamps are high-cardinality too, but
|
|
58
|
-
likely_id = cardinality_ratio > 0.95 and not looks_like_datetime(df[col])
|
|
57
|
+
# timestamps and free text are high-cardinality too, but not identifiers
|
|
58
|
+
likely_id = (cardinality_ratio > 0.95 and not looks_like_datetime(df[col])
|
|
59
|
+
and not is_free_text(df[col]))
|
|
59
60
|
|
|
60
61
|
results[col] = {
|
|
61
62
|
"count": n,
|
|
@@ -4,7 +4,7 @@ flasheda.analyzers.correlation
|
|
|
4
4
|
Computes:
|
|
5
5
|
- Pearson correlation for numeric–numeric pairs
|
|
6
6
|
- Cramér's V for categorical–categorical pairs
|
|
7
|
-
- Point-biserial for numeric–boolean pairs
|
|
7
|
+
- Point-biserial for numeric–boolean pairs (Pearson with the boolean as 0/1)
|
|
8
8
|
|
|
9
9
|
Flags strongly correlated pairs (potential redundant features).
|
|
10
10
|
Always operates on the fixed sample — constant time.
|
|
@@ -21,6 +21,7 @@ from itertools import combinations
|
|
|
21
21
|
STRONG_CORR_THRESHOLD = 0.80
|
|
22
22
|
MAX_COLS_FOR_HEATMAP = 20 # skip heatmap if too many cols (slow to render)
|
|
23
23
|
MAX_CRAMER_LEVELS = 100 # Cramér's V is meaningless (and slow) for ID-like columns
|
|
24
|
+
MAX_BOOL_COLS = 10
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def analyze(df: pd.DataFrame) -> Dict[str, Any]:
|
|
@@ -66,10 +67,20 @@ def analyze(df: pd.DataFrame) -> Dict[str, Any]:
|
|
|
66
67
|
"matrix": [[round(float(hm_corr.loc[r, c]), 3) for c in heatmap_cols] for r in heatmap_cols],
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
# ── Point-biserial for numeric–boolean pairs ────────────────────────────
|
|
71
|
+
biserial_pairs: List[Dict[str, Any]] = []
|
|
72
|
+
for b in [c for c in df.columns if _is_boolean(df[c])][:MAX_BOOL_COLS]:
|
|
73
|
+
flag = _as_01(df[b])
|
|
74
|
+
for a in num_cols[:MAX_COLS_FOR_HEATMAP]:
|
|
75
|
+
r = _point_biserial(df[a], flag)
|
|
76
|
+
if r is not None and abs(r) >= STRONG_CORR_THRESHOLD:
|
|
77
|
+
biserial_pairs.append({"numeric": a, "boolean": b, "point_biserial_r": round(r, 4)})
|
|
78
|
+
|
|
69
79
|
return {
|
|
70
80
|
"pearson_matrix": pearson_matrix,
|
|
71
81
|
"numeric_strong_pairs": strong_pairs,
|
|
72
82
|
"categorical_strong_pairs": cramer_pairs,
|
|
83
|
+
"numeric_boolean_strong_pairs": biserial_pairs,
|
|
73
84
|
"heatmap": heatmap_data,
|
|
74
85
|
"numeric_col_names": num_cols,
|
|
75
86
|
}
|
|
@@ -92,4 +103,29 @@ def _cramers_v(a: pd.Series, b: pd.Series) -> float | None:
|
|
|
92
103
|
return None
|
|
93
104
|
return float(np.sqrt(phi2_corr / denom))
|
|
94
105
|
except Exception:
|
|
95
|
-
return None
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def _is_boolean(s: pd.Series) -> bool:
|
|
109
|
+
"""bool dtype, or text holding only true/false."""
|
|
110
|
+
if pd.api.types.is_bool_dtype(s):
|
|
111
|
+
return True
|
|
112
|
+
values = s.dropna()
|
|
113
|
+
if not len(values) or pd.api.types.is_numeric_dtype(values):
|
|
114
|
+
return False
|
|
115
|
+
return bool(values.astype(str).str.strip().str.lower().isin(["true", "false"]).all())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _as_01(s: pd.Series) -> pd.Series:
|
|
119
|
+
if pd.api.types.is_bool_dtype(s):
|
|
120
|
+
return s.astype(float)
|
|
121
|
+
text = s.astype("string").str.strip().str.lower()
|
|
122
|
+
return text.map({"true": 1.0, "false": 0.0}).astype(float)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _point_biserial(x: pd.Series, flag: pd.Series) -> float | None:
|
|
126
|
+
"""Point-biserial r: Pearson between a numeric column and a 0/1 flag."""
|
|
127
|
+
pair = pd.DataFrame({"x": pd.to_numeric(x, errors="coerce"), "b": flag}).dropna()
|
|
128
|
+
if len(pair) < 3 or pair["x"].std() == 0 or pair["b"].nunique() < 2:
|
|
129
|
+
return None
|
|
130
|
+
from scipy.stats import pointbiserialr
|
|
131
|
+
return float(pointbiserialr(pair["b"], pair["x"]).statistic)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
flasheda.analyzers.text
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Text columns:
|
|
5
|
+
|
|
6
|
+
personal data emails, phone numbers, URLs, IP addresses and card numbers
|
|
7
|
+
(Luhn-checked) found in any text column, as a share of the
|
|
8
|
+
column's non-empty values
|
|
9
|
+
free text for columns that read like sentences (≥ 3 words on average
|
|
10
|
+
or ≥ 30 characters): length and word statistics, empty and
|
|
11
|
+
duplicate values, all-caps share, most common words
|
|
12
|
+
|
|
13
|
+
Regex-based and dependency-free; runs on the fixed sample.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from typing import Any, Dict, List
|
|
21
|
+
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from ..clean.detectors import is_free_text, is_text
|
|
25
|
+
|
|
26
|
+
PII_PATTERNS = {
|
|
27
|
+
"email": r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
|
|
28
|
+
"phone": r"(?<![\w.])\+?\d[\d\s().-]{7,}\d(?![\w.])",
|
|
29
|
+
"url": r"\bhttps?://[^\s]+|\bwww\.[^\s]+",
|
|
30
|
+
"ip_address": r"(?<![\d.])(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?![\d.])",
|
|
31
|
+
"card_number": r"\b(?:\d[ -]?){13,19}\b",
|
|
32
|
+
}
|
|
33
|
+
PII_MIN_PCT = 1.0 # report a type when at least 1% of values contain it
|
|
34
|
+
TOP_WORDS = 10
|
|
35
|
+
STOPWORDS = frozenset("""a an and are as at be but by for from has have i in is it its of on or
|
|
36
|
+
that the this to was were will with you your we our they their he she not no so if when what how
|
|
37
|
+
my me all just can do did been than then there about up out very""".split())
|
|
38
|
+
WORD = re.compile(r"[a-z][a-z']+")
|
|
39
|
+
DATE_LIKE = re.compile(r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}|\d:\d{2}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def analyze(df: pd.DataFrame) -> Dict[str, Any]:
|
|
43
|
+
columns: Dict[str, Dict[str, Any]] = {}
|
|
44
|
+
for col in df.columns:
|
|
45
|
+
s = df[col]
|
|
46
|
+
if not is_text(s):
|
|
47
|
+
continue
|
|
48
|
+
values = s.dropna().astype(str)
|
|
49
|
+
info: Dict[str, Any] = {}
|
|
50
|
+
pii = personal_data(values)
|
|
51
|
+
if pii:
|
|
52
|
+
info["personal_data"] = pii
|
|
53
|
+
if is_free_text(s):
|
|
54
|
+
info["free_text"] = _free_text_stats(values, values.str.len(), values.str.count(r"\S+"))
|
|
55
|
+
if info:
|
|
56
|
+
columns[str(col)] = info
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"columns": columns,
|
|
60
|
+
"personal_data_columns": [c for c, v in columns.items() if "personal_data" in v],
|
|
61
|
+
"free_text_columns": [c for c, v in columns.items() if "free_text" in v],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def personal_data(values: pd.Series) -> Dict[str, float]:
|
|
66
|
+
"""{kind: % of non-empty values containing it}, for kinds in ≥ PII_MIN_PCT of them."""
|
|
67
|
+
if not len(values):
|
|
68
|
+
return {}
|
|
69
|
+
found = {}
|
|
70
|
+
for kind, pattern in PII_PATTERNS.items():
|
|
71
|
+
hits = values.str.contains(pattern, regex=True)
|
|
72
|
+
if kind == "card_number" and hits.any():
|
|
73
|
+
hits = hits & values.map(_has_luhn_number)
|
|
74
|
+
if kind == "phone" and hits.any():
|
|
75
|
+
hits = hits & values.map(_looks_like_phone) & \
|
|
76
|
+
~values.str.contains(PII_PATTERNS["ip_address"], regex=True)
|
|
77
|
+
pct = round(float(hits.mean() * 100), 2)
|
|
78
|
+
if pct >= PII_MIN_PCT:
|
|
79
|
+
found[kind] = pct
|
|
80
|
+
if "card_number" in found:
|
|
81
|
+
found.pop("phone", None) # card numbers also match the phone pattern
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _looks_like_phone(text: str) -> bool:
|
|
86
|
+
"""9–15 digits, written with a + or separators, or exactly 10 plain digits.
|
|
87
|
+
Plain digit runs of other lengths are usually order or account numbers."""
|
|
88
|
+
for m in re.finditer(PII_PATTERNS["phone"], text):
|
|
89
|
+
raw = m.group()
|
|
90
|
+
digits = sum(ch.isdigit() for ch in raw)
|
|
91
|
+
formatted = raw.startswith("+") or any(ch in raw for ch in " ().-")
|
|
92
|
+
if DATE_LIKE.search(raw) or re.fullmatch(r"\d+\.\d+", raw):
|
|
93
|
+
continue # dates, times and decimals have digit runs too
|
|
94
|
+
if 9 <= digits <= 15 and (formatted or digits == 10):
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _has_luhn_number(text: str) -> bool:
|
|
100
|
+
for m in re.finditer(PII_PATTERNS["card_number"], text):
|
|
101
|
+
digits = [int(d) for d in re.sub(r"\D", "", m.group())]
|
|
102
|
+
if 13 <= len(digits) <= 19 and _luhn(digits):
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _luhn(digits: List[int]) -> bool:
|
|
108
|
+
total = 0
|
|
109
|
+
for i, d in enumerate(reversed(digits)):
|
|
110
|
+
if i % 2:
|
|
111
|
+
d *= 2
|
|
112
|
+
d -= 9 if d > 9 else 0
|
|
113
|
+
total += d
|
|
114
|
+
return total % 10 == 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _free_text_stats(values: pd.Series, lengths: pd.Series, words: pd.Series) -> Dict[str, Any]:
|
|
118
|
+
stripped = values.str.strip()
|
|
119
|
+
letters = stripped.str.replace(r"[^A-Za-z]", "", regex=True)
|
|
120
|
+
counter: Counter = Counter()
|
|
121
|
+
for text in values.str.lower().head(2_000):
|
|
122
|
+
counter.update(w for w in WORD.findall(text) if w not in STOPWORDS)
|
|
123
|
+
n = len(values)
|
|
124
|
+
return {
|
|
125
|
+
"mean_chars": round(float(lengths.mean()), 1),
|
|
126
|
+
"median_chars": float(lengths.median()),
|
|
127
|
+
"max_chars": int(lengths.max()),
|
|
128
|
+
"mean_words": round(float(words.mean()), 1),
|
|
129
|
+
"empty_pct": round(float((stripped == "").mean() * 100), 2),
|
|
130
|
+
"duplicate_pct": round(float(values.duplicated().mean() * 100), 2),
|
|
131
|
+
"all_caps_pct": round(float(((letters.str.len() > 3) & (letters == letters.str.upper())).mean() * 100), 2),
|
|
132
|
+
"top_words": [w for w, _ in counter.most_common(TOP_WORDS)],
|
|
133
|
+
"rows": n,
|
|
134
|
+
}
|
|
@@ -41,6 +41,19 @@ def looks_like_datetime(s: pd.Series, min_ratio: float = 0.95) -> bool:
|
|
|
41
41
|
return bool(parsed.notna().mean() >= min_ratio)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
FREE_TEXT_MIN_WORDS = 3.0
|
|
45
|
+
FREE_TEXT_MIN_CHARS = 30.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_free_text(s: pd.Series) -> bool:
|
|
49
|
+
"""Text that reads like sentences: ≥ 3 words or ≥ 30 characters on average."""
|
|
50
|
+
if not is_text(s):
|
|
51
|
+
return False
|
|
52
|
+
values = s.dropna().astype(str).head(2_000)
|
|
53
|
+
return bool(values.str.count(r"\S+").mean() >= FREE_TEXT_MIN_WORDS
|
|
54
|
+
or values.str.len().mean() >= FREE_TEXT_MIN_CHARS)
|
|
55
|
+
|
|
56
|
+
|
|
44
57
|
def numeric_text_ratio(s: pd.Series) -> Optional[float]:
|
|
45
58
|
"""Share of non-empty text values that parse as numbers, or None if it's not text."""
|
|
46
59
|
if not is_text(s):
|
|
@@ -18,7 +18,7 @@ from .planner import plan_from_report
|
|
|
18
18
|
from .steps import (
|
|
19
19
|
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
20
20
|
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio,
|
|
21
|
-
ToNumeric,
|
|
21
|
+
TextStats, ToNumeric,
|
|
22
22
|
)
|
|
23
23
|
from .validate import ValidationResult, compare_frames, run_generated, validate_code
|
|
24
24
|
from .writers import WRITERS, get_writer
|
|
@@ -83,6 +83,7 @@ __all__ = [
|
|
|
83
83
|
"OneHot",
|
|
84
84
|
"Plan",
|
|
85
85
|
"Ratio",
|
|
86
|
+
"TextStats",
|
|
86
87
|
"ToNumeric",
|
|
87
88
|
"ValidationResult",
|
|
88
89
|
"WRITERS",
|
|
@@ -155,16 +155,26 @@ class FrequencyEncode:
|
|
|
155
155
|
reason: str = ""
|
|
156
156
|
|
|
157
157
|
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class TextStats:
|
|
160
|
+
"""Add `<column>_length` (characters) and `<column>_words` (runs of non-spaces)."""
|
|
161
|
+
|
|
162
|
+
column: str
|
|
163
|
+
length_name: str
|
|
164
|
+
words_name: str
|
|
165
|
+
reason: str = ""
|
|
166
|
+
|
|
167
|
+
|
|
158
168
|
ROW_STEPS = (DropColumns, DropDuplicates, DropNullRows)
|
|
159
169
|
COLUMN_STEPS = (
|
|
160
170
|
ToNumeric, NormalizeText, GroupRare, MissingIndicator, FillNull, ClipOutliers, Log1p,
|
|
161
|
-
DateParts, Ratio, OneHot, FrequencyEncode,
|
|
171
|
+
DateParts, Ratio, OneHot, FrequencyEncode, TextStats,
|
|
162
172
|
)
|
|
163
173
|
|
|
164
174
|
Step = Union[
|
|
165
175
|
DropColumns, DropDuplicates, DropNullRows,
|
|
166
176
|
ToNumeric, NormalizeText, GroupRare, MissingIndicator, FillNull, ClipOutliers, Log1p,
|
|
167
|
-
DateParts, Ratio, OneHot, FrequencyEncode,
|
|
177
|
+
DateParts, Ratio, OneHot, FrequencyEncode, TextStats,
|
|
168
178
|
]
|
|
169
179
|
|
|
170
180
|
|
|
@@ -183,6 +193,8 @@ def outputs(step) -> tuple:
|
|
|
183
193
|
return (step.name,)
|
|
184
194
|
if isinstance(step, (DateParts, OneHot)):
|
|
185
195
|
return step.names
|
|
196
|
+
if isinstance(step, TextStats):
|
|
197
|
+
return (step.length_name, step.words_name)
|
|
186
198
|
return ()
|
|
187
199
|
|
|
188
200
|
|
|
@@ -268,6 +280,10 @@ class Plan:
|
|
|
268
280
|
s = out[step.column]
|
|
269
281
|
for value, name in zip(step.values, step.names):
|
|
270
282
|
out[name] = s.eq(value).fillna(False).astype(bool)
|
|
283
|
+
elif isinstance(step, TextStats):
|
|
284
|
+
s = out[step.column]
|
|
285
|
+
out[step.length_name] = s.str.len().astype("float64")
|
|
286
|
+
out[step.words_name] = s.str.count(r"\S+").astype("float64")
|
|
271
287
|
elif isinstance(step, FrequencyEncode):
|
|
272
288
|
mapping = dict(step.mapping)
|
|
273
289
|
out[step.column] = out[step.column].map(mapping).astype("float64").fillna(step.default)
|
|
@@ -301,4 +317,6 @@ def _label(step: Step) -> str:
|
|
|
301
317
|
return f"add {step.name!r} = {step.numerator!r} / {step.denominator!r}"
|
|
302
318
|
if isinstance(step, OneHot):
|
|
303
319
|
return f"one-hot encode {step.column!r} ({len(step.values)} columns)"
|
|
320
|
+
if isinstance(step, TextStats):
|
|
321
|
+
return f"add length and word count of {step.column!r}"
|
|
304
322
|
return f"frequency-encode {step.column!r}"
|
|
@@ -5,7 +5,7 @@ from typing import List, Optional
|
|
|
5
5
|
from ..steps import (
|
|
6
6
|
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
7
|
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio, Step,
|
|
8
|
-
ToNumeric,
|
|
8
|
+
TextStats, ToNumeric,
|
|
9
9
|
)
|
|
10
10
|
from ...engine.source import DUCKDB_CSV_TYPES
|
|
11
11
|
from .base import Writer, source_format
|
|
@@ -82,6 +82,10 @@ class DuckDBWriter(Writer):
|
|
|
82
82
|
"dayofweek": "isodow", "hour": "hour"}[part]
|
|
83
83
|
added[name] = f"CAST({fn}({ts}) AS DOUBLE)"
|
|
84
84
|
continue
|
|
85
|
+
if isinstance(step, TextStats):
|
|
86
|
+
added[step.length_name] = f"CAST(length({col}) AS DOUBLE)"
|
|
87
|
+
added[step.words_name] = f"CAST(len(regexp_extract_all({col}, '\\S+')) AS DOUBLE)"
|
|
88
|
+
continue
|
|
85
89
|
if isinstance(step, OneHot):
|
|
86
90
|
for value, name in zip(step.values, step.names):
|
|
87
91
|
added[name] = f"coalesce({col} = {literal(value)}, false)"
|
|
@@ -5,7 +5,7 @@ from typing import Optional
|
|
|
5
5
|
from ..steps import (
|
|
6
6
|
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
7
|
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio,
|
|
8
|
-
ToNumeric,
|
|
8
|
+
TextStats, ToNumeric,
|
|
9
9
|
)
|
|
10
10
|
from .base import Writer, source_format
|
|
11
11
|
|
|
@@ -69,6 +69,9 @@ class PandasWriter(Writer):
|
|
|
69
69
|
return "\n".join(
|
|
70
70
|
f"df[{name!r}] = {c}.eq({value!r}).fillna(False).astype(bool)"
|
|
71
71
|
for value, name in zip(step.values, step.names))
|
|
72
|
+
if isinstance(step, TextStats):
|
|
73
|
+
return (f'df[{step.length_name!r}] = {c}.str.len().astype("float64")\n'
|
|
74
|
+
f'df[{step.words_name!r}] = {c}.str.count(r"\\S+").astype("float64")')
|
|
72
75
|
if isinstance(step, FrequencyEncode):
|
|
73
76
|
return f'{c} = {c}.map({dict(step.mapping)!r}).astype("float64").fillna({step.default!r})'
|
|
74
77
|
raise TypeError(f"Unsupported step {step!r}")
|
|
@@ -5,7 +5,7 @@ from typing import Dict, List, Optional
|
|
|
5
5
|
from ..steps import (
|
|
6
6
|
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
7
|
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio,
|
|
8
|
-
ToNumeric,
|
|
8
|
+
TextStats, ToNumeric,
|
|
9
9
|
)
|
|
10
10
|
from .base import Writer, source_format
|
|
11
11
|
|
|
@@ -70,6 +70,10 @@ class PolarsWriter(Writer):
|
|
|
70
70
|
"dayofweek": "weekday", "hour": "hour"}[part]
|
|
71
71
|
add(name, f"{ts}.dt.{fn}().cast(pl.Float64)", step.reason)
|
|
72
72
|
continue
|
|
73
|
+
if isinstance(step, TextStats):
|
|
74
|
+
add(step.length_name, f"{e}.str.len_chars().cast(pl.Float64)", step.reason)
|
|
75
|
+
add(step.words_name, f'{e}.str.count_matches(r"\\S+").cast(pl.Float64)', step.reason)
|
|
76
|
+
continue
|
|
73
77
|
if isinstance(step, OneHot):
|
|
74
78
|
for value, name in zip(step.values, step.names):
|
|
75
79
|
add(name, f"({e} == {value!r}).fill_null(False)", step.reason)
|
|
@@ -5,7 +5,7 @@ from typing import Optional
|
|
|
5
5
|
from ..steps import (
|
|
6
6
|
ClipOutliers, DateParts, DropColumns, DropDuplicates, DropNullRows, FillNull,
|
|
7
7
|
FrequencyEncode, GroupRare, Log1p, MissingIndicator, NormalizeText, OneHot, Plan, Ratio,
|
|
8
|
-
ToNumeric,
|
|
8
|
+
TextStats, ToNumeric,
|
|
9
9
|
)
|
|
10
10
|
from .base import Writer, source_format
|
|
11
11
|
|
|
@@ -59,6 +59,10 @@ class PySparkWriter(Writer):
|
|
|
59
59
|
value = f"{spark[part]}({ts})"
|
|
60
60
|
lines.append(f'df = df.withColumn({name!r}, ({value}).cast("double"))')
|
|
61
61
|
return "\n".join(lines)
|
|
62
|
+
if isinstance(step, TextStats):
|
|
63
|
+
return (f'df = df.withColumn({step.length_name!r}, F.length({c}).cast("double"))\n'
|
|
64
|
+
f"df = df.withColumn({step.words_name!r}, "
|
|
65
|
+
f'F.size(F.regexp_extract_all({c}, F.lit(r"\\S+"), F.lit(0))).cast("double"))')
|
|
62
66
|
if isinstance(step, OneHot):
|
|
63
67
|
return "\n".join(
|
|
64
68
|
f"df = df.withColumn({name!r}, F.coalesce({c} == {value!r}, F.lit(False)))"
|