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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ pandas
2
+ scikit-learn
3
+ matplotlib
4
+ seaborn
@@ -0,0 +1,5 @@
1
+ encoders
2
+ fairness
3
+ preprocessing
4
+ profiler
5
+ utils
@@ -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