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.
Files changed (70) hide show
  1. {flasheda-0.5.0 → flasheda-0.6.0}/PKG-INFO +24 -3
  2. {flasheda-0.5.0 → flasheda-0.6.0}/README.md +21 -1
  3. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/__init__.py +19 -3
  4. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/__init__.py +2 -2
  5. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/categorical.py +4 -3
  6. {flasheda-0.5.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.5.0 → flasheda-0.6.0}/flasheda/clean/detectors.py +13 -0
  9. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/__init__.py +2 -1
  10. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/steps.py +20 -2
  11. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/duckdb_writer.py +5 -1
  12. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/pandas_writer.py +4 -1
  13. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/polars_writer.py +5 -1
  14. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/pyspark_writer.py +5 -1
  15. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/contract/rules.py +15 -4
  16. flasheda-0.6.0/flasheda/export/__init__.py +9 -0
  17. flasheda-0.6.0/flasheda/export/notebook.py +154 -0
  18. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/features/__init__.py +20 -1
  19. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/report.py +45 -8
  20. flasheda-0.6.0/flasheda/timeseries/__init__.py +313 -0
  21. flasheda-0.6.0/flasheda/timeseries/forecast.py +138 -0
  22. flasheda-0.6.0/flasheda/timeseries/script.py +117 -0
  23. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/PKG-INFO +24 -3
  24. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/SOURCES.txt +14 -0
  25. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/requires.txt +1 -0
  26. {flasheda-0.5.0 → flasheda-0.6.0}/setup.py +3 -3
  27. {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_contract.py +17 -0
  28. flasheda-0.6.0/tests/test_forecast.py +93 -0
  29. flasheda-0.6.0/tests/test_html_escape.py +19 -0
  30. flasheda-0.6.0/tests/test_no_side_effects.py +19 -0
  31. flasheda-0.6.0/tests/test_notebook.py +70 -0
  32. flasheda-0.6.0/tests/test_point_biserial.py +41 -0
  33. flasheda-0.6.0/tests/test_text.py +77 -0
  34. flasheda-0.6.0/tests/test_text_features.py +76 -0
  35. flasheda-0.6.0/tests/test_timeseries.py +109 -0
  36. {flasheda-0.5.0 → flasheda-0.6.0}/MIT.License +0 -0
  37. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/missing.py +0 -0
  38. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/numeric.py +0 -0
  39. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/overview.py +0 -0
  40. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/analyzers/target.py +0 -0
  41. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/clean/__init__.py +0 -0
  42. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/planner.py +0 -0
  43. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/validate.py +0 -0
  44. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/__init__.py +0 -0
  45. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/codegen/writers/base.py +0 -0
  46. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/compare/__init__.py +0 -0
  47. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/contract/__init__.py +0 -0
  48. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/contract/check.py +0 -0
  49. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/contract/script.py +0 -0
  50. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/__init__.py +0 -0
  51. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/backends.py +0 -0
  52. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/sampler.py +0 -0
  53. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/source.py +0 -0
  54. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/engine/sql.py +0 -0
  55. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/modeling/__init__.py +0 -0
  56. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/modeling/script.py +0 -0
  57. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/modeling/specs.py +0 -0
  58. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda/sampler.py +0 -0
  59. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/dependency_links.txt +0 -0
  60. {flasheda-0.5.0 → flasheda-0.6.0}/flasheda.egg-info/top_level.txt +0 -0
  61. {flasheda-0.5.0 → flasheda-0.6.0}/pyproject.toml +0 -0
  62. {flasheda-0.5.0 → flasheda-0.6.0}/setup.cfg +0 -0
  63. {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_clean_target.py +0 -0
  64. {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_codegen.py +0 -0
  65. {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_compare.py +0 -0
  66. {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_contract_script.py +0 -0
  67. {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_engine.py +0 -0
  68. {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_features_model.py +0 -0
  69. {flasheda-0.5.0 → flasheda-0.6.0}/tests/test_sql.py +0 -0
  70. {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.5.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
  [![Tests](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml/badge.svg)](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
79
80
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT.License)
80
81
 
81
- **Current version: 0.5.0** · [Changelog](CHANGELOG.md)
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
  [![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.5.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
 
@@ -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.5.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 = True,
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=5) as pool:
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 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
@@ -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)))"