flasheda 0.4.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.
Files changed (70) hide show
  1. {flasheda-0.4.0 → flasheda-0.6.0}/PKG-INFO +57 -3
  2. {flasheda-0.4.0 → flasheda-0.6.0}/README.md +48 -1
  3. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/__init__.py +31 -6
  4. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/analyzers/__init__.py +2 -2
  5. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/analyzers/categorical.py +4 -3
  6. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/analyzers/correlation.py +38 -2
  7. flasheda-0.6.0/flasheda/analyzers/text.py +134 -0
  8. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/clean/detectors.py +13 -0
  9. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/__init__.py +5 -2
  10. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/steps.py +20 -2
  11. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/writers/base.py +12 -2
  12. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/writers/duckdb_writer.py +5 -1
  13. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/writers/pandas_writer.py +23 -1
  14. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/writers/polars_writer.py +5 -1
  15. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/writers/pyspark_writer.py +5 -1
  16. flasheda-0.6.0/flasheda/compare/__init__.py +224 -0
  17. flasheda-0.6.0/flasheda/contract/__init__.py +29 -0
  18. flasheda-0.6.0/flasheda/contract/check.py +251 -0
  19. flasheda-0.6.0/flasheda/contract/rules.py +227 -0
  20. flasheda-0.6.0/flasheda/contract/script.py +227 -0
  21. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/engine/__init__.py +3 -0
  22. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/engine/sampler.py +4 -1
  23. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/engine/source.py +13 -0
  24. flasheda-0.6.0/flasheda/engine/sql.py +129 -0
  25. flasheda-0.6.0/flasheda/export/__init__.py +9 -0
  26. flasheda-0.6.0/flasheda/export/notebook.py +154 -0
  27. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/features/__init__.py +20 -1
  28. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/report.py +53 -9
  29. flasheda-0.6.0/flasheda/timeseries/__init__.py +313 -0
  30. flasheda-0.6.0/flasheda/timeseries/forecast.py +138 -0
  31. flasheda-0.6.0/flasheda/timeseries/script.py +117 -0
  32. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda.egg-info/PKG-INFO +57 -3
  33. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda.egg-info/SOURCES.txt +24 -0
  34. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda.egg-info/requires.txt +8 -0
  35. {flasheda-0.4.0 → flasheda-0.6.0}/setup.py +5 -4
  36. flasheda-0.6.0/tests/test_compare.py +90 -0
  37. flasheda-0.6.0/tests/test_contract.py +157 -0
  38. flasheda-0.6.0/tests/test_contract_script.py +81 -0
  39. flasheda-0.6.0/tests/test_forecast.py +93 -0
  40. flasheda-0.6.0/tests/test_html_escape.py +19 -0
  41. flasheda-0.6.0/tests/test_no_side_effects.py +19 -0
  42. flasheda-0.6.0/tests/test_notebook.py +70 -0
  43. flasheda-0.6.0/tests/test_point_biserial.py +41 -0
  44. flasheda-0.6.0/tests/test_sql.py +81 -0
  45. flasheda-0.6.0/tests/test_text.py +77 -0
  46. flasheda-0.6.0/tests/test_text_features.py +76 -0
  47. flasheda-0.6.0/tests/test_timeseries.py +109 -0
  48. {flasheda-0.4.0 → flasheda-0.6.0}/MIT.License +0 -0
  49. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/analyzers/missing.py +0 -0
  50. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/analyzers/numeric.py +0 -0
  51. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/analyzers/overview.py +0 -0
  52. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/analyzers/target.py +0 -0
  53. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/clean/__init__.py +0 -0
  54. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/planner.py +0 -0
  55. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/validate.py +0 -0
  56. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/codegen/writers/__init__.py +0 -0
  57. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/engine/backends.py +0 -0
  58. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/modeling/__init__.py +0 -0
  59. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/modeling/script.py +0 -0
  60. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/modeling/specs.py +0 -0
  61. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda/sampler.py +0 -0
  62. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda.egg-info/dependency_links.txt +0 -0
  63. {flasheda-0.4.0 → flasheda-0.6.0}/flasheda.egg-info/top_level.txt +0 -0
  64. {flasheda-0.4.0 → flasheda-0.6.0}/pyproject.toml +0 -0
  65. {flasheda-0.4.0 → flasheda-0.6.0}/setup.cfg +0 -0
  66. {flasheda-0.4.0 → flasheda-0.6.0}/tests/test_clean_target.py +0 -0
  67. {flasheda-0.4.0 → flasheda-0.6.0}/tests/test_codegen.py +0 -0
  68. {flasheda-0.4.0 → flasheda-0.6.0}/tests/test_engine.py +0 -0
  69. {flasheda-0.4.0 → flasheda-0.6.0}/tests/test_features_model.py +0 -0
  70. {flasheda-0.4.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.4.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
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
@@ -35,17 +35,24 @@ Requires-Dist: pyspark>=3.4; extra == "spark"
35
35
  Provides-Extra: ml
36
36
  Requires-Dist: scikit-learn>=1.2; extra == "ml"
37
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"
38
41
  Provides-Extra: all
39
42
  Requires-Dist: duckdb>=0.10; extra == "all"
40
43
  Requires-Dist: polars>=0.20; extra == "all"
41
44
  Requires-Dist: pyarrow>=12; extra == "all"
42
45
  Requires-Dist: scikit-learn>=1.2; extra == "all"
46
+ Requires-Dist: sqlalchemy>=1.4; extra == "all"
43
47
  Provides-Extra: dev
44
48
  Requires-Dist: pytest>=7; extra == "dev"
45
49
  Requires-Dist: duckdb>=0.10; extra == "dev"
46
50
  Requires-Dist: polars>=0.20; extra == "dev"
47
51
  Requires-Dist: pyarrow>=12; extra == "dev"
48
52
  Requires-Dist: scikit-learn>=1.2; extra == "dev"
53
+ Requires-Dist: pandera; extra == "dev"
54
+ Requires-Dist: sqlalchemy>=1.4; extra == "dev"
55
+ Requires-Dist: nbformat; extra == "dev"
49
56
  Requires-Dist: build; extra == "dev"
50
57
  Requires-Dist: twine; extra == "dev"
51
58
  Dynamic: author
@@ -72,7 +79,7 @@ Dynamic: summary
72
79
  [![Tests](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml/badge.svg)](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
73
80
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT.License)
74
81
 
75
- **Current version: 0.4.0** · [Changelog](CHANGELOG.md)
82
+ **Current version: 0.6.0** · [Changelog](CHANGELOG.md)
76
83
 
77
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.
78
85
 
@@ -84,6 +91,7 @@ FlashEDA analyses a fixed-size random sample, so analysis time barely changes be
84
91
  pip install flasheda # core
85
92
  pip install "flasheda[fast]" # + polars, DuckDB, pyarrow (recommended for big files)
86
93
  pip install "flasheda[ml]" # + scikit-learn, for flasheda.train
94
+ pip install "flasheda[sql]" # + SQLAlchemy, for database tables
87
95
  pip install "flasheda[spark]" # + PySpark
88
96
  pip install "flasheda[all]" # everything except PySpark
89
97
  ```
@@ -106,6 +114,27 @@ code.save("clean_orders.py") # then: python clean_orders.py
106
114
  result = fe.train("orders.csv", target="churn", engine="polars")
107
115
  result.show() # leaderboard
108
116
  result.save("prep.py", "train.py") # python prep.py && python train.py → model.joblib
117
+
118
+ # 4. Guard: learn rules from good data, check every new file
119
+ contract = fe.analyze("orders_jan.csv").contract()
120
+ fe.check("orders_feb.csv", contract).show()
121
+ open("check.py", "w").write(contract.script()) # python check.py new.csv → exit 1 on failure
122
+
123
+ # 5. Compare: what changed between two datasets
124
+ fe.compare("orders_jan.csv", "orders_feb.csv").show()
125
+
126
+ # Databases work like files
127
+ src = fe.sql("postgresql://user:pass@host/shop", table="orders")
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")
109
138
  ```
110
139
 
111
140
  ## What it does
@@ -120,6 +149,12 @@ result.save("prep.py", "train.py") # python prep.py && python train.py → model
120
149
  | **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
121
150
  | **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
122
151
  | **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
152
+ | **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
153
+ | **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
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` |
123
158
  | **Validation** | generated code is run on the sample and compared with a reference result before you get it |
124
159
 
125
160
  ### Cleaning steps
@@ -141,6 +176,17 @@ Each step in the generated code has a comment saying why it was added:
141
176
 
142
177
  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
178
 
179
+ ### Data contracts and drift
180
+ A **contract** describes what good data looks like, learned from a sample with tolerances so normal variation passes:
181
+ type, missing %, 1st/99th percentile range (catches unit changes like ×100), no negatives, allowed values, uniqueness of IDs.
182
+ `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.
183
+
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.
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
+
144
190
  ### Engine choice
145
191
 
146
192
  | Rows | Engine |
@@ -163,12 +209,20 @@ Measured on synthetic messy data:
163
209
  | Run the generated polars script on 1M rows | 0.9 s |
164
210
  | `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
165
211
  | Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
212
+ | Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
213
+ | Compare two 1M-row files | 0.6 s |
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 |
166
218
 
167
219
  ## Examples
168
220
 
169
221
  - [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
170
222
  - [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
171
223
  - [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
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
172
226
 
173
227
  ## Development
174
228
 
@@ -7,7 +7,7 @@
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.4.0** · [Changelog](CHANGELOG.md)
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
 
@@ -19,6 +19,7 @@ FlashEDA analyses a fixed-size random sample, so analysis time barely changes be
19
19
  pip install flasheda # core
20
20
  pip install "flasheda[fast]" # + polars, DuckDB, pyarrow (recommended for big files)
21
21
  pip install "flasheda[ml]" # + scikit-learn, for flasheda.train
22
+ pip install "flasheda[sql]" # + SQLAlchemy, for database tables
22
23
  pip install "flasheda[spark]" # + PySpark
23
24
  pip install "flasheda[all]" # everything except PySpark
24
25
  ```
@@ -41,6 +42,27 @@ code.save("clean_orders.py") # then: python clean_orders.py
41
42
  result = fe.train("orders.csv", target="churn", engine="polars")
42
43
  result.show() # leaderboard
43
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")
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")
44
66
  ```
45
67
 
46
68
  ## What it does
@@ -55,6 +77,12 @@ result.save("prep.py", "train.py") # python prep.py && python train.py → model
55
77
  | **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
56
78
  | **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
57
79
  | **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
80
+ | **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
81
+ | **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
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` |
58
86
  | **Validation** | generated code is run on the sample and compared with a reference result before you get it |
59
87
 
60
88
  ### Cleaning steps
@@ -76,6 +104,17 @@ Each step in the generated code has a comment saying why it was added:
76
104
 
77
105
  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
106
 
107
+ ### Data contracts and drift
108
+ A **contract** describes what good data looks like, learned from a sample with tolerances so normal variation passes:
109
+ type, missing %, 1st/99th percentile range (catches unit changes like ×100), no negatives, allowed values, uniqueness of IDs.
110
+ `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.
111
+
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.
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
+
79
118
  ### Engine choice
80
119
 
81
120
  | Rows | Engine |
@@ -98,12 +137,20 @@ Measured on synthetic messy data:
98
137
  | Run the generated polars script on 1M rows | 0.9 s |
99
138
  | `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
100
139
  | Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
140
+ | Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
141
+ | Compare two 1M-row files | 0.6 s |
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 |
101
146
 
102
147
  ## Examples
103
148
 
104
149
  - [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
105
150
  - [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
106
151
  - [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
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
107
154
 
108
155
  ## Development
109
156
 
@@ -10,21 +10,29 @@ import pandas as pd
10
10
  from . import codegen, engine
11
11
  from .clean import clean
12
12
  from .modeling import train
13
- from .engine import available_engines, choose_engine, inspect_source, sample, get_sample_size, set_sample_size
13
+ from .contract import check
14
+ from .compare import compare
15
+ from .timeseries import timeseries
16
+ from .engine import available_engines, choose_engine, inspect_source, sample, get_sample_size, set_sample_size, sql
14
17
  from .analyzers import overview, missing, numeric, categorical, correlation
15
18
  from .analyzers import target as target_analyzer
19
+ from .analyzers import text as text_analyzer
16
20
  from .report import EDAReport
17
21
 
18
- __version__ = "0.4.0"
22
+ __version__ = "0.6.0"
19
23
  __all__ = [
20
24
  "analyze",
21
25
  "available_engines",
26
+ "check",
22
27
  "clean",
28
+ "compare",
23
29
  "codegen",
24
30
  "engine",
25
31
  "get_sample_size",
26
32
  "inspect_source",
27
33
  "set_sample_size",
34
+ "sql",
35
+ "timeseries",
28
36
  "train",
29
37
  "EDAReport",
30
38
  ]
@@ -36,13 +44,17 @@ def analyze(
36
44
  *,
37
45
  show: bool = False,
38
46
  random_state: int = 42,
39
- save_pdf: bool = True,
47
+ save_pdf: bool = False,
40
48
  pdf_path: str = "flasheda_report.pdf",
41
49
  engine: Optional[str] = None,
42
50
  target: Optional[str] = None,
43
51
  ) -> EDAReport:
44
52
  """
45
- Analyse a fixed-size sample of `source` (DataFrame, CSV/TSV or Parquet path).
53
+ Analyse a fixed-size sample of `source`: a DataFrame, a CSV/TSV or Parquet
54
+ path, or a database table/query from flasheda.sql().
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().
46
58
 
47
59
  `target` names the column you want to predict; it adds task detection,
48
60
  class balance, per-feature predictive power and leakage checks.
@@ -65,12 +77,13 @@ def analyze(
65
77
  "numeric": lambda: numeric.analyze(sampled),
66
78
  "categorical": lambda: categorical.analyze(sampled),
67
79
  "correlation": lambda: correlation.analyze(sampled),
80
+ "text": lambda: text_analyzer.analyze(sampled),
68
81
  }
69
82
  if target is not None:
70
83
  tasks["target"] = lambda: target_analyzer.analyze(sampled, target, random_state)
71
84
 
72
85
  results: dict = {}
73
- with ThreadPoolExecutor(max_workers=5) as pool:
86
+ with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
74
87
  future_map = {pool.submit(fn): name for name, fn in tasks.items()}
75
88
  for future in as_completed(future_map):
76
89
  name = future_map[future]
@@ -92,6 +105,14 @@ def analyze(
92
105
  corr = results.get("correlation", {})
93
106
  if corr.get("numeric_strong_pairs"):
94
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")
95
116
 
96
117
  tgt = results.get("target", {})
97
118
  if "error" in tgt:
@@ -115,13 +136,17 @@ def analyze(
115
136
  numeric=results.get("numeric", {}),
116
137
  categorical=results.get("categorical", {}),
117
138
  correlation=results.get("correlation", {}),
139
+ text=txt,
118
140
  sample_size=len(sampled),
119
141
  elapsed_seconds=elapsed,
120
142
  original_shape=original_shape,
121
143
  warnings=warnings,
122
144
  target=tgt,
123
145
  source=str(info.path) if info.path else None,
124
- recommended_engine=choose_engine(info.n_rows, prefer=engine),
146
+ sql_source=info.sql,
147
+ # database sources are read with pandas (or exported with .to_parquet())
148
+ recommended_engine=choose_engine(
149
+ info.n_rows, prefer=engine or ("pandas" if info.sql is not None else None)),
125
150
  sample=sampled,
126
151
  )
127
152
 
@@ -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 they're not identifiers
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
@@ -58,10 +58,12 @@ def generate(
58
58
  output: str = "cleaned.parquet",
59
59
  sample: Optional[pd.DataFrame] = None,
60
60
  columns: Optional[List[str]] = None,
61
+ sql: Any = None,
61
62
  ) -> GeneratedCode:
63
+ """`sql` is a flasheda.sql() source to read from instead of a file."""
62
64
  if columns is None and sample is not None:
63
65
  columns = [str(c) for c in sample.columns]
64
- code = get_writer(engine).render(plan, source, output, columns)
66
+ code = get_writer(engine).render(plan, source, output, columns, sql=sql)
65
67
  return GeneratedCode(engine, code, plan, sample)
66
68
 
67
69
 
@@ -81,6 +83,7 @@ __all__ = [
81
83
  "OneHot",
82
84
  "Plan",
83
85
  "Ratio",
86
+ "TextStats",
84
87
  "ToNumeric",
85
88
  "ValidationResult",
86
89
  "WRITERS",