se-dat 0.1.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.
- se_dat-0.1.0/PKG-INFO +91 -0
- se_dat-0.1.0/README.md +70 -0
- se_dat-0.1.0/pyproject.toml +34 -0
- se_dat-0.1.0/setup.cfg +4 -0
- se_dat-0.1.0/src/se_dat.egg-info/PKG-INFO +91 -0
- se_dat-0.1.0/src/se_dat.egg-info/SOURCES.txt +14 -0
- se_dat-0.1.0/src/se_dat.egg-info/dependency_links.txt +1 -0
- se_dat-0.1.0/src/se_dat.egg-info/requires.txt +9 -0
- se_dat-0.1.0/src/se_dat.egg-info/top_level.txt +1 -0
- se_dat-0.1.0/src/sedat/__init__.py +51 -0
- se_dat-0.1.0/src/sedat/correlations.py +227 -0
- se_dat-0.1.0/src/sedat/encoding.py +237 -0
- se_dat-0.1.0/src/sedat/profile.py +75 -0
- se_dat-0.1.0/src/sedat/report.py +72 -0
- se_dat-0.1.0/src/sedat/types.py +152 -0
- se_dat-0.1.0/tests/test_se_dat.py +176 -0
se_dat-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: se-dat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple Exploratory Data Analysis tool: column profiling, correlation analysis, and encoding suggestions.
|
|
5
|
+
Author-email: Haim Feldman <haimfeld@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: eda,data-analysis,pandas,correlation,encoding
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: numpy>=1.23
|
|
14
|
+
Requires-Dist: pandas>=1.5
|
|
15
|
+
Requires-Dist: scipy>=1.9
|
|
16
|
+
Requires-Dist: matplotlib>=3.6
|
|
17
|
+
Requires-Dist: seaborn>=0.12
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# se-dat
|
|
23
|
+
|
|
24
|
+
Simple Exploratory Data Analysis tool. Profiles your columns, measures
|
|
25
|
+
associations across numeric/categorical data, and suggests encodings so you can
|
|
26
|
+
move from raw data to a model-ready frame in one call.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
1. **Column type profiling** — per column: inferred type (`numeric`, `boolean`,
|
|
37
|
+
`categorical`, `string`, `datetime`, `id`), missing %, unique count,
|
|
38
|
+
cardinality ratio, and a confidence flag when the inference is ambiguous
|
|
39
|
+
(numeric-looking strings, `0/1` vs `yes`/`no` booleans, etc.).
|
|
40
|
+
2. **Correlation analysis** — Pearson/Spearman matrices + heatmap
|
|
41
|
+
(numeric-numeric), Cramér's V (categorical-categorical), correlation ratio
|
|
42
|
+
eta (numeric-categorical), plus multicollinearity flags above a threshold.
|
|
43
|
+
3. **Encoding suggestions + auto-transform** — detects binary-like strings
|
|
44
|
+
(`yes`/`no`, `true`/`false`, `0/1`), suggests one-hot for low-cardinality
|
|
45
|
+
categoricals, and target/ordinal encoding for high-cardinality ones (with a
|
|
46
|
+
dimensionality warning). Apply suggestions individually or all at once.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import pandas as pd
|
|
52
|
+
import sedat
|
|
53
|
+
|
|
54
|
+
df = pd.DataFrame({
|
|
55
|
+
"id": range(500),
|
|
56
|
+
"age": ...,
|
|
57
|
+
"income": ["45000", "67000", ...], # numeric stored as string
|
|
58
|
+
"smoker": ["yes", "no", ...], # binary-like
|
|
59
|
+
"region": ["north", "south", ...],
|
|
60
|
+
"target": ..., # numeric target
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
report = sedat.EDAReport.create(df, target=df["target"])
|
|
64
|
+
print(report.profile.summary) # column type profiling
|
|
65
|
+
print(report.correlations.flagged_pairs) # multicollinearity warnings
|
|
66
|
+
print(report.encoding_summary) # suggested encodings
|
|
67
|
+
|
|
68
|
+
model_ready = report.apply_all_encodings() # apply every suggestion
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Granular APIs
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
profile = sedat.profile_dataframe(df)
|
|
75
|
+
pearson = sedat.numeric_correlation(df, method="pearson")
|
|
76
|
+
cramers = sedat.categorical_correlation(df) # Cramér's V matrix
|
|
77
|
+
eta = sedat.numeric_categorical_correlation(df) # correlation ratio matrix
|
|
78
|
+
fig = sedat.correlation_heatmap(pearson, title="Numeric correlations")
|
|
79
|
+
|
|
80
|
+
plan = sedat.suggest_encodings(df, target=df["target"], cardinality_threshold=10)
|
|
81
|
+
only_smoker = next(s for s in plan.suggestions if s.column == "smoker")
|
|
82
|
+
one_hot_step = only_smoker.apply(df) # accept one suggestion
|
|
83
|
+
all_encoded = plan.apply_all(df) # accept all suggestions
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install -e ".[dev]"
|
|
90
|
+
python -m pytest
|
|
91
|
+
```
|
se_dat-0.1.0/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# se-dat
|
|
2
|
+
|
|
3
|
+
Simple Exploratory Data Analysis tool. Profiles your columns, measures
|
|
4
|
+
associations across numeric/categorical data, and suggests encodings so you can
|
|
5
|
+
move from raw data to a model-ready frame in one call.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
1. **Column type profiling** — per column: inferred type (`numeric`, `boolean`,
|
|
16
|
+
`categorical`, `string`, `datetime`, `id`), missing %, unique count,
|
|
17
|
+
cardinality ratio, and a confidence flag when the inference is ambiguous
|
|
18
|
+
(numeric-looking strings, `0/1` vs `yes`/`no` booleans, etc.).
|
|
19
|
+
2. **Correlation analysis** — Pearson/Spearman matrices + heatmap
|
|
20
|
+
(numeric-numeric), Cramér's V (categorical-categorical), correlation ratio
|
|
21
|
+
eta (numeric-categorical), plus multicollinearity flags above a threshold.
|
|
22
|
+
3. **Encoding suggestions + auto-transform** — detects binary-like strings
|
|
23
|
+
(`yes`/`no`, `true`/`false`, `0/1`), suggests one-hot for low-cardinality
|
|
24
|
+
categoricals, and target/ordinal encoding for high-cardinality ones (with a
|
|
25
|
+
dimensionality warning). Apply suggestions individually or all at once.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import pandas as pd
|
|
31
|
+
import sedat
|
|
32
|
+
|
|
33
|
+
df = pd.DataFrame({
|
|
34
|
+
"id": range(500),
|
|
35
|
+
"age": ...,
|
|
36
|
+
"income": ["45000", "67000", ...], # numeric stored as string
|
|
37
|
+
"smoker": ["yes", "no", ...], # binary-like
|
|
38
|
+
"region": ["north", "south", ...],
|
|
39
|
+
"target": ..., # numeric target
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
report = sedat.EDAReport.create(df, target=df["target"])
|
|
43
|
+
print(report.profile.summary) # column type profiling
|
|
44
|
+
print(report.correlations.flagged_pairs) # multicollinearity warnings
|
|
45
|
+
print(report.encoding_summary) # suggested encodings
|
|
46
|
+
|
|
47
|
+
model_ready = report.apply_all_encodings() # apply every suggestion
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Granular APIs
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
profile = sedat.profile_dataframe(df)
|
|
54
|
+
pearson = sedat.numeric_correlation(df, method="pearson")
|
|
55
|
+
cramers = sedat.categorical_correlation(df) # Cramér's V matrix
|
|
56
|
+
eta = sedat.numeric_categorical_correlation(df) # correlation ratio matrix
|
|
57
|
+
fig = sedat.correlation_heatmap(pearson, title="Numeric correlations")
|
|
58
|
+
|
|
59
|
+
plan = sedat.suggest_encodings(df, target=df["target"], cardinality_threshold=10)
|
|
60
|
+
only_smoker = next(s for s in plan.suggestions if s.column == "smoker")
|
|
61
|
+
one_hot_step = only_smoker.apply(df) # accept one suggestion
|
|
62
|
+
all_encoded = plan.apply_all(df) # accept all suggestions
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install -e ".[dev]"
|
|
69
|
+
python -m pytest
|
|
70
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "se-dat"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Simple Exploratory Data Analysis tool: column profiling, correlation analysis, and encoding suggestions."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Haim Feldman", email = "haimfeld@gmail.com" }]
|
|
13
|
+
keywords = ["eda", "data-analysis", "pandas", "correlation", "encoding"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"numpy>=1.23",
|
|
21
|
+
"pandas>=1.5",
|
|
22
|
+
"scipy>=1.9",
|
|
23
|
+
"matplotlib>=3.6",
|
|
24
|
+
"seaborn>=0.12",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=7", "pytest-cov"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
se_dat-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: se-dat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple Exploratory Data Analysis tool: column profiling, correlation analysis, and encoding suggestions.
|
|
5
|
+
Author-email: Haim Feldman <haimfeld@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: eda,data-analysis,pandas,correlation,encoding
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: numpy>=1.23
|
|
14
|
+
Requires-Dist: pandas>=1.5
|
|
15
|
+
Requires-Dist: scipy>=1.9
|
|
16
|
+
Requires-Dist: matplotlib>=3.6
|
|
17
|
+
Requires-Dist: seaborn>=0.12
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# se-dat
|
|
23
|
+
|
|
24
|
+
Simple Exploratory Data Analysis tool. Profiles your columns, measures
|
|
25
|
+
associations across numeric/categorical data, and suggests encodings so you can
|
|
26
|
+
move from raw data to a model-ready frame in one call.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
1. **Column type profiling** — per column: inferred type (`numeric`, `boolean`,
|
|
37
|
+
`categorical`, `string`, `datetime`, `id`), missing %, unique count,
|
|
38
|
+
cardinality ratio, and a confidence flag when the inference is ambiguous
|
|
39
|
+
(numeric-looking strings, `0/1` vs `yes`/`no` booleans, etc.).
|
|
40
|
+
2. **Correlation analysis** — Pearson/Spearman matrices + heatmap
|
|
41
|
+
(numeric-numeric), Cramér's V (categorical-categorical), correlation ratio
|
|
42
|
+
eta (numeric-categorical), plus multicollinearity flags above a threshold.
|
|
43
|
+
3. **Encoding suggestions + auto-transform** — detects binary-like strings
|
|
44
|
+
(`yes`/`no`, `true`/`false`, `0/1`), suggests one-hot for low-cardinality
|
|
45
|
+
categoricals, and target/ordinal encoding for high-cardinality ones (with a
|
|
46
|
+
dimensionality warning). Apply suggestions individually or all at once.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import pandas as pd
|
|
52
|
+
import sedat
|
|
53
|
+
|
|
54
|
+
df = pd.DataFrame({
|
|
55
|
+
"id": range(500),
|
|
56
|
+
"age": ...,
|
|
57
|
+
"income": ["45000", "67000", ...], # numeric stored as string
|
|
58
|
+
"smoker": ["yes", "no", ...], # binary-like
|
|
59
|
+
"region": ["north", "south", ...],
|
|
60
|
+
"target": ..., # numeric target
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
report = sedat.EDAReport.create(df, target=df["target"])
|
|
64
|
+
print(report.profile.summary) # column type profiling
|
|
65
|
+
print(report.correlations.flagged_pairs) # multicollinearity warnings
|
|
66
|
+
print(report.encoding_summary) # suggested encodings
|
|
67
|
+
|
|
68
|
+
model_ready = report.apply_all_encodings() # apply every suggestion
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Granular APIs
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
profile = sedat.profile_dataframe(df)
|
|
75
|
+
pearson = sedat.numeric_correlation(df, method="pearson")
|
|
76
|
+
cramers = sedat.categorical_correlation(df) # Cramér's V matrix
|
|
77
|
+
eta = sedat.numeric_categorical_correlation(df) # correlation ratio matrix
|
|
78
|
+
fig = sedat.correlation_heatmap(pearson, title="Numeric correlations")
|
|
79
|
+
|
|
80
|
+
plan = sedat.suggest_encodings(df, target=df["target"], cardinality_threshold=10)
|
|
81
|
+
only_smoker = next(s for s in plan.suggestions if s.column == "smoker")
|
|
82
|
+
one_hot_step = only_smoker.apply(df) # accept one suggestion
|
|
83
|
+
all_encoded = plan.apply_all(df) # accept all suggestions
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install -e ".[dev]"
|
|
90
|
+
python -m pytest
|
|
91
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/se_dat.egg-info/PKG-INFO
|
|
4
|
+
src/se_dat.egg-info/SOURCES.txt
|
|
5
|
+
src/se_dat.egg-info/dependency_links.txt
|
|
6
|
+
src/se_dat.egg-info/requires.txt
|
|
7
|
+
src/se_dat.egg-info/top_level.txt
|
|
8
|
+
src/sedat/__init__.py
|
|
9
|
+
src/sedat/correlations.py
|
|
10
|
+
src/sedat/encoding.py
|
|
11
|
+
src/sedat/profile.py
|
|
12
|
+
src/sedat/report.py
|
|
13
|
+
src/sedat/types.py
|
|
14
|
+
tests/test_se_dat.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sedat
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""se-dat: Simple Exploratory Data Analysis tool.
|
|
2
|
+
|
|
3
|
+
Provides column type profiling, correlation analysis (numeric, categorical and
|
|
4
|
+
mixed) and encoding suggestions with automatic transforms.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .correlations import (
|
|
10
|
+
CorrelationReport,
|
|
11
|
+
categorical_correlation,
|
|
12
|
+
correlation_heatmap,
|
|
13
|
+
correlation_ratio,
|
|
14
|
+
correlation_report,
|
|
15
|
+
cramers_v,
|
|
16
|
+
numeric_categorical_correlation,
|
|
17
|
+
numeric_correlation,
|
|
18
|
+
)
|
|
19
|
+
from .encoding import (
|
|
20
|
+
EncodingPlan,
|
|
21
|
+
EncodingSuggestion,
|
|
22
|
+
apply_encodings,
|
|
23
|
+
suggest_encodings,
|
|
24
|
+
)
|
|
25
|
+
from .profile import ColumnProfile, DataFrameProfile, profile_column, profile_dataframe
|
|
26
|
+
from .report import EDAReport
|
|
27
|
+
from .types import infer_column_type, is_binary_like
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ColumnProfile",
|
|
33
|
+
"CorrelationReport",
|
|
34
|
+
"DataFrameProfile",
|
|
35
|
+
"EDAReport",
|
|
36
|
+
"EncodingPlan",
|
|
37
|
+
"EncodingSuggestion",
|
|
38
|
+
"apply_encodings",
|
|
39
|
+
"categorical_correlation",
|
|
40
|
+
"correlation_heatmap",
|
|
41
|
+
"correlation_ratio",
|
|
42
|
+
"correlation_report",
|
|
43
|
+
"cramers_v",
|
|
44
|
+
"infer_column_type",
|
|
45
|
+
"is_binary_like",
|
|
46
|
+
"numeric_categorical_correlation",
|
|
47
|
+
"numeric_correlation",
|
|
48
|
+
"profile_column",
|
|
49
|
+
"profile_dataframe",
|
|
50
|
+
"suggest_encodings",
|
|
51
|
+
]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Correlation analysis.
|
|
2
|
+
|
|
3
|
+
Numeric-numeric: Pearson/Spearman matrices plus a heatmap.
|
|
4
|
+
Categorical-categorical: Cramer's V.
|
|
5
|
+
Numeric-categorical: correlation ratio (eta), an ANOVA-style association measure.
|
|
6
|
+
|
|
7
|
+
Highly correlated pairs above a threshold are flagged as potential
|
|
8
|
+
multicollinearity.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Iterator
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from scipy.stats import chi2_contingency
|
|
19
|
+
|
|
20
|
+
from .types import binary_mapping
|
|
21
|
+
|
|
22
|
+
CORRELATION_TYPES = ("numeric", "boolean", "categorical")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _numeric_df(df: pd.DataFrame) -> pd.DataFrame:
|
|
26
|
+
return df.select_dtypes(include=[np.number])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _categorical_df(df: pd.DataFrame) -> pd.DataFrame:
|
|
30
|
+
picked = []
|
|
31
|
+
for col in df.columns:
|
|
32
|
+
dtype = df[col].dtype
|
|
33
|
+
if (
|
|
34
|
+
pd.api.types.is_bool_dtype(dtype)
|
|
35
|
+
or isinstance(dtype, pd.CategoricalDtype)
|
|
36
|
+
or dtype == object
|
|
37
|
+
or isinstance(dtype, pd.StringDtype)
|
|
38
|
+
):
|
|
39
|
+
picked.append(col)
|
|
40
|
+
return df[picked]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def numeric_correlation(df: pd.DataFrame, method: str = "pearson") -> pd.DataFrame:
|
|
44
|
+
"""Pearson or Spearman correlation matrix over numeric columns."""
|
|
45
|
+
num = _numeric_df(df)
|
|
46
|
+
if num.shape[1] < 2:
|
|
47
|
+
return pd.DataFrame()
|
|
48
|
+
return num.corr(method=method)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cramers_v(a: pd.Series, b: pd.Series, correction: bool = True) -> float:
|
|
52
|
+
"""Cramer's V association between two categorical series, in [0, 1]."""
|
|
53
|
+
if a.name is not None and b.name is not None and a.name == b.name:
|
|
54
|
+
return 1.0
|
|
55
|
+
cross = pd.crosstab(a.astype(str), b.astype(str))
|
|
56
|
+
if min(cross.shape) < 2:
|
|
57
|
+
return float("nan")
|
|
58
|
+
chi2, _, _, _ = chi2_contingency(cross, correction=correction)
|
|
59
|
+
n = int(cross.to_numpy().sum())
|
|
60
|
+
phi2 = chi2 / n
|
|
61
|
+
r, k = cross.shape
|
|
62
|
+
if correction:
|
|
63
|
+
phi2 = max(0.0, phi2 - ((k - 1) * (r - 1)) / (n - 1))
|
|
64
|
+
r = max(0, r - ((r - 1) ** 2) / (n - 1))
|
|
65
|
+
k = max(0, k - ((k - 1) ** 2) / (n - 1))
|
|
66
|
+
denom = min(k - 1, r - 1)
|
|
67
|
+
if denom <= 0:
|
|
68
|
+
return float("nan")
|
|
69
|
+
return float(np.sqrt(phi2 / denom))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def categorical_correlation(df: pd.DataFrame) -> pd.DataFrame:
|
|
73
|
+
"""Cramer's V matrix over categorical/boolean columns."""
|
|
74
|
+
cats = _categorical_df(df)
|
|
75
|
+
if cats.shape[1] < 2:
|
|
76
|
+
return pd.DataFrame()
|
|
77
|
+
mat = pd.DataFrame(index=cats.columns, columns=cats.columns, dtype=float)
|
|
78
|
+
for i, a in enumerate(cats.columns):
|
|
79
|
+
for j, b in enumerate(cats.columns):
|
|
80
|
+
if i <= j:
|
|
81
|
+
continue
|
|
82
|
+
v = cramers_v(cats[a], cats[b])
|
|
83
|
+
mat.loc[a, b] = v
|
|
84
|
+
mat.loc[b, a] = v
|
|
85
|
+
for i in range(mat.shape[0]):
|
|
86
|
+
mat.iat[i, i] = 1.0
|
|
87
|
+
return mat
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def correlation_ratio(numeric: pd.Series, categorical: pd.Series) -> float:
|
|
91
|
+
"""Eta correlation ratio between one numeric and one categorical series."""
|
|
92
|
+
y = pd.to_numeric(numeric, errors="coerce")
|
|
93
|
+
g = categorical.astype(str)
|
|
94
|
+
d = pd.DataFrame({"y": y, "g": g}).dropna()
|
|
95
|
+
if len(d) < 2 or d["g"].nunique() < 2:
|
|
96
|
+
return float("nan")
|
|
97
|
+
grand_mean = float(d["y"].mean())
|
|
98
|
+
group = d.groupby("g")["y"].agg(["mean", "count"])
|
|
99
|
+
ss_between = float(((group["mean"] - grand_mean) ** 2 * group["count"]).sum())
|
|
100
|
+
ss_total = float(((d["y"] - grand_mean) ** 2).sum())
|
|
101
|
+
if ss_total <= 0:
|
|
102
|
+
return float("nan")
|
|
103
|
+
return float(np.sqrt(ss_between / ss_total))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def numeric_categorical_correlation(df: pd.DataFrame) -> pd.DataFrame:
|
|
107
|
+
"""Eta matrix with numeric columns as rows and categorical columns as cols."""
|
|
108
|
+
num = _numeric_df(df)
|
|
109
|
+
cats = _categorical_df(df)
|
|
110
|
+
if num.shape[1] < 1 or cats.shape[1] < 1:
|
|
111
|
+
return pd.DataFrame()
|
|
112
|
+
rows = []
|
|
113
|
+
for n in num.columns:
|
|
114
|
+
for c in cats.columns:
|
|
115
|
+
rows.append({"numeric": n, "categorical": c, "eta": correlation_ratio(num[n], cats[c])})
|
|
116
|
+
if not rows:
|
|
117
|
+
return pd.DataFrame()
|
|
118
|
+
return pd.DataFrame(rows).pivot(index="numeric", columns="categorical", values="eta")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _upper_pairs(mat: pd.DataFrame) -> Iterator[tuple[str, str, float]]:
|
|
122
|
+
cols = list(mat.columns)
|
|
123
|
+
for i in range(len(cols)):
|
|
124
|
+
for j in range(i + 1, len(cols)):
|
|
125
|
+
v = mat.iat[i, j]
|
|
126
|
+
if np.isnan(v):
|
|
127
|
+
continue
|
|
128
|
+
yield cols[i], cols[j], float(v)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class CorrelationReport:
|
|
133
|
+
numeric: pd.DataFrame
|
|
134
|
+
numeric_spearman: pd.DataFrame
|
|
135
|
+
categorical: pd.DataFrame
|
|
136
|
+
numeric_categorical: pd.DataFrame
|
|
137
|
+
method_numeric: str = "pearson"
|
|
138
|
+
threshold: float = 0.7
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def summary(self) -> pd.DataFrame:
|
|
142
|
+
rows = []
|
|
143
|
+
for mat, kind, method in (
|
|
144
|
+
(self.numeric, "numeric", self.method_numeric),
|
|
145
|
+
(self.numeric_spearman, "numeric", "spearman"),
|
|
146
|
+
(self.categorical, "categorical", "cramers_v"),
|
|
147
|
+
(self.numeric_categorical, "numeric-categorical", "eta"),
|
|
148
|
+
):
|
|
149
|
+
if mat.empty:
|
|
150
|
+
continue
|
|
151
|
+
if kind == "numeric-categorical":
|
|
152
|
+
for num_col in mat.index:
|
|
153
|
+
for cat_col in mat.columns:
|
|
154
|
+
v = mat.loc[num_col, cat_col]
|
|
155
|
+
if np.isnan(v):
|
|
156
|
+
continue
|
|
157
|
+
rows.append(
|
|
158
|
+
{
|
|
159
|
+
"column_a": num_col,
|
|
160
|
+
"column_b": cat_col,
|
|
161
|
+
"kind": kind,
|
|
162
|
+
"method": method,
|
|
163
|
+
"value": round(float(v), 4),
|
|
164
|
+
"flagged": abs(float(v)) >= self.threshold,
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
for a, b, v in _upper_pairs(mat):
|
|
169
|
+
rows.append(
|
|
170
|
+
{
|
|
171
|
+
"column_a": a,
|
|
172
|
+
"column_b": b,
|
|
173
|
+
"kind": kind,
|
|
174
|
+
"method": method,
|
|
175
|
+
"value": round(v, 4),
|
|
176
|
+
"flagged": abs(v) >= self.threshold,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
return pd.DataFrame(rows)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def flagged_pairs(self) -> pd.DataFrame:
|
|
183
|
+
s = self.summary
|
|
184
|
+
return s[s["flagged"]] if not s.empty else s
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def correlation_report(
|
|
188
|
+
df: pd.DataFrame,
|
|
189
|
+
method: str = "pearson",
|
|
190
|
+
threshold: float = 0.7,
|
|
191
|
+
) -> CorrelationReport:
|
|
192
|
+
return CorrelationReport(
|
|
193
|
+
numeric=numeric_correlation(df, method=method),
|
|
194
|
+
numeric_spearman=numeric_correlation(df, method="spearman"),
|
|
195
|
+
categorical=categorical_correlation(df),
|
|
196
|
+
numeric_categorical=numeric_categorical_correlation(df),
|
|
197
|
+
method_numeric=method,
|
|
198
|
+
threshold=threshold,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _binary_numeric_map(df: pd.DataFrame) -> dict[str, dict[str, int]]:
|
|
203
|
+
return {
|
|
204
|
+
col: mapping
|
|
205
|
+
for col in df.columns
|
|
206
|
+
if (mapping := binary_mapping(df[col])) is not None
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def encode_binary_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
211
|
+
"""Encode binary-like object columns to 0/1 numeric for correlation use."""
|
|
212
|
+
out = df.copy()
|
|
213
|
+
for col, mapping in _binary_numeric_map(df).items():
|
|
214
|
+
out[col] = out[col].astype(str).str.strip().str.lower().map(mapping)
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def correlation_heatmap(matrix: pd.DataFrame, title: str = "Correlation matrix") -> object:
|
|
219
|
+
"""Plot a heatmap for a correlation matrix; returns a matplotlib Figure."""
|
|
220
|
+
import matplotlib.pyplot as plt
|
|
221
|
+
import seaborn as sns
|
|
222
|
+
|
|
223
|
+
fig, ax = plt.subplots(figsize=(max(6, matrix.shape[1] * 0.7), max(5, matrix.shape[0] * 0.6)))
|
|
224
|
+
sns.heatmap(matrix, annot=True, fmt=".2f", cmap="coolwarm", vmin=-1, vmax=1, ax=ax)
|
|
225
|
+
ax.set_title(title)
|
|
226
|
+
fig.tight_layout()
|
|
227
|
+
return fig
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Encoding suggestions and automatic transforms.
|
|
2
|
+
|
|
3
|
+
Detects binary-like string columns ("yes"/"no", "true"/"false", 0/1) and offers
|
|
4
|
+
an .encode()-style 0/1 map. Suggests one-hot encoding for low-cardinality
|
|
5
|
+
categoricals and warns/suggests target or ordinal encoding for high-cardinality
|
|
6
|
+
ones to avoid a dimensionality explosion.
|
|
7
|
+
|
|
8
|
+
Suggestions can be applied individually (``suggestion.apply``) or all at once
|
|
9
|
+
(``EncodingPlan.apply_all``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from .profile import profile_dataframe
|
|
20
|
+
from .types import binary_mapping
|
|
21
|
+
|
|
22
|
+
BINARY_STRATEGY = "binary_encode"
|
|
23
|
+
ONE_HOT_STRATEGY = "one_hot"
|
|
24
|
+
ORDINAL_STRATEGY = "ordinal_encode"
|
|
25
|
+
TARGET_STRATEGY = "target_encode"
|
|
26
|
+
NONE_STRATEGY = "none"
|
|
27
|
+
|
|
28
|
+
DEFAULT_CARDINALITY_THRESHOLD = 10
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class EncodingSuggestion:
|
|
33
|
+
column: str
|
|
34
|
+
current_type: str
|
|
35
|
+
suggested_type: str
|
|
36
|
+
strategy: str
|
|
37
|
+
cardinality: int
|
|
38
|
+
rationale: str
|
|
39
|
+
categories: list[str] = field(default_factory=list)
|
|
40
|
+
target_means: dict[str, float] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
def apply(self, df: pd.DataFrame, target: pd.Series | None = None) -> pd.DataFrame:
|
|
43
|
+
out = df.copy()
|
|
44
|
+
col = self.column
|
|
45
|
+
if col not in out.columns:
|
|
46
|
+
return out
|
|
47
|
+
if self.strategy == NONE_STRATEGY:
|
|
48
|
+
return out
|
|
49
|
+
if self.strategy == BINARY_STRATEGY:
|
|
50
|
+
mapping = binary_mapping(out[col])
|
|
51
|
+
if mapping is None:
|
|
52
|
+
ordered = sorted(out[col].dropna().unique())
|
|
53
|
+
mapping = {v: i for i, v in enumerate(ordered)}
|
|
54
|
+
out[col] = out[col].astype(str).str.strip().str.lower().map(mapping)
|
|
55
|
+
elif self.strategy == ONE_HOT_STRATEGY:
|
|
56
|
+
dummies = pd.get_dummies(out[col], prefix=col, dtype=int)
|
|
57
|
+
out = out.drop(columns=[col])
|
|
58
|
+
out = pd.concat([out, dummies], axis=1)
|
|
59
|
+
elif self.strategy == ORDINAL_STRATEGY:
|
|
60
|
+
ordered = sorted(out[col].dropna().astype(str).unique())
|
|
61
|
+
out[col] = out[col].astype(str).map({v: i for i, v in enumerate(ordered)})
|
|
62
|
+
elif self.strategy == TARGET_STRATEGY:
|
|
63
|
+
if target is not None:
|
|
64
|
+
means = target.groupby(out[col].astype(str)).transform("mean")
|
|
65
|
+
out[col] = pd.to_numeric(means, errors="coerce")
|
|
66
|
+
elif self.target_means:
|
|
67
|
+
out[col] = out[col].astype(str).map(self.target_means)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class EncodingPlan:
|
|
73
|
+
suggestions: list[EncodingSuggestion]
|
|
74
|
+
cardinality_threshold: int = DEFAULT_CARDINALITY_THRESHOLD
|
|
75
|
+
target: pd.Series | None = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def summary(self) -> pd.DataFrame:
|
|
79
|
+
rows = [
|
|
80
|
+
{
|
|
81
|
+
"column": s.column,
|
|
82
|
+
"current_type": s.current_type,
|
|
83
|
+
"suggested_type": s.suggested_type,
|
|
84
|
+
"strategy": s.strategy,
|
|
85
|
+
"cardinality": s.cardinality,
|
|
86
|
+
"rationale": s.rationale,
|
|
87
|
+
}
|
|
88
|
+
for s in self.suggestions
|
|
89
|
+
]
|
|
90
|
+
return pd.DataFrame(rows)
|
|
91
|
+
|
|
92
|
+
def apply(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
93
|
+
return self.apply_all(df)
|
|
94
|
+
|
|
95
|
+
def apply_all(self, df: pd.DataFrame, target: pd.Series | None = None) -> pd.DataFrame:
|
|
96
|
+
result = df.copy()
|
|
97
|
+
tgt = target if target is not None else self.target
|
|
98
|
+
for suggestion in self.suggestions:
|
|
99
|
+
result = suggestion.apply(result, target=tgt)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return repr(self.summary)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _warn(msg: str) -> None:
|
|
107
|
+
import warnings
|
|
108
|
+
|
|
109
|
+
warnings.warn(msg, UserWarning, stacklevel=3)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def suggest_encodings(
|
|
113
|
+
df: pd.DataFrame,
|
|
114
|
+
target: pd.Series | None = None,
|
|
115
|
+
cardinality_threshold: int = DEFAULT_CARDINALITY_THRESHOLD,
|
|
116
|
+
) -> EncodingPlan:
|
|
117
|
+
"""Build an :class:`EncodingPlan` of encoding suggestions for ``df``.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
df:
|
|
122
|
+
Input DataFrame.
|
|
123
|
+
target:
|
|
124
|
+
Optional numeric target Series used for target-encoding suggestions on
|
|
125
|
+
high-cardinality categoricals. The Series index must match ``df``.
|
|
126
|
+
cardinality_threshold:
|
|
127
|
+
Categoricals with at most this many distinct values get a one-hot
|
|
128
|
+
suggestion; above it they get target/ordinal encoding.
|
|
129
|
+
"""
|
|
130
|
+
profile = profile_dataframe(df)
|
|
131
|
+
suggestions: list[EncodingSuggestion] = []
|
|
132
|
+
|
|
133
|
+
for col_profile in profile.columns:
|
|
134
|
+
col = col_profile.name
|
|
135
|
+
series = df[col]
|
|
136
|
+
cardinality = col_profile.n_unique
|
|
137
|
+
current_type = col_profile.inferred_type
|
|
138
|
+
|
|
139
|
+
if current_type == "boolean":
|
|
140
|
+
if series.dtype == object or isinstance(series.dtype, pd.StringDtype):
|
|
141
|
+
suggestions.append(
|
|
142
|
+
EncodingSuggestion(
|
|
143
|
+
column=col,
|
|
144
|
+
current_type=current_type,
|
|
145
|
+
suggested_type="boolean (0/1)",
|
|
146
|
+
strategy=BINARY_STRATEGY,
|
|
147
|
+
cardinality=cardinality,
|
|
148
|
+
rationale="binary-like strings map cleanly to 0/1",
|
|
149
|
+
categories=sorted(series.dropna().unique().tolist()),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if current_type not in ("categorical",):
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
if cardinality == 0:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
if cardinality == 2:
|
|
161
|
+
suggestions.append(
|
|
162
|
+
EncodingSuggestion(
|
|
163
|
+
column=col,
|
|
164
|
+
current_type=current_type,
|
|
165
|
+
suggested_type="boolean (0/1)",
|
|
166
|
+
strategy=BINARY_STRATEGY,
|
|
167
|
+
cardinality=cardinality,
|
|
168
|
+
rationale="two distinct values: binary encode",
|
|
169
|
+
categories=sorted(series.dropna().unique().tolist()),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
elif cardinality <= cardinality_threshold:
|
|
173
|
+
suggestions.append(
|
|
174
|
+
EncodingSuggestion(
|
|
175
|
+
column=col,
|
|
176
|
+
current_type=current_type,
|
|
177
|
+
suggested_type="one-hot (dummy) columns",
|
|
178
|
+
strategy=ONE_HOT_STRATEGY,
|
|
179
|
+
cardinality=cardinality,
|
|
180
|
+
rationale=f"low-cardinality categorical (k={cardinality})",
|
|
181
|
+
categories=sorted(series.dropna().unique().tolist()),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
if target is not None:
|
|
186
|
+
suggestions.append(
|
|
187
|
+
EncodingSuggestion(
|
|
188
|
+
column=col,
|
|
189
|
+
current_type=current_type,
|
|
190
|
+
suggested_type="target-encoded numeric",
|
|
191
|
+
strategy=TARGET_STRATEGY,
|
|
192
|
+
cardinality=cardinality,
|
|
193
|
+
rationale=(
|
|
194
|
+
f"high-cardinality categorical (k={cardinality}): "
|
|
195
|
+
"one-hot would explode dimensionality"
|
|
196
|
+
),
|
|
197
|
+
categories=sorted(series.dropna().unique().tolist()),
|
|
198
|
+
target_means={
|
|
199
|
+
str(k): float(v)
|
|
200
|
+
for k, v in target.groupby(series.astype(str)).mean().items()
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
else:
|
|
205
|
+
_warn(
|
|
206
|
+
f"Column '{col}' has high cardinality ({cardinality}). "
|
|
207
|
+
"Pass a `target` Series to enable target encoding; "
|
|
208
|
+
"falling back to ordinal encoding."
|
|
209
|
+
)
|
|
210
|
+
suggestions.append(
|
|
211
|
+
EncodingSuggestion(
|
|
212
|
+
column=col,
|
|
213
|
+
current_type=current_type,
|
|
214
|
+
suggested_type="ordinal-encoded numeric",
|
|
215
|
+
strategy=ORDINAL_STRATEGY,
|
|
216
|
+
cardinality=cardinality,
|
|
217
|
+
rationale=(
|
|
218
|
+
f"high-cardinality categorical (k={cardinality}): "
|
|
219
|
+
"one-hot would explode dimensionality"
|
|
220
|
+
),
|
|
221
|
+
categories=sorted(series.dropna().astype(str).unique().tolist()),
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return EncodingPlan(suggestions=suggestions, cardinality_threshold=cardinality_threshold, target=target)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def apply_encodings(
|
|
229
|
+
df: pd.DataFrame,
|
|
230
|
+
plan: EncodingPlan | None = None,
|
|
231
|
+
target: pd.Series | None = None,
|
|
232
|
+
cardinality_threshold: int = DEFAULT_CARDINALITY_THRESHOLD,
|
|
233
|
+
) -> pd.DataFrame:
|
|
234
|
+
"""Shortcut: build a plan (if not given) and apply every suggestion."""
|
|
235
|
+
if plan is None:
|
|
236
|
+
plan = suggest_encodings(df, target=target, cardinality_threshold=cardinality_threshold)
|
|
237
|
+
return plan.apply_all(df, target=target)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Column type profiling.
|
|
2
|
+
|
|
3
|
+
Produces a per-column profile with the inferred semantic type, basic stats
|
|
4
|
+
(missing %, unique count, cardinality ratio) and a confidence flag when the
|
|
5
|
+
inference is ambiguous.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from .types import infer_column_type
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ColumnProfile:
|
|
19
|
+
name: str
|
|
20
|
+
dtype: str
|
|
21
|
+
inferred_type: str
|
|
22
|
+
n_non_null: int
|
|
23
|
+
n_unique: int
|
|
24
|
+
missing_pct: float
|
|
25
|
+
cardinality_ratio: float
|
|
26
|
+
confidence: str
|
|
27
|
+
notes: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def ambiguous(self) -> bool:
|
|
31
|
+
return self.confidence in ("low", "medium")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DataFrameProfile:
|
|
36
|
+
columns: list[ColumnProfile]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def summary(self) -> pd.DataFrame:
|
|
40
|
+
rows = [
|
|
41
|
+
{
|
|
42
|
+
"column": c.name,
|
|
43
|
+
"dtype": c.dtype,
|
|
44
|
+
"inferred_type": c.inferred_type,
|
|
45
|
+
"missing_pct": c.missing_pct,
|
|
46
|
+
"n_unique": c.n_unique,
|
|
47
|
+
"cardinality_ratio": c.cardinality_ratio,
|
|
48
|
+
"confidence": c.confidence,
|
|
49
|
+
"notes": "; ".join(c.notes),
|
|
50
|
+
}
|
|
51
|
+
for c in self.columns
|
|
52
|
+
]
|
|
53
|
+
return pd.DataFrame(rows).set_index("column")
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return repr(self.summary)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def profile_column(series: pd.Series) -> ColumnProfile:
|
|
60
|
+
info = infer_column_type(series)
|
|
61
|
+
return ColumnProfile(
|
|
62
|
+
name=str(series.name),
|
|
63
|
+
dtype=str(series.dtype),
|
|
64
|
+
inferred_type=info["inferred_type"],
|
|
65
|
+
n_non_null=info["n_non_null"],
|
|
66
|
+
n_unique=info["n_unique"],
|
|
67
|
+
missing_pct=info["missing_pct"],
|
|
68
|
+
cardinality_ratio=info["cardinality_ratio"],
|
|
69
|
+
confidence=info["confidence"],
|
|
70
|
+
notes=info["notes"],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def profile_dataframe(df: pd.DataFrame) -> DataFrameProfile:
|
|
75
|
+
return DataFrameProfile([profile_column(df[col]) for col in df.columns])
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""High-level facade combining profiling, correlations and encoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .correlations import CorrelationReport, correlation_report
|
|
10
|
+
from .encoding import (
|
|
11
|
+
DEFAULT_CARDINALITY_THRESHOLD,
|
|
12
|
+
EncodingPlan,
|
|
13
|
+
suggest_encodings,
|
|
14
|
+
)
|
|
15
|
+
from .profile import DataFrameProfile, profile_dataframe
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class EDAReport:
|
|
20
|
+
"""One object summarizing profiling, correlations and encodings for a frame."""
|
|
21
|
+
|
|
22
|
+
df: pd.DataFrame
|
|
23
|
+
profile: DataFrameProfile
|
|
24
|
+
correlations: CorrelationReport
|
|
25
|
+
encodings: EncodingPlan
|
|
26
|
+
corr_threshold: float = 0.7
|
|
27
|
+
cardinality_threshold: int = DEFAULT_CARDINALITY_THRESHOLD
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def create(
|
|
31
|
+
cls,
|
|
32
|
+
df: pd.DataFrame,
|
|
33
|
+
corr_threshold: float = 0.7,
|
|
34
|
+
cardinality_threshold: int = DEFAULT_CARDINALITY_THRESHOLD,
|
|
35
|
+
target: pd.Series | None = None,
|
|
36
|
+
) -> "EDAReport":
|
|
37
|
+
return cls(
|
|
38
|
+
df=df,
|
|
39
|
+
profile=profile_dataframe(df),
|
|
40
|
+
correlations=correlation_report(df, threshold=corr_threshold),
|
|
41
|
+
encodings=suggest_encodings(
|
|
42
|
+
df, target=target, cardinality_threshold=cardinality_threshold
|
|
43
|
+
),
|
|
44
|
+
corr_threshold=corr_threshold,
|
|
45
|
+
cardinality_threshold=cardinality_threshold,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def summary(self) -> pd.DataFrame:
|
|
50
|
+
return self.profile.summary
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def correlation_summary(self) -> pd.DataFrame:
|
|
54
|
+
return self.correlations.summary
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def encoding_summary(self) -> pd.DataFrame:
|
|
58
|
+
return self.encodings.summary
|
|
59
|
+
|
|
60
|
+
def apply_all_encodings(self, target: pd.Series | None = None) -> pd.DataFrame:
|
|
61
|
+
return self.encodings.apply_all(self.df, target=target)
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
lines = [repr(self.profile)]
|
|
65
|
+
corr = self.correlations.summary
|
|
66
|
+
if not corr.empty:
|
|
67
|
+
flagged = corr[corr["flagged"]]
|
|
68
|
+
if not flagged.empty:
|
|
69
|
+
lines.append("\nFlagged highly-correlated pairs:")
|
|
70
|
+
lines.append(repr(flagged))
|
|
71
|
+
lines.append("\n" + repr(self.encodings))
|
|
72
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Column type inference helpers.
|
|
2
|
+
|
|
3
|
+
Infers a semantic type for each column: numeric, boolean, categorical,
|
|
4
|
+
string/text, datetime or id (high-cardinality unique values), and reports a
|
|
5
|
+
confidence level plus explanatory notes when the inference is ambiguous.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
BINARY_VALUE_SETS: dict[str, set[str]] = {
|
|
16
|
+
"yes/no": {"yes", "no", "y", "n"},
|
|
17
|
+
"true/false": {"true", "false", "t", "f"},
|
|
18
|
+
"0/1": {"0", "1"},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_BINARY_MAPS: dict[frozenset[str], dict[str, int]] = {
|
|
22
|
+
frozenset({"yes", "no", "y", "n"}): {"yes": 1, "no": 0, "y": 1, "n": 0},
|
|
23
|
+
frozenset({"true", "false", "t", "f"}): {"true": 1, "false": 0, "t": 1, "f": 0},
|
|
24
|
+
frozenset({"0", "1"}): {"0": 1, "1": 0},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def binary_mapping(series: pd.Series) -> dict[str, int] | None:
|
|
29
|
+
"""Return a {value: 0/1} mapping if ``series`` is binary-like, else None."""
|
|
30
|
+
unique = set(series.dropna().astype(str).str.strip().str.lower().unique())
|
|
31
|
+
if not unique:
|
|
32
|
+
return None
|
|
33
|
+
for allowed, mapping in _BINARY_MAPS.items():
|
|
34
|
+
if unique.issubset(allowed):
|
|
35
|
+
return {v: mapping[v] for v in unique}
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_binary_like(series: pd.Series) -> bool:
|
|
40
|
+
return binary_mapping(series) is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parseable_as_datetime(series: pd.Series, threshold: float = 0.9) -> bool:
|
|
44
|
+
sample = series.dropna().astype(str).head(200)
|
|
45
|
+
if sample.empty:
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
with pd.option_context("mode.chained_assignment", None):
|
|
49
|
+
import warnings
|
|
50
|
+
|
|
51
|
+
with warnings.catch_warnings():
|
|
52
|
+
warnings.simplefilter("ignore")
|
|
53
|
+
parsed = pd.to_datetime(sample, errors="coerce")
|
|
54
|
+
except (TypeError, ValueError, OverflowError):
|
|
55
|
+
return False
|
|
56
|
+
return float(parsed.notna().mean()) >= threshold
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _numeric_like_string(series: pd.Series, threshold: float = 0.9) -> bool:
|
|
60
|
+
sample = series.dropna().head(500)
|
|
61
|
+
if sample.empty:
|
|
62
|
+
return False
|
|
63
|
+
coerced = pd.to_numeric(sample, errors="coerce")
|
|
64
|
+
return float(coerced.notna().mean()) >= threshold
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _looks_like_identifier(series: pd.Series, threshold: float = 0.95) -> bool:
|
|
68
|
+
sample = series.dropna().astype(str).head(500)
|
|
69
|
+
if sample.empty:
|
|
70
|
+
return False
|
|
71
|
+
no_whitespace = ~sample.str.contains(r"\s", regex=True)
|
|
72
|
+
return float(no_whitespace.mean()) >= threshold
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _basic_stats(series: pd.Series) -> dict[str, float | int]:
|
|
76
|
+
n_non_null = int(series.notna().sum())
|
|
77
|
+
n_rows = len(series)
|
|
78
|
+
n_unique = int(series.nunique(dropna=True))
|
|
79
|
+
missing_pct = 0.0 if n_rows == 0 else (n_rows - n_non_null) / n_rows * 100
|
|
80
|
+
cardinality_ratio = n_unique / n_non_null if n_non_null else 0.0
|
|
81
|
+
return {
|
|
82
|
+
"n_non_null": n_non_null,
|
|
83
|
+
"n_unique": n_unique,
|
|
84
|
+
"missing_pct": round(missing_pct, 2),
|
|
85
|
+
"cardinality_ratio": round(cardinality_ratio, 4),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def infer_column_type(series: pd.Series) -> dict[str, Any]:
|
|
90
|
+
"""Infer the semantic type of a single ``Series``.
|
|
91
|
+
|
|
92
|
+
Returns a dict with keys: inferred_type, confidence, notes, plus the basic
|
|
93
|
+
stats (n_non_null, n_unique, missing_pct, cardinality_ratio).
|
|
94
|
+
"""
|
|
95
|
+
stats = _basic_stats(series)
|
|
96
|
+
notes: list[str] = []
|
|
97
|
+
inferred_type: str
|
|
98
|
+
confidence: str = "high"
|
|
99
|
+
|
|
100
|
+
if isinstance(series.dtype, pd.CategoricalDtype):
|
|
101
|
+
inferred_type = "categorical"
|
|
102
|
+
elif pd.api.types.is_datetime64_any_dtype(series.dtype):
|
|
103
|
+
inferred_type = "datetime"
|
|
104
|
+
elif pd.api.types.is_bool_dtype(series.dtype):
|
|
105
|
+
inferred_type = "boolean"
|
|
106
|
+
elif pd.api.types.is_numeric_dtype(series.dtype):
|
|
107
|
+
mapping = binary_mapping(series)
|
|
108
|
+
if mapping is not None and stats["n_unique"] <= 2:
|
|
109
|
+
inferred_type = "boolean"
|
|
110
|
+
confidence = "medium"
|
|
111
|
+
notes.append("numeric 0/1 column: ambiguous boolean vs numeric")
|
|
112
|
+
elif (
|
|
113
|
+
pd.api.types.is_integer_dtype(series.dtype)
|
|
114
|
+
and stats["cardinality_ratio"] >= 0.95
|
|
115
|
+
):
|
|
116
|
+
inferred_type = "id"
|
|
117
|
+
notes.append("high-cardinality unique integer values")
|
|
118
|
+
else:
|
|
119
|
+
inferred_type = "numeric"
|
|
120
|
+
else:
|
|
121
|
+
if stats["n_non_null"] == 0:
|
|
122
|
+
inferred_type = "string"
|
|
123
|
+
confidence = "low"
|
|
124
|
+
notes.append("empty column")
|
|
125
|
+
else:
|
|
126
|
+
mapping = binary_mapping(series)
|
|
127
|
+
if mapping is not None:
|
|
128
|
+
inferred_type = "boolean"
|
|
129
|
+
confidence = "medium"
|
|
130
|
+
notes.append(f"stored as strings ({', '.join(sorted(mapping))})")
|
|
131
|
+
elif _parseable_as_datetime(series):
|
|
132
|
+
inferred_type = "datetime"
|
|
133
|
+
confidence = "medium"
|
|
134
|
+
notes.append("parsed from object dtype")
|
|
135
|
+
elif _numeric_like_string(series):
|
|
136
|
+
inferred_type = "numeric"
|
|
137
|
+
confidence = "low"
|
|
138
|
+
notes.append("numeric-looking values stored as string")
|
|
139
|
+
elif stats["cardinality_ratio"] >= 0.95:
|
|
140
|
+
if _looks_like_identifier(series):
|
|
141
|
+
inferred_type = "id"
|
|
142
|
+
notes.append("high-cardinality unique values")
|
|
143
|
+
else:
|
|
144
|
+
inferred_type = "string"
|
|
145
|
+
notes.append("high-cardinality free text")
|
|
146
|
+
elif stats["cardinality_ratio"] >= 0.5:
|
|
147
|
+
inferred_type = "string"
|
|
148
|
+
notes.append("many unique values")
|
|
149
|
+
else:
|
|
150
|
+
inferred_type = "categorical"
|
|
151
|
+
|
|
152
|
+
return {**stats, "inferred_type": inferred_type, "confidence": confidence, "notes": notes}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import sedat
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_df(n=1000, seed=0):
|
|
9
|
+
rng = np.random.default_rng(seed)
|
|
10
|
+
df = pd.DataFrame(
|
|
11
|
+
{
|
|
12
|
+
"id": np.arange(n),
|
|
13
|
+
"age": rng.integers(18, 80, n),
|
|
14
|
+
"height": rng.normal(170, 10, n).round(2),
|
|
15
|
+
"income": [str(x) for x in rng.integers(30000, 120000, n)],
|
|
16
|
+
"gender": rng.choice(["M", "F"], n),
|
|
17
|
+
"smoker": rng.choice(["yes", "no"], n),
|
|
18
|
+
"active": rng.choice([True, False], n),
|
|
19
|
+
"flag01": rng.integers(0, 2, n),
|
|
20
|
+
"score_cat": rng.choice(["low", "med", "high"], n),
|
|
21
|
+
"free_text": [f"user comment number {i}" for i in range(n)],
|
|
22
|
+
"joined": pd.to_datetime("2020-01-01")
|
|
23
|
+
+ pd.to_timedelta(rng.integers(0, 365 * 5, n), unit="D"),
|
|
24
|
+
"large_card": rng.choice([f"sku_{i}" for i in range(50)], n),
|
|
25
|
+
"target": rng.normal(0, 1, n),
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
df.loc[rng.choice(n, 25, replace=False), "age"] = np.nan
|
|
29
|
+
return df
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def type_map(profile):
|
|
33
|
+
return {c.name: c.inferred_type for c in profile.columns}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_profile_infers_types():
|
|
37
|
+
df = make_df()
|
|
38
|
+
profile = sedat.profile_dataframe(df)
|
|
39
|
+
types = type_map(profile)
|
|
40
|
+
assert types["id"] == "id"
|
|
41
|
+
assert types["age"] == "numeric"
|
|
42
|
+
assert types["height"] == "numeric"
|
|
43
|
+
assert types["income"] == "numeric"
|
|
44
|
+
assert types["gender"] == "categorical"
|
|
45
|
+
assert types["smoker"] == "boolean"
|
|
46
|
+
assert types["active"] == "boolean"
|
|
47
|
+
assert types["flag01"] == "boolean"
|
|
48
|
+
assert types["free_text"] == "string"
|
|
49
|
+
assert types["joined"] == "datetime"
|
|
50
|
+
assert types["target"] == "numeric"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_profile_confidence_flags():
|
|
54
|
+
df = make_df()
|
|
55
|
+
profile = sedat.profile_dataframe(df)
|
|
56
|
+
by_name = {c.name: c for c in profile.columns}
|
|
57
|
+
assert by_name["income"].confidence == "low"
|
|
58
|
+
assert by_name["income"].ambiguous
|
|
59
|
+
assert by_name["smoker"].confidence == "medium"
|
|
60
|
+
assert by_name["active"].confidence == "high"
|
|
61
|
+
assert "stored as string" in by_name["income"].notes[0]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_profile_basic_stats():
|
|
65
|
+
df = make_df()
|
|
66
|
+
profile = sedat.profile_dataframe(df)
|
|
67
|
+
by_name = {c.name: c for c in profile.columns}
|
|
68
|
+
assert by_name["age"].missing_pct == pytest.approx(2.5, abs=0.05)
|
|
69
|
+
assert by_name["id"].cardinality_ratio == pytest.approx(1.0)
|
|
70
|
+
assert by_name["gender"].n_unique == 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_numeric_correlation():
|
|
74
|
+
df = make_df()
|
|
75
|
+
pearson = sedat.numeric_correlation(df)
|
|
76
|
+
assert {"age", "height"} <= set(pearson.columns)
|
|
77
|
+
assert abs(pearson.loc["height", "age"]) < 0.3
|
|
78
|
+
spearman = sedat.numeric_correlation(df, method="spearman")
|
|
79
|
+
assert "age" in spearman.index
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_cramers_v_range():
|
|
83
|
+
df = make_df()
|
|
84
|
+
mat = sedat.categorical_correlation(df)
|
|
85
|
+
assert {"gender", "smoker", "score_cat"} <= set(mat.columns)
|
|
86
|
+
assert mat.loc["gender", "gender"] == 1.0
|
|
87
|
+
for col in mat.columns:
|
|
88
|
+
v = mat.loc["gender", col]
|
|
89
|
+
assert np.isnan(v) or 0.0 <= v <= 1.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_eta_range():
|
|
93
|
+
df = make_df()
|
|
94
|
+
mat = sedat.numeric_categorical_correlation(df)
|
|
95
|
+
assert "gender" in mat.columns
|
|
96
|
+
assert "age" in mat.index
|
|
97
|
+
v = mat.loc["age", "gender"]
|
|
98
|
+
assert np.isnan(v) or 0.0 <= v <= 1.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_correlation_report_flags():
|
|
102
|
+
df = make_df()
|
|
103
|
+
df2 = df.copy()
|
|
104
|
+
df2["height2"] = df["height"] * 1.5 + 3
|
|
105
|
+
report = sedat.correlation_report(df2, threshold=0.95)
|
|
106
|
+
flagged = report.flagged_pairs
|
|
107
|
+
assert not flagged.empty
|
|
108
|
+
pair = flagged[(flagged["column_a"] == "height") & (flagged["column_b"] == "height2")]
|
|
109
|
+
assert not pair.empty
|
|
110
|
+
assert pair["value"].iloc[0] == pytest.approx(1.0, abs=0.01)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_encode_binary_suggestion():
|
|
114
|
+
df = make_df()
|
|
115
|
+
plan = sedat.suggest_encodings(df)
|
|
116
|
+
by_col = {s.column: s for s in plan.suggestions}
|
|
117
|
+
assert by_col["smoker"].strategy == "binary_encode"
|
|
118
|
+
assert "active" not in by_col
|
|
119
|
+
assert by_col["gender"].strategy == "binary_encode"
|
|
120
|
+
assert by_col["score_cat"].strategy == "one_hot"
|
|
121
|
+
assert by_col["large_card"].strategy == "ordinal_encode"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_apply_encodings_all():
|
|
125
|
+
df = make_df()
|
|
126
|
+
plan = sedat.suggest_encodings(df)
|
|
127
|
+
out = plan.apply_all(df)
|
|
128
|
+
object_cols = [c for c in out.columns if out[c].dtype == object]
|
|
129
|
+
assert object_cols == []
|
|
130
|
+
assert out["smoker"].isin([0, 1]).all()
|
|
131
|
+
assert out["score_cat_low"].dtype == int
|
|
132
|
+
assert "large_card" in out.columns and out["large_card"].dtype in (int, float)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_target_encoding_high_cardinality():
|
|
136
|
+
df = make_df()
|
|
137
|
+
plan = sedat.suggest_encodings(df, target=df["target"])
|
|
138
|
+
by_col = {s.column: s for s in plan.suggestions}
|
|
139
|
+
assert by_col["large_card"].strategy == "target_encode"
|
|
140
|
+
out = plan.apply_all(df)
|
|
141
|
+
assert out["large_card"].dtype == float
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_apply_single_suggestion():
|
|
145
|
+
df = make_df()
|
|
146
|
+
plan = sedat.suggest_encodings(df)
|
|
147
|
+
smoker = next(s for s in plan.suggestions if s.column == "smoker")
|
|
148
|
+
out = smoker.apply(df)
|
|
149
|
+
assert out["smoker"].isin([0, 1]).all()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_report_end_to_end():
|
|
153
|
+
df = make_df()
|
|
154
|
+
report = sedat.EDAReport.create(df, target=df["target"])
|
|
155
|
+
assert isinstance(report.profile, sedat.DataFrameProfile)
|
|
156
|
+
assert report.encoding_summary is not None
|
|
157
|
+
out = report.apply_all_encodings()
|
|
158
|
+
assert object not in out.dtypes.values
|
|
159
|
+
assert "score_cat" not in out.columns
|
|
160
|
+
assert "score_cat_low" in out.columns
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_binary_mapping_helpers():
|
|
164
|
+
s = pd.Series(["yes", "no", "yes"])
|
|
165
|
+
assert sedat.is_binary_like(s)
|
|
166
|
+
s2 = pd.Series([0, 1, 1])
|
|
167
|
+
assert sedat.is_binary_like(s2)
|
|
168
|
+
s3 = pd.Series(["alpha", "beta", "gamma"])
|
|
169
|
+
assert not sedat.is_binary_like(s3)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_infer_column_type_direct():
|
|
173
|
+
s = pd.Series(["1.5", "2.5", "3.0"])
|
|
174
|
+
info = sedat.infer_column_type(s)
|
|
175
|
+
assert info["inferred_type"] == "numeric"
|
|
176
|
+
assert info["confidence"] == "low"
|