flasheda 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flasheda-0.4.0 → flasheda-0.5.0}/PKG-INFO +36 -3
- {flasheda-0.4.0 → flasheda-0.5.0}/README.md +28 -1
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/__init__.py +13 -4
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/__init__.py +3 -1
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/base.py +12 -2
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/pandas_writer.py +19 -0
- flasheda-0.5.0/flasheda/compare/__init__.py +224 -0
- flasheda-0.5.0/flasheda/contract/__init__.py +29 -0
- flasheda-0.5.0/flasheda/contract/check.py +251 -0
- flasheda-0.5.0/flasheda/contract/rules.py +216 -0
- flasheda-0.5.0/flasheda/contract/script.py +227 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/engine/__init__.py +3 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/engine/sampler.py +4 -1
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/engine/source.py +13 -0
- flasheda-0.5.0/flasheda/engine/sql.py +129 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/report.py +8 -1
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/PKG-INFO +36 -3
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/SOURCES.txt +10 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/requires.txt +7 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/setup.py +5 -4
- flasheda-0.5.0/tests/test_compare.py +90 -0
- flasheda-0.5.0/tests/test_contract.py +140 -0
- flasheda-0.5.0/tests/test_contract_script.py +81 -0
- flasheda-0.5.0/tests/test_sql.py +81 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/MIT.License +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/__init__.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/categorical.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/correlation.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/missing.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/numeric.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/overview.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/analyzers/target.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/clean/__init__.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/clean/detectors.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/planner.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/steps.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/validate.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/__init__.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/duckdb_writer.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/polars_writer.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/codegen/writers/pyspark_writer.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/engine/backends.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/features/__init__.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/modeling/__init__.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/modeling/script.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/modeling/specs.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda/sampler.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/dependency_links.txt +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/flasheda.egg-info/top_level.txt +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/pyproject.toml +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/setup.cfg +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/tests/test_clean_target.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/tests/test_codegen.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/tests/test_engine.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.0}/tests/test_features_model.py +0 -0
- {flasheda-0.4.0 → flasheda-0.5.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.5.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
|
|
13
13
|
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Intended Audience :: Science/Research
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
@@ -35,17 +35,23 @@ 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"
|
|
49
55
|
Requires-Dist: build; extra == "dev"
|
|
50
56
|
Requires-Dist: twine; extra == "dev"
|
|
51
57
|
Dynamic: author
|
|
@@ -72,7 +78,7 @@ Dynamic: summary
|
|
|
72
78
|
[](https://github.com/sjapanjots/Flash-EDA/actions/workflows/tests.yml)
|
|
73
79
|
[](MIT.License)
|
|
74
80
|
|
|
75
|
-
**Current version: 0.
|
|
81
|
+
**Current version: 0.5.0** · [Changelog](CHANGELOG.md)
|
|
76
82
|
|
|
77
83
|
Analyse any dataset in a few seconds, then get **validated cleaning, feature and training code** that runs on the full data, in the engine that fits its size: pandas, polars, DuckDB or PySpark.
|
|
78
84
|
|
|
@@ -84,6 +90,7 @@ FlashEDA analyses a fixed-size random sample, so analysis time barely changes be
|
|
|
84
90
|
pip install flasheda # core
|
|
85
91
|
pip install "flasheda[fast]" # + polars, DuckDB, pyarrow (recommended for big files)
|
|
86
92
|
pip install "flasheda[ml]" # + scikit-learn, for flasheda.train
|
|
93
|
+
pip install "flasheda[sql]" # + SQLAlchemy, for database tables
|
|
87
94
|
pip install "flasheda[spark]" # + PySpark
|
|
88
95
|
pip install "flasheda[all]" # everything except PySpark
|
|
89
96
|
```
|
|
@@ -106,6 +113,18 @@ code.save("clean_orders.py") # then: python clean_orders.py
|
|
|
106
113
|
result = fe.train("orders.csv", target="churn", engine="polars")
|
|
107
114
|
result.show() # leaderboard
|
|
108
115
|
result.save("prep.py", "train.py") # python prep.py && python train.py → model.joblib
|
|
116
|
+
|
|
117
|
+
# 4. Guard: learn rules from good data, check every new file
|
|
118
|
+
contract = fe.analyze("orders_jan.csv").contract()
|
|
119
|
+
fe.check("orders_feb.csv", contract).show()
|
|
120
|
+
open("check.py", "w").write(contract.script()) # python check.py new.csv → exit 1 on failure
|
|
121
|
+
|
|
122
|
+
# 5. Compare: what changed between two datasets
|
|
123
|
+
fe.compare("orders_jan.csv", "orders_feb.csv").show()
|
|
124
|
+
|
|
125
|
+
# Databases work like files
|
|
126
|
+
src = fe.sql("postgresql://user:pass@host/shop", table="orders")
|
|
127
|
+
fe.analyze(src, target="churn")
|
|
109
128
|
```
|
|
110
129
|
|
|
111
130
|
## What it does
|
|
@@ -120,6 +139,9 @@ result.save("prep.py", "train.py") # python prep.py && python train.py → model
|
|
|
120
139
|
| **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
|
|
121
140
|
| **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
|
|
122
141
|
| **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
|
|
142
|
+
| **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
|
|
143
|
+
| **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
|
|
144
|
+
| **Databases** | any SQLAlchemy URL; sampling runs in the database; passwords never stored |
|
|
123
145
|
| **Validation** | generated code is run on the sample and compared with a reference result before you get it |
|
|
124
146
|
|
|
125
147
|
### Cleaning steps
|
|
@@ -141,6 +163,13 @@ Each step in the generated code has a comment saying why it was added:
|
|
|
141
163
|
|
|
142
164
|
The metric and split follow the target: PR-AUC for imbalanced binary targets, MAE for skewed regression targets, time-based splits when there's a date column, and so on.
|
|
143
165
|
|
|
166
|
+
### Data contracts and drift
|
|
167
|
+
A **contract** describes what good data looks like, learned from a sample with tolerances so normal variation passes:
|
|
168
|
+
type, missing %, 1st/99th percentile range (catches unit changes like ×100), no negatives, allowed values, uniqueness of IDs.
|
|
169
|
+
`fe.check()` checks every row of a file in one DuckDB pass; `contract.script()` writes the same check as a standalone script that exits with code 1 on failure, ready for a pipeline or CI job.
|
|
170
|
+
|
|
171
|
+
`fe.compare(old, new)` reports added/removed columns, type changes and how much each column shifted (PSI: < 0.1 stable, 0.1–0.25 moderate, ≥ 0.25 major). Dates and IDs are shown as range shifts, since they always move.
|
|
172
|
+
|
|
144
173
|
### Engine choice
|
|
145
174
|
|
|
146
175
|
| Rows | Engine |
|
|
@@ -163,12 +192,16 @@ Measured on synthetic messy data:
|
|
|
163
192
|
| Run the generated polars script on 1M rows | 0.9 s |
|
|
164
193
|
| `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
|
|
165
194
|
| Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
|
|
195
|
+
| Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
|
|
196
|
+
| Compare two 1M-row files | 0.6 s |
|
|
197
|
+
| Analyse a 1M-row SQLite table (5,000 rows transferred) | 0.3 s |
|
|
166
198
|
|
|
167
199
|
## Examples
|
|
168
200
|
|
|
169
201
|
- [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
|
|
170
202
|
- [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
|
|
171
203
|
- [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
|
|
204
|
+
- [`examples/phase4_demo.py`](examples/phase4_demo.py): contracts, check script, drift and a database on 1M rows
|
|
172
205
|
|
|
173
206
|
## Development
|
|
174
207
|
|
|
@@ -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.5.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,18 @@ 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")
|
|
44
57
|
```
|
|
45
58
|
|
|
46
59
|
## What it does
|
|
@@ -55,6 +68,9 @@ result.save("prep.py", "train.py") # python prep.py && python train.py → model
|
|
|
55
68
|
| **Code generation** | one cleaning plan → pandas, polars (lazy, streaming), DuckDB SQL (larger than memory) or PySpark |
|
|
56
69
|
| **Features** | date parts, one-hot / frequency encoding, ratios that beat their inputs |
|
|
57
70
|
| **Models** | baseline, linear, random forest, gradient boosting compared with the right metric and split; training script for the winner |
|
|
71
|
+
| **Data contracts** | rules learned from good data; full-data checks; standalone check script for CI; pandera export |
|
|
72
|
+
| **Drift** | schema changes and per-column drift (PSI, KS, new categories, null-rate change) between two datasets |
|
|
73
|
+
| **Databases** | any SQLAlchemy URL; sampling runs in the database; passwords never stored |
|
|
58
74
|
| **Validation** | generated code is run on the sample and compared with a reference result before you get it |
|
|
59
75
|
|
|
60
76
|
### Cleaning steps
|
|
@@ -76,6 +92,13 @@ Each step in the generated code has a comment saying why it was added:
|
|
|
76
92
|
|
|
77
93
|
The metric and split follow the target: PR-AUC for imbalanced binary targets, MAE for skewed regression targets, time-based splits when there's a date column, and so on.
|
|
78
94
|
|
|
95
|
+
### Data contracts and drift
|
|
96
|
+
A **contract** describes what good data looks like, learned from a sample with tolerances so normal variation passes:
|
|
97
|
+
type, missing %, 1st/99th percentile range (catches unit changes like ×100), no negatives, allowed values, uniqueness of IDs.
|
|
98
|
+
`fe.check()` checks every row of a file in one DuckDB pass; `contract.script()` writes the same check as a standalone script that exits with code 1 on failure, ready for a pipeline or CI job.
|
|
99
|
+
|
|
100
|
+
`fe.compare(old, new)` reports added/removed columns, type changes and how much each column shifted (PSI: < 0.1 stable, 0.1–0.25 moderate, ≥ 0.25 major). Dates and IDs are shown as range shifts, since they always move.
|
|
101
|
+
|
|
79
102
|
### Engine choice
|
|
80
103
|
|
|
81
104
|
| Rows | Engine |
|
|
@@ -98,12 +121,16 @@ Measured on synthetic messy data:
|
|
|
98
121
|
| Run the generated polars script on 1M rows | 0.9 s |
|
|
99
122
|
| `fe.train()` on a 1M-row CSV (plan, features, 4 models, checks) | 6.7 s |
|
|
100
123
|
| Generated `prep.py` + `train.py` on 1M rows | 1.3 s + 20 s |
|
|
124
|
+
| Learn a contract from 1M rows / run the check script on 1M rows | 0.8 s / 0.6–0.9 s |
|
|
125
|
+
| Compare two 1M-row files | 0.6 s |
|
|
126
|
+
| Analyse a 1M-row SQLite table (5,000 rows transferred) | 0.3 s |
|
|
101
127
|
|
|
102
128
|
## Examples
|
|
103
129
|
|
|
104
130
|
- [`examples/phase1_demo.py`](examples/phase1_demo.py): engine choice and code generation at 0.1M / 1M / 5M rows
|
|
105
131
|
- [`examples/phase2_demo.py`](examples/phase2_demo.py): target analysis and cleaning on a 1M-row churn CSV
|
|
106
132
|
- [`examples/phase3_demo.py`](examples/phase3_demo.py): features, model comparison and training on 1M rows
|
|
133
|
+
- [`examples/phase4_demo.py`](examples/phase4_demo.py): contracts, check script, drift and a database on 1M rows
|
|
107
134
|
|
|
108
135
|
## Development
|
|
109
136
|
|
|
@@ -10,21 +10,26 @@ import pandas as pd
|
|
|
10
10
|
from . import codegen, engine
|
|
11
11
|
from .clean import clean
|
|
12
12
|
from .modeling import train
|
|
13
|
-
from .
|
|
13
|
+
from .contract import check
|
|
14
|
+
from .compare import compare
|
|
15
|
+
from .engine import available_engines, choose_engine, inspect_source, sample, get_sample_size, set_sample_size, sql
|
|
14
16
|
from .analyzers import overview, missing, numeric, categorical, correlation
|
|
15
17
|
from .analyzers import target as target_analyzer
|
|
16
18
|
from .report import EDAReport
|
|
17
19
|
|
|
18
|
-
__version__ = "0.
|
|
20
|
+
__version__ = "0.5.0"
|
|
19
21
|
__all__ = [
|
|
20
22
|
"analyze",
|
|
21
23
|
"available_engines",
|
|
24
|
+
"check",
|
|
22
25
|
"clean",
|
|
26
|
+
"compare",
|
|
23
27
|
"codegen",
|
|
24
28
|
"engine",
|
|
25
29
|
"get_sample_size",
|
|
26
30
|
"inspect_source",
|
|
27
31
|
"set_sample_size",
|
|
32
|
+
"sql",
|
|
28
33
|
"train",
|
|
29
34
|
"EDAReport",
|
|
30
35
|
]
|
|
@@ -42,7 +47,8 @@ def analyze(
|
|
|
42
47
|
target: Optional[str] = None,
|
|
43
48
|
) -> EDAReport:
|
|
44
49
|
"""
|
|
45
|
-
Analyse a fixed-size sample of `source
|
|
50
|
+
Analyse a fixed-size sample of `source`: a DataFrame, a CSV/TSV or Parquet
|
|
51
|
+
path, or a database table/query from flasheda.sql().
|
|
46
52
|
|
|
47
53
|
`target` names the column you want to predict; it adds task detection,
|
|
48
54
|
class balance, per-feature predictive power and leakage checks.
|
|
@@ -121,7 +127,10 @@ def analyze(
|
|
|
121
127
|
warnings=warnings,
|
|
122
128
|
target=tgt,
|
|
123
129
|
source=str(info.path) if info.path else None,
|
|
124
|
-
|
|
130
|
+
sql_source=info.sql,
|
|
131
|
+
# database sources are read with pandas (or exported with .to_parquet())
|
|
132
|
+
recommended_engine=choose_engine(
|
|
133
|
+
info.n_rows, prefer=engine or ("pandas" if info.sql is not None else None)),
|
|
125
134
|
sample=sampled,
|
|
126
135
|
)
|
|
127
136
|
|
|
@@ -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
|
|
|
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import textwrap
|
|
15
15
|
from abc import ABC, abstractmethod
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import List, Optional
|
|
17
|
+
from typing import Any, List, Optional
|
|
18
18
|
|
|
19
19
|
from ..steps import Plan, Step
|
|
20
20
|
|
|
@@ -23,6 +23,8 @@ class Writer(ABC):
|
|
|
23
23
|
engine: str = ""
|
|
24
24
|
install_hint: str = ""
|
|
25
25
|
columns: Optional[List[str]] = None
|
|
26
|
+
sql: Any = None
|
|
27
|
+
reads_sql = False # writers that can load from a database URL
|
|
26
28
|
|
|
27
29
|
def render(
|
|
28
30
|
self,
|
|
@@ -30,9 +32,17 @@ class Writer(ABC):
|
|
|
30
32
|
source: Optional[str],
|
|
31
33
|
output: str,
|
|
32
34
|
columns: Optional[List[str]] = None,
|
|
35
|
+
sql: Any = None,
|
|
33
36
|
) -> str:
|
|
34
|
-
"""`columns` are the source's column names, when known."""
|
|
37
|
+
"""`columns` are the source's column names, when known; `sql` a database source."""
|
|
35
38
|
self.columns = list(columns) if columns is not None else None
|
|
39
|
+
self.sql = sql
|
|
40
|
+
if sql is not None and not self.reads_sql:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"The {self.engine} writer can't read from a database directly. Use engine='pandas', "
|
|
43
|
+
"or export first: flasheda.sql(...).to_parquet('data.parquet'), then analyze that file.")
|
|
44
|
+
if sql is not None:
|
|
45
|
+
source = sql.display
|
|
36
46
|
parts = [
|
|
37
47
|
self._docstring(plan, source),
|
|
38
48
|
self.imports(),
|
|
@@ -13,6 +13,7 @@ from .base import Writer, source_format
|
|
|
13
13
|
class PandasWriter(Writer):
|
|
14
14
|
engine = "pandas"
|
|
15
15
|
install_hint = "pandas, pyarrow"
|
|
16
|
+
reads_sql = True
|
|
16
17
|
|
|
17
18
|
def imports(self) -> str:
|
|
18
19
|
return "import numpy as np\nimport pandas as pd"
|
|
@@ -73,6 +74,8 @@ class PandasWriter(Writer):
|
|
|
73
74
|
raise TypeError(f"Unsupported step {step!r}")
|
|
74
75
|
|
|
75
76
|
def main(self, source: Optional[str]) -> str:
|
|
77
|
+
if self.sql is not None:
|
|
78
|
+
return self._main_sql()
|
|
76
79
|
fmt = source_format(source)
|
|
77
80
|
if fmt == "parquet":
|
|
78
81
|
load = "pd.read_parquet(SOURCE)"
|
|
@@ -85,3 +88,19 @@ class PandasWriter(Writer):
|
|
|
85
88
|
" df.to_parquet(OUTPUT, index=False)\n"
|
|
86
89
|
' print(f"Wrote {len(df):,} rows -> {OUTPUT}")'
|
|
87
90
|
)
|
|
91
|
+
|
|
92
|
+
def _main_sql(self) -> str:
|
|
93
|
+
from ...engine.sql import URL_ENV_VAR
|
|
94
|
+
query = f"SELECT * FROM {self.sql.from_sql()}"
|
|
95
|
+
return (
|
|
96
|
+
"def main() -> None:\n"
|
|
97
|
+
" import os\n"
|
|
98
|
+
" from sqlalchemy import create_engine, text\n\n"
|
|
99
|
+
f" # The database URL is read from ${URL_ENV_VAR} so no password is stored here.\n"
|
|
100
|
+
f" url = os.environ[{URL_ENV_VAR!r}]\n"
|
|
101
|
+
" with create_engine(url).connect() as con:\n"
|
|
102
|
+
f" df = pd.read_sql(text({query!r}), con)\n"
|
|
103
|
+
" df = transform(df)\n"
|
|
104
|
+
" df.to_parquet(OUTPUT, index=False)\n"
|
|
105
|
+
' print(f"Wrote {len(df):,} rows -> {OUTPUT}")'
|
|
106
|
+
)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
flasheda.compare
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
What changed between two datasets (last month vs this month, train vs
|
|
5
|
+
test), measured on fixed-size samples so it takes seconds at any size:
|
|
6
|
+
|
|
7
|
+
drift = flasheda.compare("orders_jan.csv", "orders_feb.csv")
|
|
8
|
+
drift.show()
|
|
9
|
+
|
|
10
|
+
schema columns added / removed, type changes
|
|
11
|
+
per column PSI (population stability index) on reference quantile bins or
|
|
12
|
+
categories; KS test for numbers; new categories; null-rate change
|
|
13
|
+
dates, IDs reported as a range shift (they always move), not as drift
|
|
14
|
+
severity PSI < 0.1 stable · 0.1–0.25 moderate · ≥ 0.25 major (the usual
|
|
15
|
+
credit-risk thresholds); a null rate moving ≥ 10 points is at
|
|
16
|
+
least moderate
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
from ..contract.rules import _kind
|
|
28
|
+
from ..engine import inspect_source, sample
|
|
29
|
+
|
|
30
|
+
__all__ = ["DriftReport", "compare", "psi"]
|
|
31
|
+
|
|
32
|
+
PSI_MODERATE = 0.10
|
|
33
|
+
PSI_MAJOR = 0.25
|
|
34
|
+
NULL_SHIFT_POINTS = 10.0
|
|
35
|
+
BINS = 10
|
|
36
|
+
TOP_CATEGORIES = 20
|
|
37
|
+
EPS = 1e-4
|
|
38
|
+
HIGH_CARDINALITY = 0.5 # unique / rows above this: IDs, free text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class DriftReport:
|
|
43
|
+
rows_reference: int
|
|
44
|
+
rows_current: int
|
|
45
|
+
added_columns: List[str]
|
|
46
|
+
removed_columns: List[str]
|
|
47
|
+
type_changes: List[Dict[str, str]]
|
|
48
|
+
columns: List[Dict[str, Any]] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def drifted(self) -> List[Dict[str, Any]]:
|
|
52
|
+
return [c for c in self.columns if c["severity"] != "stable"]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ok(self) -> bool:
|
|
56
|
+
"""No schema changes and no major drift."""
|
|
57
|
+
return (not self.added_columns and not self.removed_columns and not self.type_changes
|
|
58
|
+
and all(c["severity"] != "major" for c in self.columns))
|
|
59
|
+
|
|
60
|
+
def __getitem__(self, column: str) -> Dict[str, Any]:
|
|
61
|
+
for c in self.columns:
|
|
62
|
+
if c["column"] == column:
|
|
63
|
+
return c
|
|
64
|
+
raise KeyError(column)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
67
|
+
return {"ok": self.ok, "rows_reference": self.rows_reference,
|
|
68
|
+
"rows_current": self.rows_current, "added_columns": self.added_columns,
|
|
69
|
+
"removed_columns": self.removed_columns, "type_changes": self.type_changes,
|
|
70
|
+
"columns": self.columns}
|
|
71
|
+
|
|
72
|
+
def show(self) -> None:
|
|
73
|
+
from rich import box
|
|
74
|
+
from rich.console import Console
|
|
75
|
+
from rich.table import Table
|
|
76
|
+
|
|
77
|
+
console = Console()
|
|
78
|
+
console.print(f"[bold]Drift[/bold]: {self.rows_reference:,} → {self.rows_current:,} rows "
|
|
79
|
+
+ ("[green]no major drift[/]" if self.ok else "[red]changes found[/]"))
|
|
80
|
+
if self.added_columns:
|
|
81
|
+
console.print(f" [yellow]added columns:[/] {', '.join(self.added_columns)}")
|
|
82
|
+
if self.removed_columns:
|
|
83
|
+
console.print(f" [red]removed columns:[/] {', '.join(self.removed_columns)}")
|
|
84
|
+
for t in self.type_changes:
|
|
85
|
+
console.print(f" [red]type change:[/] {t['column']}: {t['reference']} → {t['current']}")
|
|
86
|
+
table = Table(box=box.SIMPLE)
|
|
87
|
+
for h in ("column", "PSI", "severity", "null % (ref → cur)", "detail"):
|
|
88
|
+
table.add_column(h)
|
|
89
|
+
colors = {"stable": "green", "moderate": "yellow", "major": "red"}
|
|
90
|
+
for c in self.columns:
|
|
91
|
+
table.add_row(c["column"], f"{c['psi']:.3f}", f"[{colors[c['severity']]}]{c['severity']}[/]",
|
|
92
|
+
f"{c['null_pct_reference']} → {c['null_pct_current']}", c["detail"])
|
|
93
|
+
console.print(table)
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
return (f"DriftReport(ok={self.ok}, drifted={len(self.drifted)}/{len(self.columns)}, "
|
|
97
|
+
f"added={len(self.added_columns)}, removed={len(self.removed_columns)})")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def compare(reference: Any, current: Any, *, n: Optional[int] = None,
|
|
101
|
+
random_state: int = 42) -> DriftReport:
|
|
102
|
+
ref_info, cur_info = inspect_source(reference), inspect_source(current)
|
|
103
|
+
ref = sample(reference, n=n, random_state=random_state)
|
|
104
|
+
cur = sample(current, n=n, random_state=random_state)
|
|
105
|
+
ref.columns = [str(c) for c in ref.columns]
|
|
106
|
+
cur.columns = [str(c) for c in cur.columns]
|
|
107
|
+
|
|
108
|
+
common = [c for c in ref.columns if c in cur.columns]
|
|
109
|
+
report = DriftReport(
|
|
110
|
+
rows_reference=ref_info.n_rows if ref_info.n_rows is not None else len(ref),
|
|
111
|
+
rows_current=cur_info.n_rows if cur_info.n_rows is not None else len(cur),
|
|
112
|
+
added_columns=[c for c in cur.columns if c not in ref.columns],
|
|
113
|
+
removed_columns=[c for c in ref.columns if c not in cur.columns],
|
|
114
|
+
type_changes=[],
|
|
115
|
+
)
|
|
116
|
+
for col in common:
|
|
117
|
+
k_ref, k_cur = _kind(ref[col]), _kind(cur[col])
|
|
118
|
+
if k_ref != k_cur:
|
|
119
|
+
report.type_changes.append({"column": col, "reference": k_ref, "current": k_cur})
|
|
120
|
+
continue
|
|
121
|
+
report.columns.append(_column_drift(col, ref[col], cur[col], k_ref))
|
|
122
|
+
report.columns.sort(key=lambda c: c["psi"], reverse=True)
|
|
123
|
+
return report
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def psi(expected: np.ndarray, actual: np.ndarray) -> float:
|
|
127
|
+
"""Population stability index between two proportion vectors."""
|
|
128
|
+
e = np.clip(np.asarray(expected, float), EPS, None)
|
|
129
|
+
a = np.clip(np.asarray(actual, float), EPS, None)
|
|
130
|
+
return float(np.sum((a - e) * np.log(a / e)))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _column_drift(col: str, ref: pd.Series, cur: pd.Series, kind: str) -> Dict[str, Any]:
|
|
134
|
+
null_ref = round(float(ref.isna().mean() * 100), 2)
|
|
135
|
+
null_cur = round(float(cur.isna().mean() * 100), 2)
|
|
136
|
+
out: Dict[str, Any] = {"column": col, "kind": kind,
|
|
137
|
+
"null_pct_reference": null_ref, "null_pct_current": null_cur}
|
|
138
|
+
|
|
139
|
+
if kind == "datetime":
|
|
140
|
+
# Dates move forward by nature, so they're reported as a range shift,
|
|
141
|
+
# not as drift; only their null rate can raise the severity.
|
|
142
|
+
r = pd.to_datetime(ref, errors="coerce", format="mixed").dropna()
|
|
143
|
+
c = pd.to_datetime(cur, errors="coerce", format="mixed").dropna()
|
|
144
|
+
value = 0.0
|
|
145
|
+
out["detail"] = (f"{r.min():%Y-%m-%d}…{r.max():%Y-%m-%d} → {c.min():%Y-%m-%d}…{c.max():%Y-%m-%d}"
|
|
146
|
+
if len(r) and len(c) else "")
|
|
147
|
+
elif kind == "integer" and _is_id_like(ref):
|
|
148
|
+
# New IDs always differ from old ones: report the range, not drift.
|
|
149
|
+
r, c = ref.dropna(), cur.dropna()
|
|
150
|
+
value = 0.0
|
|
151
|
+
out["detail"] = (f"ID-like: {r.min():,}…{r.max():,} → {c.min():,}…{c.max():,}"
|
|
152
|
+
if len(r) and len(c) else "ID-like")
|
|
153
|
+
elif kind in ("number", "integer"):
|
|
154
|
+
r = pd.to_numeric(ref, errors="coerce")
|
|
155
|
+
c = pd.to_numeric(cur, errors="coerce")
|
|
156
|
+
value = _numeric_psi(r, c)
|
|
157
|
+
out.update(_numeric_details(r.dropna(), c.dropna()))
|
|
158
|
+
else:
|
|
159
|
+
value, new_share, new_values = _categorical_psi(ref.astype("string"), cur.astype("string"))
|
|
160
|
+
values = ref.dropna()
|
|
161
|
+
if len(values) and values.nunique() / len(values) > HIGH_CARDINALITY:
|
|
162
|
+
out["detail"] = "mostly unique values: compared by most common values only"
|
|
163
|
+
else:
|
|
164
|
+
out["new_values_pct"] = round(new_share * 100, 2)
|
|
165
|
+
out["new_values"] = new_values
|
|
166
|
+
out["detail"] = (f"{out['new_values_pct']}% new values, e.g. {', '.join(new_values[:3])}"
|
|
167
|
+
if new_values else "")
|
|
168
|
+
out["psi"] = round(value, 4)
|
|
169
|
+
|
|
170
|
+
severity = "major" if value >= PSI_MAJOR else "moderate" if value >= PSI_MODERATE else "stable"
|
|
171
|
+
if severity == "stable" and abs(null_cur - null_ref) >= NULL_SHIFT_POINTS:
|
|
172
|
+
severity = "moderate"
|
|
173
|
+
out["severity"] = severity
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _is_id_like(s: pd.Series) -> bool:
|
|
178
|
+
values = s.dropna()
|
|
179
|
+
return len(values) > 0 and values.nunique() / len(values) > 0.95
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _numeric_psi(ref: pd.Series, cur: pd.Series) -> float:
|
|
183
|
+
r, c = ref.dropna().to_numpy(), cur.dropna().to_numpy()
|
|
184
|
+
if not len(r) or not len(c):
|
|
185
|
+
return 0.0
|
|
186
|
+
edges = np.unique(np.quantile(r, np.linspace(0, 1, BINS + 1)))
|
|
187
|
+
if len(edges) < 2: # constant reference
|
|
188
|
+
return psi([1.0, 0.0], [np.mean(c == edges[0]), np.mean(c != edges[0])])
|
|
189
|
+
edges[0], edges[-1] = -np.inf, np.inf
|
|
190
|
+
# missing values get their own bin, so a jump in nulls counts as drift
|
|
191
|
+
e = np.append(np.histogram(r, edges)[0] / len(ref), ref.isna().mean())
|
|
192
|
+
a = np.append(np.histogram(c, edges)[0] / len(cur), cur.isna().mean())
|
|
193
|
+
return psi(e, a)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _numeric_details(r: pd.Series, c: pd.Series) -> Dict[str, Any]:
|
|
197
|
+
from scipy.stats import ks_2samp
|
|
198
|
+
|
|
199
|
+
if not len(r) or not len(c):
|
|
200
|
+
return {"detail": "no values to compare"}
|
|
201
|
+
ks = ks_2samp(r, c)
|
|
202
|
+
std = float(r.std()) or 1.0
|
|
203
|
+
shift = (float(c.median()) - float(r.median())) / std
|
|
204
|
+
return {
|
|
205
|
+
"median_reference": float(r.median()),
|
|
206
|
+
"median_current": float(c.median()),
|
|
207
|
+
"median_shift_std": round(shift, 3),
|
|
208
|
+
"ks_statistic": round(float(ks.statistic), 4),
|
|
209
|
+
"ks_pvalue": float(ks.pvalue),
|
|
210
|
+
"detail": f"median {float(r.median()):.4g} → {float(c.median()):.4g} ({shift:+.2f} std)",
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _categorical_psi(ref: pd.Series, cur: pd.Series):
|
|
215
|
+
ref_counts = ref.fillna("<null>").value_counts(normalize=True)
|
|
216
|
+
cur_counts = cur.fillna("<null>").value_counts(normalize=True)
|
|
217
|
+
top = list(ref_counts.index[:TOP_CATEGORIES])
|
|
218
|
+
if "<null>" not in top:
|
|
219
|
+
top.append("<null>")
|
|
220
|
+
e = [ref_counts.get(v, 0.0) for v in top] + [1 - ref_counts.reindex(top).fillna(0).sum()]
|
|
221
|
+
a = [cur_counts.get(v, 0.0) for v in top] + [1 - cur_counts.reindex(top).fillna(0).sum()]
|
|
222
|
+
seen = set(ref_counts.index) | {"<null>"} # nulls are reported as null %, not new values
|
|
223
|
+
new = cur_counts[[v not in seen for v in cur_counts.index]]
|
|
224
|
+
return psi(e, a), float(new.sum()), [str(v) for v in new.index[:10]]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
flasheda.contract
|
|
3
|
+
~~~~~~~~~~~~~~~~~
|
|
4
|
+
Learn a data contract from a sample, then check new or full data against it:
|
|
5
|
+
|
|
6
|
+
report = flasheda.analyze("orders_jan.csv")
|
|
7
|
+
contract = report.contract()
|
|
8
|
+
contract.save("orders.contract.json")
|
|
9
|
+
|
|
10
|
+
result = flasheda.check("orders_feb.csv", "orders.contract.json")
|
|
11
|
+
result.show() # failures, if any
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Union
|
|
18
|
+
|
|
19
|
+
from .check import CheckResult, Outcome
|
|
20
|
+
from .rules import ColumnRule, Contract, infer_contract
|
|
21
|
+
|
|
22
|
+
__all__ = ["CheckResult", "ColumnRule", "Contract", "Outcome", "check", "infer_contract"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check(source: Any, contract: Union[Contract, str, Path]) -> CheckResult:
|
|
26
|
+
"""Check `source` against a Contract or a saved contract JSON file."""
|
|
27
|
+
if not isinstance(contract, Contract):
|
|
28
|
+
contract = Contract.load(contract)
|
|
29
|
+
return contract.check(source)
|