data-auditor 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.
- data_auditor-0.1.0/PKG-INFO +15 -0
- data_auditor-0.1.0/README.md +1 -0
- data_auditor-0.1.0/pyproject.toml +23 -0
- data_auditor-0.1.0/setup.cfg +4 -0
- data_auditor-0.1.0/src/data_auditor.egg-info/PKG-INFO +15 -0
- data_auditor-0.1.0/src/data_auditor.egg-info/SOURCES.txt +14 -0
- data_auditor-0.1.0/src/data_auditor.egg-info/dependency_links.txt +1 -0
- data_auditor-0.1.0/src/data_auditor.egg-info/requires.txt +4 -0
- data_auditor-0.1.0/src/data_auditor.egg-info/top_level.txt +5 -0
- data_auditor-0.1.0/src/encoders/custom_transformer.py +42 -0
- data_auditor-0.1.0/src/encoders/onehot.py +144 -0
- data_auditor-0.1.0/src/encoders/ordinal.py +108 -0
- data_auditor-0.1.0/src/fairness.py +161 -0
- data_auditor-0.1.0/src/preprocessing.py +165 -0
- data_auditor-0.1.0/src/profiler.py +322 -0
- data_auditor-0.1.0/src/utils.py +12 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: data-auditor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A modular profiling and preprocessing library for tabular ML datasets
|
|
5
|
+
Author-email: miriamspsantos <miriam.santos@fc.up.pt>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/miriamspsantos/data-auditor
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: pandas
|
|
11
|
+
Requires-Dist: scikit-learn
|
|
12
|
+
Requires-Dist: matplotlib
|
|
13
|
+
Requires-Dist: seaborn
|
|
14
|
+
|
|
15
|
+
data-auditor: 0.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
data-auditor: 0.1.0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "data-auditor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A modular profiling and preprocessing library for tabular ML datasets"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name="miriamspsantos", email="miriam.santos@fc.up.pt" }
|
|
11
|
+
]
|
|
12
|
+
license = {text = "MIT"}
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.9"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"pandas",
|
|
17
|
+
"scikit-learn",
|
|
18
|
+
"matplotlib",
|
|
19
|
+
"seaborn"
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
"Homepage" = "https://github.com/miriamspsantos/data-auditor"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: data-auditor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A modular profiling and preprocessing library for tabular ML datasets
|
|
5
|
+
Author-email: miriamspsantos <miriam.santos@fc.up.pt>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/miriamspsantos/data-auditor
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: pandas
|
|
11
|
+
Requires-Dist: scikit-learn
|
|
12
|
+
Requires-Dist: matplotlib
|
|
13
|
+
Requires-Dist: seaborn
|
|
14
|
+
|
|
15
|
+
data-auditor: 0.1.0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/fairness.py
|
|
4
|
+
src/preprocessing.py
|
|
5
|
+
src/profiler.py
|
|
6
|
+
src/utils.py
|
|
7
|
+
src/data_auditor.egg-info/PKG-INFO
|
|
8
|
+
src/data_auditor.egg-info/SOURCES.txt
|
|
9
|
+
src/data_auditor.egg-info/dependency_links.txt
|
|
10
|
+
src/data_auditor.egg-info/requires.txt
|
|
11
|
+
src/data_auditor.egg-info/top_level.txt
|
|
12
|
+
src/encoders/custom_transformer.py
|
|
13
|
+
src/encoders/onehot.py
|
|
14
|
+
src/encoders/ordinal.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from numpy.random import randint
|
|
2
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
3
|
+
|
|
4
|
+
#from encoders.ordinal import ProfilerOrdinalEncoder
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CustomTransformer(BaseEstimator, TransformerMixin):
|
|
8
|
+
"""Create custom transformer for learning purposes.
|
|
9
|
+
https://www.andrewvillazon.com/custom-scikit-learn-transformers/
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def fit(self, X, y=None):
|
|
14
|
+
return self
|
|
15
|
+
|
|
16
|
+
def transform(self, X, y=None):
|
|
17
|
+
# perform arbitrary transformation
|
|
18
|
+
X["random_int"] = randint(0, 10, X.shape[0])
|
|
19
|
+
return X
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MultiplyColumns(BaseEstimator, TransformerMixin):
|
|
23
|
+
def __init__(self, by=1, columns=None):
|
|
24
|
+
self.by = by
|
|
25
|
+
self.columns = columns
|
|
26
|
+
|
|
27
|
+
def fit(self, X, y=None):
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def transform(self, X, y=None):
|
|
31
|
+
cols_to_transform = list(X.columns)
|
|
32
|
+
|
|
33
|
+
if self.columns:
|
|
34
|
+
cols_to_transform = self.columns
|
|
35
|
+
|
|
36
|
+
X[cols_to_transform] = X[cols_to_transform] * self.by
|
|
37
|
+
|
|
38
|
+
return X
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
from profiler import DataProfiler
|
|
4
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
5
|
+
import copy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProfilerOneHotEncoder(BaseEstimator, TransformerMixin):
|
|
9
|
+
def __init__(self, profiler:DataProfiler=None, columns=None, drop=None, handle_unknown="use_nan"):
|
|
10
|
+
"""Perform One-Hot-Encoding
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
profiler : DataProfiler, optional
|
|
15
|
+
An instance of DataProfiler, for saving mappings.
|
|
16
|
+
Optional so that it works standalone too.
|
|
17
|
+
columns : list of str, optional
|
|
18
|
+
Columns to apply the encoding to. If None, encodes all columns.
|
|
19
|
+
Use columns for flexible control (standalone pipelines).
|
|
20
|
+
drop : {"if_binary", "last"}
|
|
21
|
+
How to drop one of the categories per feature.
|
|
22
|
+
"if_binary" removes 1 category from binary columns.
|
|
23
|
+
"last" keeps only n-1 columns for nominal data.
|
|
24
|
+
handle_unknown : {"use_nan", "error"}
|
|
25
|
+
How to handle unknown categories during transform.
|
|
26
|
+
"""
|
|
27
|
+
self.profiler = profiler
|
|
28
|
+
self.columns = columns
|
|
29
|
+
self.handle_unknown = handle_unknown
|
|
30
|
+
self.drop = drop
|
|
31
|
+
|
|
32
|
+
self.mappings = {} # {col: [cat1, cat2, cat3]}, e.g., {'Gender': ['F', 'M']}
|
|
33
|
+
self.inverse_mappings = {}
|
|
34
|
+
|
|
35
|
+
def get_encoder_type(self):
|
|
36
|
+
return "OHE"
|
|
37
|
+
|
|
38
|
+
def fit(self, X, y=None):
|
|
39
|
+
"""Scan columns and build mappings."""
|
|
40
|
+
X = pd.DataFrame(X)
|
|
41
|
+
|
|
42
|
+
cols_to_map = self.columns if self.columns else list(X.columns)
|
|
43
|
+
|
|
44
|
+
for col in cols_to_map:
|
|
45
|
+
categories = pd.Series(X[col].dropna().unique()).sort_values().tolist() # deterministic encoding
|
|
46
|
+
self.mappings[col] = categories # save to rebuild at transform time
|
|
47
|
+
|
|
48
|
+
# pre-save inverse mappings
|
|
49
|
+
for category in categories:
|
|
50
|
+
onehot_col = f"{col}_{category}"
|
|
51
|
+
self.inverse_mappings[onehot_col] = (col, category)
|
|
52
|
+
|
|
53
|
+
# Update profiler
|
|
54
|
+
if self.profiler is not None:
|
|
55
|
+
self.profiler._set_feature_mappings(map=self.mappings, inverse_map=self.inverse_mappings)
|
|
56
|
+
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def transform(self, X, y=None):
|
|
60
|
+
"""Applies mappings to create new one hot columns."""
|
|
61
|
+
X = pd.DataFrame(X).copy()
|
|
62
|
+
|
|
63
|
+
cols_to_map = self.columns if self.columns else list(X.columns)
|
|
64
|
+
|
|
65
|
+
for col in cols_to_map:
|
|
66
|
+
if col not in self.mappings:
|
|
67
|
+
raise KeyError(f"Error: No mapping found for column: {col}")
|
|
68
|
+
|
|
69
|
+
if ProfilerOneHotEncoder._value_not_in_mappings(X, col, self.mappings):
|
|
70
|
+
if self.handle_unknown == "error":
|
|
71
|
+
raise ValueError(f"Error: Category not in mappings: {col}")
|
|
72
|
+
elif self.handle_unknown != "use_nan":
|
|
73
|
+
raise ValueError(f"Error: Invalid handle_unknown option: {self.handle_unknown}")
|
|
74
|
+
|
|
75
|
+
X[col] = X[col].where(X[col].isin(self.mappings[col]))
|
|
76
|
+
|
|
77
|
+
cats_to_encode = self.mappings[col][:] # shallow copy, new list with same els
|
|
78
|
+
|
|
79
|
+
if self.drop is not None:
|
|
80
|
+
if self.drop == "if_binary":
|
|
81
|
+
if len(cats_to_encode) == 2:
|
|
82
|
+
del cats_to_encode[-1] # drop 1 if binary
|
|
83
|
+
elif self.drop == "last":
|
|
84
|
+
if len(cats_to_encode) >= 2:
|
|
85
|
+
del cats_to_encode[-1] # drop last category, keep n-1
|
|
86
|
+
else:
|
|
87
|
+
raise ValueError(f"Error: Invalid drop option: {self.drop}")
|
|
88
|
+
|
|
89
|
+
for cat in cats_to_encode:
|
|
90
|
+
ohe_col = f"{col}_{cat}"
|
|
91
|
+
X[ohe_col] = ((X[col] == cat).astype(int)).where(X[col].notnull(), other=np.nan)
|
|
92
|
+
|
|
93
|
+
# drop original columns
|
|
94
|
+
X.drop(columns=cols_to_map, inplace=True)
|
|
95
|
+
return X
|
|
96
|
+
|
|
97
|
+
def inverse_transform(self, X, y=None):
|
|
98
|
+
"""Reverse one hot vectors to original categories."""
|
|
99
|
+
X = pd.DataFrame(X).copy()
|
|
100
|
+
|
|
101
|
+
col_groups = {}
|
|
102
|
+
|
|
103
|
+
# Go through inverse_mappings and group ohe columns
|
|
104
|
+
# inverse_mappings: {'Gender_F': ('Gender', 'F'), 'Gender_M': ('Gender', 'M')}
|
|
105
|
+
# col_groups = {'Gender': [('Gender_F', 'F'), ('Gender_M', 'M')]}
|
|
106
|
+
for ohe_col, (orig_col, cat) in self.inverse_mappings.items():
|
|
107
|
+
if orig_col not in col_groups.keys():
|
|
108
|
+
col_groups[orig_col] = []
|
|
109
|
+
col_groups[orig_col].append((ohe_col, cat))
|
|
110
|
+
|
|
111
|
+
result = pd.DataFrame(index=X.index) # empty dataframe with same index (row labels) as X
|
|
112
|
+
|
|
113
|
+
for orig_col in col_groups.keys():
|
|
114
|
+
result[orig_col] = X.apply(
|
|
115
|
+
ProfilerOneHotEncoder.decode_row,
|
|
116
|
+
axis=1,
|
|
117
|
+
args=(col_groups[orig_col], self.drop, self.mappings[orig_col])
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
ohe_cols = list(self.inverse_mappings.keys())
|
|
121
|
+
X.drop(columns=[col for col in ohe_cols if col in X.columns], inplace=True)
|
|
122
|
+
|
|
123
|
+
X = pd.concat([X, result], axis=1)
|
|
124
|
+
|
|
125
|
+
return X
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _value_not_in_mappings(df, col:str, mappings:list):
|
|
129
|
+
"""Checks whether the values of a column exist in mappings.
|
|
130
|
+
Supports either mapping direction.
|
|
131
|
+
"""
|
|
132
|
+
for category in list(df[col].dropna().unique()):
|
|
133
|
+
if category not in mappings[col]:
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def decode_row(row, ohe_list, drop, mappings):
|
|
138
|
+
for ohe_col, category in ohe_list:
|
|
139
|
+
if ohe_col in row:
|
|
140
|
+
if row[ohe_col] == 1: return category
|
|
141
|
+
elif pd.isna(row[ohe_col]): return np.nan
|
|
142
|
+
if drop in ["if_binary", "last"]:
|
|
143
|
+
return mappings[-1]
|
|
144
|
+
return np.nan
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
from sklearn.base import BaseEstimator, TransformerMixin
|
|
4
|
+
from profiler import DataProfiler
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ProfilerOrdinalEncoder(BaseEstimator, TransformerMixin):
|
|
8
|
+
def __init__(self, profiler:DataProfiler=None, columns=None, categories=None, handle_unknown="use_nan"):
|
|
9
|
+
"""Perform ordinal encoding.
|
|
10
|
+
https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.OrdinalEncoder.html
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
profiler : DataProfiler, optional
|
|
15
|
+
An instance of DataProfiler, for saving mappings.
|
|
16
|
+
Optional so that it works standalone too.
|
|
17
|
+
columns : list of str, optional
|
|
18
|
+
Columns to apply the encoding to. If None, encodes all columns.
|
|
19
|
+
Use columns for flexible control (standalone pipelines).
|
|
20
|
+
categories: list of list, optional
|
|
21
|
+
Defined the expected ordered categories for the ith column.
|
|
22
|
+
handle_unknown : {"use_nan", "error"}
|
|
23
|
+
How to handle unknown categories during transform.
|
|
24
|
+
"""
|
|
25
|
+
self.profiler = profiler
|
|
26
|
+
self.columns = columns
|
|
27
|
+
self.categories = categories
|
|
28
|
+
self.handle_unknown = handle_unknown
|
|
29
|
+
|
|
30
|
+
# provide scaffolding to save mappings during fit()
|
|
31
|
+
self.mappings = {} # {col: {label: int}}
|
|
32
|
+
self.inverse_mappings = {} # {col: {int: label}}
|
|
33
|
+
|
|
34
|
+
def get_encoder_type(self):
|
|
35
|
+
return "Ordinal Encoder"
|
|
36
|
+
|
|
37
|
+
def fit(self, X, y=None):
|
|
38
|
+
"""Scan columns and build mappings."""
|
|
39
|
+
X = pd.DataFrame(X)
|
|
40
|
+
|
|
41
|
+
cols_to_map = self.columns if self.columns else list(X.columns)
|
|
42
|
+
|
|
43
|
+
for i, col in enumerate(cols_to_map):
|
|
44
|
+
if self.categories and i < len(self.categories):
|
|
45
|
+
ordered_values = self.categories[i]
|
|
46
|
+
else:
|
|
47
|
+
ordered_values = pd.Series(X[col].dropna().unique()).sort_values().tolist() # deterministic encoding
|
|
48
|
+
|
|
49
|
+
mapping = {val: idx for idx, val in enumerate(ordered_values)}
|
|
50
|
+
inverse_mapping = {idx: val for val, idx in mapping.items()}
|
|
51
|
+
|
|
52
|
+
self.mappings[col] = mapping
|
|
53
|
+
self.inverse_mappings[col] = inverse_mapping
|
|
54
|
+
|
|
55
|
+
# Update profiler
|
|
56
|
+
if self.profiler is not None:
|
|
57
|
+
self.profiler._set_feature_mappings(map=self.mappings, inverse_map=self.inverse_mappings)
|
|
58
|
+
|
|
59
|
+
return self # always return self with a fit()
|
|
60
|
+
|
|
61
|
+
def transform(self, X, y=None):
|
|
62
|
+
"""Apply mappings to turn labels into numerical values."""
|
|
63
|
+
X = pd.DataFrame(X).copy()
|
|
64
|
+
|
|
65
|
+
cols_to_map = self.columns if self.columns else list(X.columns)
|
|
66
|
+
|
|
67
|
+
for col in cols_to_map:
|
|
68
|
+
if col not in self.mappings:
|
|
69
|
+
raise KeyError(f"Error: No mapping found for column: {col}")
|
|
70
|
+
|
|
71
|
+
if ProfilerOrdinalEncoder._value_not_in_mappings(X, col, self.mappings):
|
|
72
|
+
if self.handle_unknown == "error":
|
|
73
|
+
raise ValueError(f"Error: Category not in mappings: {col}")
|
|
74
|
+
elif self.handle_unknown != "use_nan":
|
|
75
|
+
raise ValueError(f"Error: Invalid handle_unknown option: {self.handle_unknown}")
|
|
76
|
+
|
|
77
|
+
X[col] = X[col].map(self.mappings[col]) # this will type-cast to float if there are NaN
|
|
78
|
+
|
|
79
|
+
return X
|
|
80
|
+
|
|
81
|
+
def inverse_transform(self, X, y=None):
|
|
82
|
+
"""Reverse integers back to original labels."""
|
|
83
|
+
X = pd.DataFrame(X).copy()
|
|
84
|
+
|
|
85
|
+
cols_to_map = self.columns if self.columns else list(X.columns)
|
|
86
|
+
|
|
87
|
+
for col in cols_to_map:
|
|
88
|
+
if col not in self.inverse_mappings:
|
|
89
|
+
raise KeyError(f"Error: No inverse mapping found for column: {col}")
|
|
90
|
+
|
|
91
|
+
if ProfilerOrdinalEncoder._value_not_in_mappings(X, col, self.inverse_mappings):
|
|
92
|
+
if self.handle_unknown == "error":
|
|
93
|
+
raise ValueError(f"Error: Category not in mappings: {col}")
|
|
94
|
+
elif self.handle_unknown != "use_nan":
|
|
95
|
+
raise ValueError(f"Error: Invalid handle_unknown option: {self.handle_unknown}")
|
|
96
|
+
|
|
97
|
+
X[col] = X[col].map(self.inverse_mappings[col])
|
|
98
|
+
|
|
99
|
+
return X
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _value_not_in_mappings(df, col:str, mappings:list):
|
|
103
|
+
"""Checks whether the values of a column exist in mappings.
|
|
104
|
+
Supports either mapping direction.
|
|
105
|
+
"""
|
|
106
|
+
for category in list(df[col].dropna().unique()):
|
|
107
|
+
if category not in list(mappings[col].keys()):
|
|
108
|
+
return True
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import seaborn as sns
|
|
3
|
+
import numpy as np
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
from profiler import DataProfiler
|
|
6
|
+
from itertools import combinations
|
|
7
|
+
from typing import Union
|
|
8
|
+
|
|
9
|
+
class FairnessEngine:
|
|
10
|
+
def __init__(self, df, sensitive_features:list[str], target:str=None):
|
|
11
|
+
self.df = df
|
|
12
|
+
#TODO: maybe check if these features exist in df and if they are categorical
|
|
13
|
+
self.sensitive_features = sensitive_features
|
|
14
|
+
self.target = target
|
|
15
|
+
self.profiler = DataProfiler(df=df, target=target)
|
|
16
|
+
self.sensitive_groups = FairnessEngine.intersectional_fairness_groups(sensitive_features)
|
|
17
|
+
self.fairness_df = FairnessEngine.create_fairness_df(df=self.df,
|
|
18
|
+
sensitive_groups=self.sensitive_groups)
|
|
19
|
+
self.fairness_profiler = DataProfiler(df=self.fairness_df, target=target)
|
|
20
|
+
|
|
21
|
+
def group_proportions_table(self, group:str, plot_tab=False) -> pd.DataFrame:
|
|
22
|
+
"""Creates a table of group distribution.
|
|
23
|
+
Group can be a combination of sensitive features, given by
|
|
24
|
+
self.sensitive_groups.
|
|
25
|
+
"""
|
|
26
|
+
if group in self.sensitive_groups.keys():
|
|
27
|
+
sensitive_features = self.sensitive_groups[group]
|
|
28
|
+
rows_index = sensitive_features[0] if len(sensitive_features) > 1 else None
|
|
29
|
+
cols = sensitive_features[1:] if rows_index else sensitive_features
|
|
30
|
+
|
|
31
|
+
pivot = pd.pivot_table(
|
|
32
|
+
self.df,
|
|
33
|
+
index=rows_index,
|
|
34
|
+
columns=cols,
|
|
35
|
+
aggfunc="size",
|
|
36
|
+
fill_value=0
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
pivot_percent = round(pivot / pivot.values.sum() * 100, 2)
|
|
40
|
+
|
|
41
|
+
if plot_tab:
|
|
42
|
+
self.profiler.plot_pivot_table(
|
|
43
|
+
index=rows_index,
|
|
44
|
+
columns=cols,
|
|
45
|
+
aggfunc="size",
|
|
46
|
+
title="Proportions by Group",
|
|
47
|
+
plot_percent=True,
|
|
48
|
+
figsize=(pivot_percent.shape[1] * 2, pivot_percent.shape[0] * 2))
|
|
49
|
+
|
|
50
|
+
return pivot_percent
|
|
51
|
+
|
|
52
|
+
def target_rate_by_group_table(self, group:str, target:str=None, plot_tab=False) -> pd.DataFrame:
|
|
53
|
+
"""Creates a table of target distribution by group.
|
|
54
|
+
Group can be a combination of sensitive features, given by
|
|
55
|
+
self.sensitive_groups.
|
|
56
|
+
"""
|
|
57
|
+
#TODO: add protections for unknown group or target
|
|
58
|
+
target = self.target if target is None else target
|
|
59
|
+
|
|
60
|
+
if group in self.sensitive_groups.keys() and target:
|
|
61
|
+
pivot = pd.pivot_table(
|
|
62
|
+
self.df,
|
|
63
|
+
index=target,
|
|
64
|
+
columns=self.sensitive_groups[group],
|
|
65
|
+
aggfunc="size",
|
|
66
|
+
fill_value=0
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
pivot_rate = round(pivot / pivot.values.sum() * 100, 2)
|
|
70
|
+
|
|
71
|
+
if plot_tab:
|
|
72
|
+
self.profiler.plot_pivot_table(
|
|
73
|
+
index=target,
|
|
74
|
+
columns=self.sensitive_groups[group],
|
|
75
|
+
aggfunc="size",
|
|
76
|
+
title="Proportions by Group",
|
|
77
|
+
plot_percent=True,
|
|
78
|
+
figsize=(pivot_rate.shape[1] * 2, pivot_rate.shape[0] * 2))
|
|
79
|
+
|
|
80
|
+
return pivot_rate
|
|
81
|
+
|
|
82
|
+
def missing_data_disparity_imbalance(self):
|
|
83
|
+
"""Calculates the disparity (imbalance ratio) by feature
|
|
84
|
+
for each group or group aggregation.
|
|
85
|
+
Returns a dictionary for all groups and all features.
|
|
86
|
+
"""
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def plot_sensitive_groups(self, groups:Union[str, list], target=None, subplot_size=(9, 4)):
|
|
90
|
+
"""Plots the distribution of sensitive groups. If target is given,
|
|
91
|
+
the distribution will be color-coded. Parameter groups can be a str of specific group,
|
|
92
|
+
a list of groups, or "all" (in this case it plots all sensitive groups).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
all_groups = self.get_sensitive_groups()
|
|
96
|
+
|
|
97
|
+
if groups == "all": groups = all_groups
|
|
98
|
+
elif isinstance(groups, str): groups=[groups]
|
|
99
|
+
elif not (isinstance(groups, list) and all(g in all_groups for g in groups)):
|
|
100
|
+
raise ValueError("Error: Unknown group passed as argument.")
|
|
101
|
+
|
|
102
|
+
self.fairness_profiler.plot_mult_histogram(
|
|
103
|
+
col_names=groups,
|
|
104
|
+
hue=target,
|
|
105
|
+
cols=1,
|
|
106
|
+
subplot_size=subplot_size,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def plot_features_by_group(self, col_names:list, groups:Union[str, list], subplot_size=(9, 4)):
|
|
110
|
+
|
|
111
|
+
all_groups = self.get_sensitive_groups()
|
|
112
|
+
|
|
113
|
+
if groups == "all": groups = all_groups
|
|
114
|
+
elif isinstance(groups, str): groups=[groups]
|
|
115
|
+
elif not (isinstance(groups, list) and all(g in all_groups for g in groups)):
|
|
116
|
+
raise ValueError("Error: Unknown group passed as argument.")
|
|
117
|
+
|
|
118
|
+
if isinstance(col_names, str): col_names=[col_names]
|
|
119
|
+
|
|
120
|
+
for group in groups:
|
|
121
|
+
print(col_names)
|
|
122
|
+
print(group)
|
|
123
|
+
self.fairness_profiler.plot_mult_histogram(col_names=col_names, hue=group)
|
|
124
|
+
|
|
125
|
+
def get_sensitive_groups(self):
|
|
126
|
+
return list(self.sensitive_groups.keys())
|
|
127
|
+
|
|
128
|
+
def get_sensitive_features(self):
|
|
129
|
+
return self.sensitive_features
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def intersectional_fairness_groups(sensitive_features:list[str]) -> dict:
|
|
134
|
+
"""Determines interseccional sensitive groups.
|
|
135
|
+
For each sensitive group, returns the features that
|
|
136
|
+
create it.
|
|
137
|
+
"""
|
|
138
|
+
res = []
|
|
139
|
+
for i in range(1, len(sensitive_features) + 1):
|
|
140
|
+
c = list(combinations(sensitive_features, i))
|
|
141
|
+
res.extend(c)
|
|
142
|
+
group_lst = ["_".join(pairs) for pairs in res]
|
|
143
|
+
|
|
144
|
+
intersectional_groups = {}
|
|
145
|
+
for g in group_lst:
|
|
146
|
+
intersectional_groups[g] = g.split("_")
|
|
147
|
+
|
|
148
|
+
return intersectional_groups
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def create_fairness_df(df: pd.DataFrame, sensitive_groups: dict):
|
|
152
|
+
"""Creates a copy of the original dataframe and adds columns
|
|
153
|
+
corresponding to the combination of sensitive features.
|
|
154
|
+
"""
|
|
155
|
+
fairness_df = df.copy()
|
|
156
|
+
for group, features in sensitive_groups.items():
|
|
157
|
+
if len(features) > 1:
|
|
158
|
+
fairness_df[group] = fairness_df[features].agg(lambda x: "_".join(x.dropna().astype(str))
|
|
159
|
+
if x.notna().all() else np.nan, axis=1)
|
|
160
|
+
return fairness_df
|
|
161
|
+
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from profiler import DataProfiler
|
|
2
|
+
import copy
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
from encoders.ordinal import ProfilerOrdinalEncoder
|
|
5
|
+
from encoders.onehot import ProfilerOneHotEncoder
|
|
6
|
+
from pandas import DataFrame
|
|
7
|
+
from sklearn.compose import ColumnTransformer
|
|
8
|
+
from sklearn.preprocessing import MinMaxScaler
|
|
9
|
+
|
|
10
|
+
# And functions to perform MinMax, Scaling, etc to each of them
|
|
11
|
+
# Note sure how Pipelines work and whether we'd like to incorporate them here, probably we won't
|
|
12
|
+
|
|
13
|
+
# Decorators
|
|
14
|
+
def track_changes(func: Callable):
|
|
15
|
+
def wrapper(self, *args, **kwargs):
|
|
16
|
+
result = func(self, *args, **kwargs)
|
|
17
|
+
self.change_logs.append(f"{func.__name__}")
|
|
18
|
+
return result # can't be swallowed by decorator to allow chaining
|
|
19
|
+
return wrapper
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DataPreprocessor:
|
|
23
|
+
def __init__(self, profiler: DataProfiler):
|
|
24
|
+
self.profiler = profiler # shared reference
|
|
25
|
+
#self.df = copy.deepcopy(profiler.df)
|
|
26
|
+
self.df = profiler.df.copy()
|
|
27
|
+
self.encoders = {}
|
|
28
|
+
self.column_transformers = {}
|
|
29
|
+
self.change_logs = []
|
|
30
|
+
self.log_details = []
|
|
31
|
+
|
|
32
|
+
@track_changes
|
|
33
|
+
def delete_features(self, col_names: list):
|
|
34
|
+
"""Delete specific features from the dataset and update profiler."""
|
|
35
|
+
if self.profiler.target in col_names:
|
|
36
|
+
self.profiler.target = None
|
|
37
|
+
self.df.drop(columns=col_names, inplace=True) # modify directly, do not create new dataframe
|
|
38
|
+
self.profiler.df = self.df
|
|
39
|
+
self.profiler._refresh()
|
|
40
|
+
log_msg = f"Deleted: {col_names}"
|
|
41
|
+
self.log_details.append(log_msg)
|
|
42
|
+
return self # allows for method chaining (e.g. processor.delete_features().scale_numericals())
|
|
43
|
+
# without return self, you need to call separately:
|
|
44
|
+
# processor.delete_features()
|
|
45
|
+
# processor.scale_numericals()
|
|
46
|
+
#TODO: Maybe delete corresponding feature mappings, if feature gets deleted?
|
|
47
|
+
# (self.mapping and profiler.mappings?)
|
|
48
|
+
|
|
49
|
+
@track_changes
|
|
50
|
+
def set_semantic_feature_types(self,
|
|
51
|
+
continuous_cols: Optional[list]=None,
|
|
52
|
+
ordinal_cols: Optional[list]=None,
|
|
53
|
+
nominal_cols: Optional[list]=None,
|
|
54
|
+
binary_cols: Optional[list]=None):
|
|
55
|
+
"""Defines continuous, ordinal, nominal, and binary features."""
|
|
56
|
+
self.profiler._set_semantic_feature_types(
|
|
57
|
+
cont_cols = continuous_cols,
|
|
58
|
+
ord_cols = ordinal_cols,
|
|
59
|
+
nom_cols = nominal_cols,
|
|
60
|
+
bin_cols = binary_cols)
|
|
61
|
+
log_msg = ""
|
|
62
|
+
if continuous_cols: log_msg += f"Continuous: {continuous_cols}, "
|
|
63
|
+
if ordinal_cols: log_msg += f"Ordinal: {ordinal_cols} "
|
|
64
|
+
if nominal_cols: log_msg += f"Nominal: {nominal_cols} "
|
|
65
|
+
if binary_cols: log_msg += f"Binary: {binary_cols} "
|
|
66
|
+
self.log_details.append(log_msg)
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
@track_changes
|
|
70
|
+
def change_feature_types(self, num_cols:list[str]=None, cat_cols:list[str]=None):
|
|
71
|
+
self.profiler._override_feature_types(numeric=num_cols, categorical=cat_cols)
|
|
72
|
+
self.profiler._refresh()
|
|
73
|
+
log_msg = ""
|
|
74
|
+
if num_cols: log_msg += f"Num Cols: {num_cols}"
|
|
75
|
+
if cat_cols: log_msg += f"Cat Cols: {cat_cols}"
|
|
76
|
+
self.log_details.append(log_msg)
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def print_log_report(self):
|
|
80
|
+
print("===== Track Changes =====")
|
|
81
|
+
for i in range(len(self.change_logs)):
|
|
82
|
+
print(f"Method: {self.change_logs[i]}. {self.log_details[i]}")
|
|
83
|
+
|
|
84
|
+
@track_changes
|
|
85
|
+
def ohe_transform(self, profiler=None, columns:list[str]=None, drop=None, handle_unknown="use_nan"):
|
|
86
|
+
ohe = ProfilerOneHotEncoder(
|
|
87
|
+
profiler=profiler if profiler is not None else self.profiler,
|
|
88
|
+
columns=columns,
|
|
89
|
+
drop=drop,
|
|
90
|
+
handle_unknown=handle_unknown
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
self.df = ohe.fit_transform(self.df)
|
|
94
|
+
self.profiler.df = self.df
|
|
95
|
+
key = f"OHE_{'_'.join(ohe.columns)}"
|
|
96
|
+
self.encoders[key] = ohe
|
|
97
|
+
self.profiler._refresh()
|
|
98
|
+
log_msg = f"OHE Encoding: {ohe.columns}"
|
|
99
|
+
self.log_details.append(log_msg)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
@track_changes
|
|
103
|
+
def ordinal_transform(self, profiler=None, columns:list[str]=None, categories=None, handle_unknown="use_nan"):
|
|
104
|
+
oe = ProfilerOrdinalEncoder(
|
|
105
|
+
profiler=profiler if profiler is not None else self.profiler,
|
|
106
|
+
columns=columns,
|
|
107
|
+
categories=categories,
|
|
108
|
+
handle_unknown=handle_unknown
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self.df = oe.fit_transform(self.df)
|
|
112
|
+
self.profiler.df = self.df
|
|
113
|
+
key = f"OrdinalEnc_{'_'.join(oe.columns)}"
|
|
114
|
+
self.encoders[key] = oe
|
|
115
|
+
self.profiler._refresh()
|
|
116
|
+
log_msg = f"Ordinal Encoding: {oe.columns}"
|
|
117
|
+
self.log_details.append(log_msg)
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
@track_changes
|
|
121
|
+
def encoder_inverse_transform(self, encoder_key:str):
|
|
122
|
+
encoder = self.encoders[encoder_key]
|
|
123
|
+
self.df = encoder.inverse_transform(self.df)
|
|
124
|
+
self.profiler.df = self.df
|
|
125
|
+
self.profiler._refresh()
|
|
126
|
+
encoder_type = encoder.get_encoder_type()
|
|
127
|
+
log_msg = f"{encoder_type} Inverse Transform: {encoder.columns}"
|
|
128
|
+
self.log_details.append(log_msg)
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
@track_changes
|
|
132
|
+
def auto_col_transformer(self):
|
|
133
|
+
"""Automatically build ColumnTransformer based on profiler metadata."""
|
|
134
|
+
|
|
135
|
+
transformer_constructor = {
|
|
136
|
+
"cont_cols": lambda: ("scale_continuous", MinMaxScaler(), getattr(self.profiler, "cont_cols", [])),
|
|
137
|
+
"ord_cols": lambda: ("encode_ordinal", ProfilerOrdinalEncoder(profiler=self.profiler), getattr(self.profiler, "ord_cols", [])),
|
|
138
|
+
"nom_cols": lambda: ("encode_nominal", ProfilerOneHotEncoder(profiler=self.profiler), getattr(self.profiler, "nom_cols", [])),
|
|
139
|
+
"bin_cols": lambda: ("encode_binary", ProfilerOneHotEncoder(profiler=self.profiler), getattr(self.profiler, "bin_cols", []))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
transformers = []
|
|
143
|
+
log_transformers = ""
|
|
144
|
+
|
|
145
|
+
for constructor in transformer_constructor.values():
|
|
146
|
+
name, transformer, cols = constructor() # call lambda function to return tuple
|
|
147
|
+
if cols:
|
|
148
|
+
transformers.append((name, transformer, cols))
|
|
149
|
+
log_transformers += f"{name}"
|
|
150
|
+
|
|
151
|
+
if not transformers:
|
|
152
|
+
raise Exception("Error: No transformer was created.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
ct = ColumnTransformer(transformers=transformers, remainder="passthrough")
|
|
156
|
+
log_msg = "Automatic Column Transformer Created: " + log_transformers.rstrip("_")
|
|
157
|
+
self.log_details.append(log_msg)
|
|
158
|
+
key = f"ColumnTransformer_{log_transformers.rstrip("_")}"
|
|
159
|
+
self.column_transformers[key] = ct
|
|
160
|
+
|
|
161
|
+
return ct
|
|
162
|
+
|
|
163
|
+
def get_encoders(self):
|
|
164
|
+
return self.encoders
|
|
165
|
+
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import seaborn as sns
|
|
3
|
+
import numpy as np
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
class DataProfiler:
|
|
8
|
+
"""Organizes metadata and offers visual diagnostics on tabular datasets."""
|
|
9
|
+
def __init__(self, df: pd.DataFrame, target: str = None):
|
|
10
|
+
self.df = df
|
|
11
|
+
self.target = target
|
|
12
|
+
self.analysis_summary = {}
|
|
13
|
+
self.metadata_summary = {}
|
|
14
|
+
self._feature_overrides = {
|
|
15
|
+
"num_cols": set(),
|
|
16
|
+
"cat_cols": set()
|
|
17
|
+
}
|
|
18
|
+
self.cont_cols = None
|
|
19
|
+
self.ord_cols = None
|
|
20
|
+
self.nom_cols = None
|
|
21
|
+
self.bin_cols = None
|
|
22
|
+
self._analyze()
|
|
23
|
+
|
|
24
|
+
def get_data(self):
|
|
25
|
+
return self.df
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def summary(self):
|
|
29
|
+
return {**self.analysis_summary, **self.metadata_summary}
|
|
30
|
+
|
|
31
|
+
def print_report(self):
|
|
32
|
+
# TODO: add all, metadata, analysis
|
|
33
|
+
for k,v in self.summary.items():
|
|
34
|
+
print(f"{k}: {v}")
|
|
35
|
+
|
|
36
|
+
def plot_histogram(self, x: str, hue=None, kde=None, multiple="layer", shrink=1, title=None, figsize=(6, 4)):
|
|
37
|
+
"""Plot basic histogram.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
x: str
|
|
42
|
+
The name of the column to plot the histogram
|
|
43
|
+
kde: bool, optional
|
|
44
|
+
If True, computes kernel density and shows on the plot
|
|
45
|
+
hue: str, optional
|
|
46
|
+
Vector or key in self.df. Semantic feature to determine the color of plot elements.
|
|
47
|
+
multiple: {"layer", "dodge", "stack", "fill"}
|
|
48
|
+
Approach to resolve multiple elements when semantic mapping creates subsets.
|
|
49
|
+
"""
|
|
50
|
+
if multiple == "dodge": shrink = 0.8
|
|
51
|
+
plt.figure(figsize=figsize)
|
|
52
|
+
sns.histplot(data=self.df, x=x, hue=hue, kde=kde, multiple=multiple, shrink=shrink)
|
|
53
|
+
if not title:
|
|
54
|
+
title = f"Histogram of {x}"
|
|
55
|
+
if hue: title += f" based on {hue}"
|
|
56
|
+
plt.title(title)
|
|
57
|
+
plt.tight_layout()
|
|
58
|
+
plt.show()
|
|
59
|
+
|
|
60
|
+
def plot_scatter(self, x: str, y: str, hue=None, style=None, title=None, x_legend=None, y_legend=None):
|
|
61
|
+
"""Plot basic scatterplot.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
x: str
|
|
66
|
+
The name of the column on the xx axis
|
|
67
|
+
y: str
|
|
68
|
+
The name of the column on the yy axis
|
|
69
|
+
hue: str, optional
|
|
70
|
+
Grouping column to determine the color of plot elements.
|
|
71
|
+
style: str, optional
|
|
72
|
+
Grouping column that will produce different markers.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
plt.figure(figsize=(6, 4))
|
|
76
|
+
palette = "deep" if hue else None
|
|
77
|
+
sns.scatterplot(self.df, x=x, y=y, hue=hue, style=style, palette=palette)
|
|
78
|
+
plt.xlabel(x_legend if x_legend else x)
|
|
79
|
+
plt.ylabel(y_legend if y_legend else y)
|
|
80
|
+
text_title = f"{x} versus {y}"
|
|
81
|
+
if hue: text_title += f" by {hue}"
|
|
82
|
+
plt.title(title if title else f"{x} versus {y}")
|
|
83
|
+
plt.tight_layout()
|
|
84
|
+
plt.show()
|
|
85
|
+
|
|
86
|
+
def plot_pivot_table(self, index: str, columns: str, aggfunc="mean", title:str=None, values:str=None, figsize:tuple=(6,4), plot_percent=False):
|
|
87
|
+
"""Plot heatmap based on pivot table.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
values: str
|
|
92
|
+
Column to aggregate.
|
|
93
|
+
index: str
|
|
94
|
+
Keys to group by on the pivot table.
|
|
95
|
+
columns: str
|
|
96
|
+
Keys to group by on the pivot table.
|
|
97
|
+
aggfunc: {"mean", "max", "min"}
|
|
98
|
+
Function to calculate aggregates.
|
|
99
|
+
"""
|
|
100
|
+
fill_value = 0 if aggfunc == "size" else None
|
|
101
|
+
|
|
102
|
+
pivot_df = self.df.pivot_table(
|
|
103
|
+
values=values,
|
|
104
|
+
index=index,
|
|
105
|
+
columns=columns,
|
|
106
|
+
aggfunc = aggfunc,
|
|
107
|
+
fill_value=fill_value
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if aggfunc == "size" and plot_percent:
|
|
111
|
+
pivot_df = round(pivot_df / pivot_df.values.sum() * 100, 2)
|
|
112
|
+
|
|
113
|
+
plt.figure(figsize=figsize)
|
|
114
|
+
ax = sns.heatmap(pivot_df, annot=True, fmt=".1f", linewidth=.5, cbar_kws={"label": f"{f"{aggfunc}_{values}" if values else aggfunc}"})
|
|
115
|
+
ax.set(xlabel=columns, ylabel=index)
|
|
116
|
+
ax.xaxis.tick_top()
|
|
117
|
+
ax.xaxis.set_label_position('top')
|
|
118
|
+
plt.title(title if title else f"{values if values else aggfunc} by {index} and {columns}")
|
|
119
|
+
plt.show()
|
|
120
|
+
|
|
121
|
+
def plot_mult_histogram(self, col_names: list, hue=None, cols=2, subplot_size=(6, 4)):
|
|
122
|
+
"""Plot multiple histograms in a grid layout.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
col_names: list
|
|
127
|
+
List of column names to plot.
|
|
128
|
+
hue: str, optional
|
|
129
|
+
Column used to group histograms.
|
|
130
|
+
cols: int
|
|
131
|
+
Number of columns in the grid layout.
|
|
132
|
+
figsize: tuple
|
|
133
|
+
Size of the figure.
|
|
134
|
+
"""
|
|
135
|
+
n = len(col_names)
|
|
136
|
+
rows = math.ceil(n/cols)
|
|
137
|
+
|
|
138
|
+
fig_width, fig_height = subplot_size[0] * cols, subplot_size[1] * rows
|
|
139
|
+
|
|
140
|
+
_ , axes = plt.subplots(rows, cols, figsize=(fig_width, fig_height))
|
|
141
|
+
axes = np.atleast_1d(axes)
|
|
142
|
+
axes = axes.flatten()
|
|
143
|
+
|
|
144
|
+
for i, col in enumerate(col_names):
|
|
145
|
+
ax = axes[i]
|
|
146
|
+
if col in self.cat_cols: sns.histplot(self.df, x=col, hue=hue, multiple="dodge", shrink=0.8, ax=ax)
|
|
147
|
+
else: sns.histplot(self.df, x=col, hue=hue, ax=ax)
|
|
148
|
+
title_text = f"{col}"
|
|
149
|
+
if hue: title_text += f" by {hue}"
|
|
150
|
+
ax.set_title(title_text)
|
|
151
|
+
|
|
152
|
+
# Remove extra axis
|
|
153
|
+
for j in range(i + 1, len(axes)):
|
|
154
|
+
axes[j].set_visible(False)
|
|
155
|
+
|
|
156
|
+
plt.tight_layout()
|
|
157
|
+
plt.show()
|
|
158
|
+
|
|
159
|
+
def plot_mult_scatter(self, col_names: list, fixed_col=None, hue=None, style=None, cols=2, figsize=(12, 4)):
|
|
160
|
+
"""Plot all scatter, by hue, using numerical features.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
col_names: list
|
|
165
|
+
List of column names to plot.
|
|
166
|
+
hue: str, optional
|
|
167
|
+
Column used to group histograms.
|
|
168
|
+
cols: int
|
|
169
|
+
Number of columns in the grid layout.
|
|
170
|
+
figsize: tuple
|
|
171
|
+
Size of the figure.
|
|
172
|
+
"""
|
|
173
|
+
if fixed_col:
|
|
174
|
+
combinations = set()
|
|
175
|
+
col_names.remove(fixed_col)
|
|
176
|
+
for i in col_names:
|
|
177
|
+
combinations.add((fixed_col, i))
|
|
178
|
+
else: combinations = DataProfiler.combinatorial_k_2(col_names)
|
|
179
|
+
|
|
180
|
+
n = len(combinations)
|
|
181
|
+
rows = math.ceil(n/cols)
|
|
182
|
+
palette = "deep" if hue else None
|
|
183
|
+
|
|
184
|
+
_ , axes = plt.subplots(rows, cols, figsize=(figsize[0], figsize[1]*rows))
|
|
185
|
+
axes = axes.flatten()
|
|
186
|
+
|
|
187
|
+
for i, combo in enumerate(combinations):
|
|
188
|
+
ax = axes[i]
|
|
189
|
+
if combo[0] in self.num_cols and combo[1] in self.num_cols:
|
|
190
|
+
sns.scatterplot(self.df, x=combo[0], y=combo[1], hue=hue, style=style, palette=palette, ax=ax)
|
|
191
|
+
title_text = f"{combo[0]} versus {combo[1]}"
|
|
192
|
+
if hue: title_text += f" by {hue}"
|
|
193
|
+
ax.set_title(title_text)
|
|
194
|
+
else: raise ValueError("One or more features is not quantitative.") # TODO: raise specific error
|
|
195
|
+
|
|
196
|
+
# Remove extra axis
|
|
197
|
+
for j in range(i + 1, len(axes)):
|
|
198
|
+
axes[j].set_visible(False)
|
|
199
|
+
|
|
200
|
+
plt.tight_layout()
|
|
201
|
+
plt.show()
|
|
202
|
+
|
|
203
|
+
def plot_correlation(self):
|
|
204
|
+
"""Plot correlation matrix for numerical features.
|
|
205
|
+
Consider ordinal and othey types?
|
|
206
|
+
"""
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
def _analyze(self):
|
|
210
|
+
df = self.df
|
|
211
|
+
self.n_samples, self.n_features = df.shape
|
|
212
|
+
|
|
213
|
+
# update num and cat cols (check if they were user-defined as well)
|
|
214
|
+
num_cols = set(df.select_dtypes(include=["int64", "float64"]).columns.tolist())
|
|
215
|
+
cat_cols = set(df.select_dtypes(include=["object", "bool"]).columns.tolist())
|
|
216
|
+
|
|
217
|
+
num_cols.update(self._feature_overrides["num_cols"])
|
|
218
|
+
cat_cols.update(self._feature_overrides["cat_cols"])
|
|
219
|
+
|
|
220
|
+
num_cols.difference_update(self._feature_overrides["cat_cols"])
|
|
221
|
+
cat_cols.difference_update(self._feature_overrides["num_cols"])
|
|
222
|
+
|
|
223
|
+
self.num_cols = sorted(list(num_cols))
|
|
224
|
+
self.cat_cols = sorted(list(cat_cols))
|
|
225
|
+
|
|
226
|
+
self.cols = self.num_cols + self.cat_cols
|
|
227
|
+
if self.target: self.cat_cols.remove(self.target)
|
|
228
|
+
self.mv_cols = df.columns[df.isna().any()].to_list()
|
|
229
|
+
self.mv_percentage = round(df.isna().sum().sum() / df.size * 100, 2)
|
|
230
|
+
self.duplicate_rows = df.duplicated().sum()
|
|
231
|
+
|
|
232
|
+
if self.target and self.target in df.columns:
|
|
233
|
+
self.target_distribution = round(df[self.target].value_counts(normalize=True), 2).to_dict()
|
|
234
|
+
else:
|
|
235
|
+
self.target_distribution = {}
|
|
236
|
+
|
|
237
|
+
self.analysis_summary = {
|
|
238
|
+
"n_samples": self.n_samples,
|
|
239
|
+
"n_features": self.n_features,
|
|
240
|
+
"target": self.target,
|
|
241
|
+
"num_cols": self.num_cols,
|
|
242
|
+
"cat_cols": self.cat_cols,
|
|
243
|
+
"mv_cols": self.mv_cols,
|
|
244
|
+
"mv_percentage": self.mv_percentage,
|
|
245
|
+
"duplicate_rows": self.duplicate_rows,
|
|
246
|
+
"target_distribution": self.target_distribution,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def _refresh(self):
|
|
250
|
+
self._analyze()
|
|
251
|
+
|
|
252
|
+
def reset_feature_type_overrides(self):
|
|
253
|
+
"""Clean all overrides to num and cat columns."""
|
|
254
|
+
self._feature_overrides = {"num_cols": set(), "cat_cols": set()}
|
|
255
|
+
self._refresh()
|
|
256
|
+
|
|
257
|
+
def _set_semantic_feature_types(self, cont_cols=None, ord_cols=None, nom_cols=None, bin_cols=None):
|
|
258
|
+
"""Sets semantic feature types to the profiler.
|
|
259
|
+
If a new setting occurs, it will override the previous.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
feature_roles = {
|
|
263
|
+
"continuous": cont_cols,
|
|
264
|
+
"ordinal": ord_cols,
|
|
265
|
+
"nominal": nom_cols,
|
|
266
|
+
"binary": bin_cols
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for role, cols in feature_roles.items():
|
|
270
|
+
if cols is not None:
|
|
271
|
+
setattr(self, role, cols)
|
|
272
|
+
self.metadata_summary[f"{role}_cols"] = cols
|
|
273
|
+
|
|
274
|
+
def _override_feature_types(self, numeric=None, categorical=None):
|
|
275
|
+
if numeric:
|
|
276
|
+
self._feature_overrides["num_cols"].update(numeric)
|
|
277
|
+
self._feature_overrides["cat_cols"].difference(numeric)
|
|
278
|
+
if categorical:
|
|
279
|
+
self._feature_overrides["cat_cols"].update(categorical)
|
|
280
|
+
self._feature_overrides["num_cols"].difference_update(categorical)
|
|
281
|
+
|
|
282
|
+
def _set_feature_mappings(self, map: dict, inverse_map: dict=None):
|
|
283
|
+
if not hasattr(self, "feature_mappings"):
|
|
284
|
+
self.feature_mappings = {}
|
|
285
|
+
self.feature_mappings.update(map)
|
|
286
|
+
self.metadata_summary["feature_mappings"] = self.feature_mappings
|
|
287
|
+
if inverse_map:
|
|
288
|
+
if not hasattr(self, "inverse_feature_mappings"):
|
|
289
|
+
self.inverse_feature_mappings = {}
|
|
290
|
+
self.inverse_feature_mappings.update(inverse_map)
|
|
291
|
+
self.metadata_summary["inverse_mappings"] = self.inverse_feature_mappings
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def combinatorial_k_2(n_cols: list):
|
|
295
|
+
comb = set()
|
|
296
|
+
for i in n_cols:
|
|
297
|
+
for j in n_cols:
|
|
298
|
+
if i != j and (j, i) not in comb:
|
|
299
|
+
comb.add((i, j))
|
|
300
|
+
return comb
|
|
301
|
+
|
|
302
|
+
# TODO: def save_plot(self, plot_func, *args, **kwargs):
|
|
303
|
+
|
|
304
|
+
# TODO: get_data()?
|
|
305
|
+
|
|
306
|
+
# Given this code, how could I use it to plot all features?
|
|
307
|
+
# How can I determine feature types? And change them?
|
|
308
|
+
# Can I provide alternative maps? map and reverse mapping?
|
|
309
|
+
|
|
310
|
+
class MissingDataProfiler:
|
|
311
|
+
pass
|
|
312
|
+
# TODO: Get basic stats on missing data and visualization
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class DataVisualizer:
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
def load_data(path: str) -> pd.DataFrame:
|
|
4
|
+
df = pd.read_csv(path, na_values="?")
|
|
5
|
+
df.columns = (
|
|
6
|
+
df.columns.str.strip()
|
|
7
|
+
.str.lower()
|
|
8
|
+
.str.replace(" ", "_")
|
|
9
|
+
)
|
|
10
|
+
for col in df.select_dtypes(include=["object", "string"]).columns:
|
|
11
|
+
df[col] = df[col].str.strip().str.lower()
|
|
12
|
+
return df
|