CaglarAytekin commited on
Commit
b9ba714
·
1 Parent(s): 1e7a8d6

first commit

Browse files
Files changed (11) hide show
  1. Causality_Example.png +0 -0
  2. DATA.py +198 -0
  3. DEMO.py +97 -0
  4. LEURN.py +695 -0
  5. LICENSE +201 -0
  6. Presentation_Product.pdf +0 -0
  7. Presentation_Technical.pdf +0 -0
  8. README.md +27 -13
  9. TRAINER.py +186 -0
  10. app.py +176 -0
  11. requirements.txt +7 -0
Causality_Example.png ADDED
DATA.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ @author: Caglar Aytekin
3
+ contact: caglar@deepcause.ai
4
+ """
5
+
6
+ import numpy as np
7
+ from sklearn.preprocessing import LabelEncoder, MinMaxScaler
8
+ import warnings
9
+ from sklearn.model_selection import train_test_split
10
+ import torch
11
+ import pandas as pd
12
+ pd.set_option('display.max_rows', None) # None means show all rows
13
+ pd.set_option('display.max_columns', None) # None means show all columns
14
+ pd.set_option('display.width', None) # Use appropriate width to display columns
15
+ pd.set_option('display.max_colwidth', None) # Show full content of each column
16
+
17
+ warnings.filterwarnings("ignore")
18
+
19
+ def split_and_processing(X,y,categoricals,output_type,attribute_names):
20
+ #If every entryin a column of a dataframe is None drop it
21
+ columns_to_keep_mask = ~X.isna().all()
22
+ X = X.dropna(axis=1, how='all')
23
+ # Update the categoricals list to reflect the columns not dropped
24
+ categoricals = [cat for cat, keep in zip(categoricals, columns_to_keep_mask) if keep]
25
+ attribute_names= [cat for cat, keep in zip(attribute_names, columns_to_keep_mask) if keep]
26
+
27
+
28
+
29
+ # Split into train and remaining
30
+ X_train, X_remaining, y_train, y_remaining = train_test_split(X, y, test_size=0.2, random_state=42)
31
+
32
+ # Split remaining into validation and test
33
+ X_val, X_test, y_val, y_test = train_test_split(X_remaining, y_remaining, test_size=0.5, random_state=42)
34
+
35
+ # Initialize preprocessor
36
+ preprocessor=DataProcessor(categoricals,output_type)
37
+
38
+ #Fit and transform for training set
39
+ X_train=torch.from_numpy(preprocessor.fit_transform_X(X_train).values).float()
40
+ y_train=torch.from_numpy(preprocessor.fit_transform_y(y_train)).float()
41
+ if output_type<2:
42
+ y_train=y_train.unsqueeze(dim=-1)
43
+ else:
44
+ y_train=y_train.long()
45
+
46
+ #Transform for validation and test set
47
+ X_val=torch.from_numpy(preprocessor.transform_X(X_val).values).float()
48
+ y_val=torch.from_numpy(preprocessor.transform_y(y_val)).float()
49
+ if output_type<2:
50
+ y_val=y_val.unsqueeze(dim=-1)
51
+ else:
52
+ y_val=y_val.long()
53
+
54
+ X_test=torch.from_numpy(preprocessor.transform_X(X_test).values).float()
55
+ y_test=torch.from_numpy(preprocessor.transform_y(y_test)).float()
56
+ if output_type<2:
57
+ y_test=y_test.unsqueeze(dim=-1)
58
+ else:
59
+ y_test=y_test.long()
60
+
61
+ preprocessor.attribute_names=attribute_names
62
+ preprocessor.output_type=output_type
63
+
64
+ #Determine class no
65
+ if output_type==0:
66
+ output_dim=y_train.shape[1]
67
+ elif output_type==1:
68
+ output_dim=1
69
+ else:
70
+ output_dim=len(np.unique(y_train))
71
+
72
+ preprocessor.output_dim=output_dim
73
+ return X_train,X_val,X_test,y_train,y_val,y_test,preprocessor
74
+
75
+
76
+
77
+ class DataProcessor:
78
+ def __init__(self, categoricals, output_type):
79
+ self.categoricals = categoricals
80
+ self.output_type = output_type
81
+ self.label_encoders = {}
82
+ self.scaler = MinMaxScaler(feature_range=(-1, 1))
83
+ self.target_scaler = MinMaxScaler(feature_range=(-1, 1))
84
+ self.most_common_categories = {}
85
+ self.target_encoder = None # For binary and multiclass
86
+ self.unique_targets = None # To store unique targets for binary classification
87
+ self.category_details=[]
88
+ self.suggested_embeddings=None
89
+ self.encoders_for_nn={}
90
+
91
+ def fit_transform_X(self, X):
92
+
93
+
94
+ # Convert all numerical columns to float precision
95
+ X.iloc[:, ~np.array(self.categoricals)] = X.iloc[:, ~np.array(self.categoricals)].astype(float)
96
+ X.iloc[:, np.array(self.categoricals)] = X.iloc[:, np.array(self.categoricals)].astype(str)
97
+
98
+ X_transformed = X.copy()
99
+ for i, is_categorical in enumerate(self.categoricals):
100
+ if is_categorical:
101
+ encoder = LabelEncoder()
102
+ X_transformed.iloc[:, i] = encoder.fit_transform(X.iloc[:, i])
103
+ self.label_encoders[i] = encoder
104
+ self.encoders_for_nn[X_transformed.columns[i]] = dict(zip(encoder.classes_, encoder.transform(encoder.classes_)))
105
+ self.most_common_categories[i] = X.iloc[:, i].mode()[0]
106
+ self.category_details.append((i, len(encoder.classes_)))
107
+ else:
108
+ # Fill missing values with the median for numerical columns
109
+ X_transformed.iloc[:, i] = X.iloc[:, i].fillna(X.iloc[:, i].median())
110
+
111
+ # Scale numerical features
112
+ numerical_features = X_transformed.iloc[:, ~np.array(self.categoricals)]
113
+ if numerical_features.shape[-1]>0:
114
+ self.scaler.fit(numerical_features)
115
+ X_transformed.iloc[:, ~np.array(self.categoricals)] = self.scaler.transform(numerical_features)
116
+ self.suggested_embeddings=[max(2, int(np.log2(x[1]))) for x in self.category_details]
117
+
118
+ return X_transformed.astype(float)
119
+
120
+ def transform_X(self, X):
121
+ X.iloc[:, np.array(self.categoricals)] = X.iloc[:, np.array(self.categoricals)].astype(str)
122
+ X_transformed = X.copy()
123
+ for i, is_categorical in enumerate(self.categoricals):
124
+ if is_categorical:
125
+ encoder = self.label_encoders[i]
126
+ # Transform categories, replace unseen with most common category
127
+ X_transformed.iloc[:, i] = X.iloc[:, i].map(lambda x: x if x in encoder.classes_ else self.most_common_categories[i])
128
+ X_transformed.iloc[:, i] = encoder.transform(X_transformed.iloc[:, i])
129
+ else:
130
+ X_transformed.iloc[:, i] = X.iloc[:, i].fillna(X.iloc[:, i].mean())
131
+
132
+ # Scale numerical features
133
+ numerical_features = X_transformed.iloc[:, ~np.array(self.categoricals)]
134
+ if numerical_features.shape[-1]>0:
135
+ X_transformed.iloc[:, ~np.array(self.categoricals)] = self.scaler.transform(numerical_features)
136
+
137
+ return X_transformed.astype(float)
138
+
139
+
140
+ def inverse_transform_X(self, sample):
141
+ #inverse transform from pytorch tensor
142
+ sample=sample.detach().numpy()
143
+ sample_inverse_transformed = pd.DataFrame(sample.copy())
144
+
145
+ #Handle numerical features
146
+ numerical_features_indices = np.where(~np.array(self.categoricals))[0]
147
+ if len(numerical_features_indices)>0:
148
+ sample_inverse_transformed.iloc[:,numerical_features_indices] = self.scaler.inverse_transform(sample[:,numerical_features_indices])
149
+
150
+
151
+ for i, is_categorical in enumerate(self.categoricals):
152
+ if is_categorical:
153
+ encoder = self.label_encoders[i]
154
+ sample_inverse_transformed.iloc[:, i] = encoder.inverse_transform(sample[:, i].astype('int'))
155
+ sample_inverse_transformed.columns = self.attribute_names
156
+ return sample_inverse_transformed
157
+
158
+
159
+ def fit_transform_y(self, y):
160
+ if self.output_type == 0: # Regression
161
+ y_transformed = self.target_scaler.fit_transform(y.values.reshape(-1, 1)).flatten()
162
+ elif self.output_type == 1: # Binary classification
163
+ self.unique_targets = y.unique()
164
+ mapping = {category: idx for idx, category in enumerate(self.unique_targets)}
165
+ y_transformed = y.map(mapping).astype(int).values
166
+ elif self.output_type == 2: # Multiclass classification
167
+ self.target_encoder = LabelEncoder()
168
+ y_transformed = self.target_encoder.fit_transform(y)
169
+ else:
170
+ raise ValueError("Invalid output type")
171
+ return y_transformed
172
+
173
+ def transform_y(self, y):
174
+ if self.output_type == 0: # Regression
175
+ y_transformed = self.target_scaler.transform(y.values.reshape(-1, 1)).flatten()
176
+ elif self.output_type == 1: # Binary classification
177
+ mapping = {category: idx for idx, category in enumerate(self.unique_targets)}
178
+ y_transformed = y.map(mapping).astype(int).values
179
+ elif self.output_type == 2: # Multiclass classification
180
+ y_transformed = self.target_encoder.transform(y)
181
+ else:
182
+ raise ValueError("Invalid output type")
183
+ return y_transformed
184
+
185
+ def inverse_transform_y(self, nn_output):
186
+ if self.output_type == 0: # Regression
187
+ y_transformed=nn_output.squeeze().detach().numpy()
188
+ return self.target_scaler.inverse_transform(y_transformed.reshape(-1, 1)).flatten()
189
+ elif self.output_type == 1: # Binary classification
190
+ y_transformed=int(np.round(torch.sigmoid(nn_output).squeeze().detach().numpy()))
191
+ inverse_mapping = {idx: category for idx, category in enumerate(self.unique_targets)}
192
+ return inverse_mapping[y_transformed]
193
+ elif self.output_type == 2: # Multiclass classification
194
+ y_transformed=int(np.round(torch.argmax(nn_output).squeeze().detach().numpy()))
195
+ return self.target_encoder.inverse_transform([y_transformed])
196
+ else:
197
+ raise ValueError("Invalid output type")
198
+
DEMO.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ @author: Caglar Aytekin
3
+ contact: caglar@deepcause.ai
4
+ """
5
+ # %% IMPORT
6
+ from LEURN import LEURN
7
+ import torch
8
+ from DATA import split_and_processing
9
+ from TRAINER import Trainer
10
+ import numpy as np
11
+ import openml
12
+
13
+
14
+
15
+ #DEMO FOR CREDIT SCORING DATASET: OPENML ID : 31
16
+ #MORE INFO: https://www.openml.org/search?type=data&sort=runs&id=31&status=active
17
+ #%% Set Neural Network Hyperparameters
18
+ depth=2
19
+ batch_size=1024
20
+ lr=5e-3
21
+ epochs=300
22
+ droprate=0.
23
+ output_type=1 #0: regression, 1: binary classification, 2: multi-class classification
24
+
25
+ #%% Check if CUDA is available and set the device accordingly
26
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
27
+ print("Using device:", device)
28
+
29
+
30
+ #%% Load the dataset
31
+ #Read dataset from openml
32
+ open_ml_dataset_id=1590
33
+ dataset = openml.datasets.get_dataset(open_ml_dataset_id)
34
+ X, y, categoricals, attribute_names = dataset.get_data(target=dataset.default_target_attribute)
35
+ #Alternatively load your own dataset from another source (excel,csv etc)
36
+ #Be mindful that X and y should be dataframes, categoricals is a boolean list indicating categorical features, attribute_names is a list of feature names
37
+
38
+ # %% Process data, save useful statistics
39
+ X_train,X_val,X_test,y_train,y_val,y_test,preprocessor=split_and_processing(X,y,categoricals,output_type,attribute_names)
40
+
41
+ #%% Initialize model, loss function, optimizer, and learning rate scheduler
42
+ model = LEURN(preprocessor, depth=depth,droprate=droprate).to(device)
43
+
44
+
45
+ #%%Train model
46
+ model_trainer=Trainer(model, X_train, X_val, y_train, y_val,lr=lr,batch_size=batch_size,epochs=epochs,problem_type=output_type)
47
+ model_trainer.train()
48
+ #Load best weights
49
+ model.load_state_dict(torch.load('best_model_weights.pth'))
50
+
51
+ #%%Evaluate performance
52
+ perf=model_trainer.evaluate(X_train, y_train)
53
+ perf=model_trainer.evaluate(X_test, y_test)
54
+ perf=model_trainer.evaluate(X_val, y_val)
55
+
56
+ #%%TESTS
57
+ model.eval()
58
+
59
+ #%%Check sample in original format:
60
+ print(preprocessor.inverse_transform_X(X_test[0:1]))
61
+ #%% Explain single example
62
+ Exp_df_test_sample,result,result_original_format=model.explain(X_test[0:1])
63
+ #%% Check results
64
+ print(result,result_original_format)
65
+ #%% Check explanation
66
+ print(Exp_df_test_sample)
67
+ #%% Influences
68
+ effects=model.influence_matrix()
69
+ new_list = [a for c, a in zip(categoricals, attribute_names) if c]+[a for c, a in zip(categoricals, attribute_names) if not(c)]
70
+ torch.argmax(effects,dim=1)
71
+ global_importances=model.global_importance()
72
+ #%% tests
73
+ #model output and sum of contributions should be the same
74
+ print(result,model.output,model(X_test[0:1]),Exp_df_test_sample['Contribution'].values.sum())
75
+
76
+
77
+ #%% GENERATION FROM SAME CATEGORY
78
+ generated_sample_nn_friendly, generated_sample_original_input_format,output=model.generate_from_same_category(X_test[0:1])
79
+ #%%Check sample in original format:
80
+ print(preprocessor.inverse_transform_X(X_test[0:1]))
81
+ print(generated_sample_original_input_format)
82
+ #%% Explain single example
83
+ Exp_df_generated_sample,result,result_original_format=model.explain(generated_sample_nn_friendly)
84
+ print(Exp_df_generated_sample)
85
+ print(Exp_df_test_sample.equals(Exp_df_generated_sample)) #this should be true
86
+
87
+
88
+ #%% GENERATE FROM SCRATCH
89
+ generated_sample_nn_friendly, generated_sample_original_input_format,output=model.generate()
90
+ Exp_df_generated_sample,result,result_original_format=model.explain(generated_sample_nn_friendly)
91
+ print(Exp_df_generated_sample)
92
+ print(result,result_original_format)
93
+
94
+
95
+
96
+
97
+
LEURN.py ADDED
@@ -0,0 +1,695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ @author: Caglar Aytekin
3
+ contact: caglar@deepcause.ai
4
+ """
5
+ import torch
6
+ import torch.nn as nn
7
+ import random
8
+ import numpy as np
9
+ import pandas as pd
10
+ import copy
11
+ class CustomEncodingFunction(torch.autograd.Function):
12
+ @staticmethod
13
+ def forward(ctx, x, tau,alpha):
14
+ ctx.save_for_backward(x, tau)
15
+ # Perform the tanh operation on (x + tau)
16
+ y = torch.tanh(x + tau)
17
+ # The actual forward output : binarized output
18
+ forward_output = alpha * (2 * torch.round((y + 1) / 2) - 1) + (1-alpha)*y
19
+ return forward_output
20
+
21
+ @staticmethod
22
+ def backward(ctx, grad_output):
23
+ x, tau = ctx.saved_tensors
24
+ # Use the derivative of tanh for the backward pass: 1 - tanh^2(x + tau)
25
+ grad_input = grad_output * (1 - torch.tanh(x + tau) ** 2)
26
+ return grad_input, grad_input,None # Assuming tau also requires gradient
27
+
28
+ # Wrapping the custom function in a nn.Module for easier use
29
+ class EncodingLayer(nn.Module):
30
+ def __init__(self):
31
+ super(EncodingLayer, self).__init__()
32
+ def forward(self, x, tau,alpha):
33
+ return CustomEncodingFunction.apply(x, tau,alpha)
34
+
35
+ class LEURN(nn.Module):
36
+ def __init__(self, preprocessor,depth,droprate):
37
+ """
38
+ Initializes the model.
39
+
40
+ Parameters:
41
+ - preprocessor: A class containing useful info about the dataset
42
+ - Including: attribute names, categorical features details, suggested embedding size for each category, output type, output dimension, transformation information
43
+ - depth: Depth of the network
44
+ - droprate: dropout rate
45
+ """
46
+ super(LEURN, self).__init__()
47
+
48
+ #Find categorical indices and category numbers for each
49
+ self.alpha=1.0
50
+ self.preprocessor=preprocessor
51
+ self.attribute_names=preprocessor.attribute_names
52
+ self.label_encoders=preprocessor.encoders_for_nn
53
+ self.categorical_indices = [info[0] for info in preprocessor.category_details]
54
+ self.num_categories = [info[1] for info in preprocessor.category_details]
55
+
56
+ #If embedding_size is integer, cast it to all categories
57
+ if isinstance(preprocessor.suggested_embeddings, int):
58
+ embedding_sizes = [preprocessor.suggested_embeddings] * len(self.categorical_indices)
59
+ else:
60
+ assert len(preprocessor.suggested_embeddings) == len(self.categorical_indices), "Length of embedding_size must match number of categorical features"
61
+ embedding_sizes = preprocessor.suggested_embeddings
62
+
63
+ self.embedding_sizes=embedding_sizes
64
+
65
+ #Embedding layers for categorical features
66
+ self.embeddings = nn.ModuleList([
67
+ nn.Embedding(num_categories, embedding_dim)
68
+ for num_categories, embedding_dim in zip(self.num_categories, embedding_sizes)
69
+ ])
70
+
71
+ for embedding_now in self.embeddings:
72
+ nn.init.uniform_(embedding_now.weight, -1.0, 1.0)
73
+
74
+ self.total_embedding_size = sum(embedding_sizes) #number of categorical features for NN
75
+ self.non_cat_input_dim = len(self.attribute_names) - len(self.categorical_indices) #Number of numerical features for NN
76
+ self.nn_input_dim = self.total_embedding_size + self.non_cat_input_dim #Number of features for NN
77
+
78
+
79
+ #LAYERS
80
+
81
+ self.tau_initial = nn.Parameter(torch.zeros(1,self.nn_input_dim)) # Initial tau as a learnable parameter
82
+ self.layers = nn.ModuleList()
83
+ self.depth = depth
84
+ self.output_type=preprocessor.output_type
85
+
86
+ for d_now in range(depth):
87
+ # Each iteration adds an encoding layer followed by a dropout and then a linear layer
88
+ self.layers.append(EncodingLayer())
89
+ self.layers.append(nn.Dropout1d(droprate))
90
+ linear_layer = nn.Linear((d_now + 1) * self.nn_input_dim, self.nn_input_dim)
91
+ self._init_weights(linear_layer,d_now+1) #special layer initialization
92
+ self.layers.append(linear_layer)
93
+
94
+
95
+ # Final stage: dropout and linear layer
96
+ self.final_dropout=nn.Dropout1d(droprate)
97
+ self.final_linear = nn.Linear(depth * self.nn_input_dim, self.preprocessor.output_dim)
98
+ self._init_weights(self.final_linear, depth)
99
+
100
+ def set_alpha(self, alpha):
101
+ """Method to update the dynamic parameter."""
102
+ self.alpha = alpha
103
+
104
+ def _init_weights(self, layer,depth_now):
105
+ # Custom initialization
106
+ # Considering the binary (-1,1) nature of the input,
107
+ # when we initialize layer in (-1/dim,1/dim) range, output is bounded at (-1,1)
108
+ # Knowing our input is roughly at (-1,1) range, this serves as good initialization for tau
109
+
110
+ if not(self.embedding_sizes==[]):
111
+ init_tensor = torch.tensor([1/size for size in self.embedding_sizes for _ in range(size)])
112
+ if init_tensor.shape[0]<self.nn_input_dim: #Means we have numericals too
113
+ init_tensor=torch.cat((init_tensor, torch.ones(self.non_cat_input_dim)), dim=0)
114
+ else:
115
+ init_tensor = torch.ones(self.non_cat_input_dim)
116
+
117
+ init_tensor=init_tensor/((depth_now+1)*torch.tensor(len(self.attribute_names)))
118
+ init_tensor=init_tensor.unsqueeze(0).repeat_interleave(repeats=layer.weight.shape[0],dim=0).repeat_interleave(repeats=depth_now,dim=1)
119
+ layer.weight.data.uniform_(-1, 1)
120
+ layer.weight=torch.nn.Parameter(layer.weight*init_tensor)
121
+
122
+
123
+ def forward(self, x):
124
+ # Defines forward function for provided input: Normalizes numericals, embeds categoricals, and gives to neural network.
125
+
126
+
127
+ # Separate categorical and numerical features for easier handling
128
+ cat_features = [x[:, i].long() for i in self.categorical_indices]
129
+ non_cat_features = [x[:, i] for i in range(x.size(1)) if i not in self.categorical_indices]
130
+ non_cat_features = torch.stack(non_cat_features, dim=1) if non_cat_features else x.new_empty(x.size(0), 0)
131
+
132
+ # Embed categoricals
133
+ embedded_features = [embedding(cat_feature) for embedding, cat_feature in zip(self.embeddings, cat_features)]
134
+ # Combine categoricals and numericals
135
+ try:
136
+ embedded_features = torch.cat(embedded_features, dim=1)
137
+ nninput = torch.cat([embedded_features, non_cat_features], dim=1)
138
+ except:
139
+ nninput=non_cat_features
140
+
141
+ self.nninput=nninput
142
+
143
+ # Forward pass neural network
144
+ output=self.forward_from_embeddings(self.nninput)
145
+ self.output=output
146
+ return output
147
+
148
+ def forward_from_embeddings(self,x):
149
+ # Forward function for normalized numericals and embedded categoricals
150
+ tau=self.tau_initial
151
+ tau=torch.repeat_interleave(tau,x.shape[0],0) #tau is 1xF, cast it for batch
152
+ # For each depth
153
+ for i in range(0, self.depth * 3, 3):
154
+ # encode, drop and find next tau
155
+ encoding_layer = self.layers[i]
156
+ dropout_layer = self.layers[i + 1]
157
+ linear_layer = self.layers[i + 2]
158
+ #encode and drop
159
+ encoded_x =dropout_layer( encoding_layer(x, tau,self.alpha))
160
+ #save encodings and thresholds
161
+ #notice that threshold is -tau, not tau since we binarize x+tau
162
+ if i==0:
163
+ encodings=encoded_x
164
+ taus=-tau
165
+ else:
166
+ encodings=torch.cat((encodings,encoded_x),dim=-1)
167
+ taus=torch.cat((taus,-tau),dim=-1)
168
+ #find next thresholds
169
+ tau = linear_layer(encodings) #not used, redundant for last layer
170
+
171
+ self.encodings=encodings
172
+ self.taus=taus
173
+ #Final layer: drop and linear
174
+ output=self.final_linear(self.final_dropout(encodings))
175
+
176
+ return output
177
+
178
+
179
+ def find_boundaries(self, x):
180
+ """
181
+ Given input, find boundaries for numerical features and valid categories for categorical features
182
+ Can accept unnormalized and not embedded input - set embedding False
183
+ """
184
+ # Ensure x is the correct shape [1, input_dim]
185
+ if x.ndim == 1:
186
+ x = x.unsqueeze(0) # Add batch dimension if not present
187
+
188
+ # Perform a forward pass to update self.encodings and self.taus
189
+ # to update self.taus
190
+
191
+ self(x)
192
+
193
+ # self.taus has the shape [1, depth * input_dim]
194
+ # reshape to [depth, input_dim] for easier boundary finding
195
+ taus_reshaped = self.taus.view(self.depth, self.nn_input_dim)
196
+
197
+ # embedded and normalized input
198
+ embedded_x=self.nninput
199
+
200
+ # Initialize boundaries - numericals are in (-1,1) range and categoricals are from embeddings.
201
+ # So -100,100 is safe min and max. -inf,+inf is not chosen since problematic for later sampling
202
+ upper_boundaries = torch.full((embedded_x.size(1),), 100.0)
203
+ lower_boundaries = torch.full((embedded_x.size(1),), -100.0)
204
+
205
+ # Compare each threshold in self.taus with the corresponding input value
206
+ for feature_index in range(self.nn_input_dim):
207
+ for depth_index in range(self.depth):
208
+ threshold = taus_reshaped[depth_index, feature_index]
209
+ input_value = embedded_x[0, feature_index]
210
+
211
+ # If the threshold is greater than the input value and less than the current upper boundary, update the upper boundary
212
+ if threshold > input_value and threshold < upper_boundaries[feature_index]:
213
+ upper_boundaries[feature_index] = threshold
214
+
215
+ # If the threshold is less than the input value and greater than the current lower boundary, update the lower boundary
216
+ if threshold < input_value and threshold > lower_boundaries[feature_index]:
217
+ lower_boundaries[feature_index] = threshold
218
+
219
+ # Convert boundaries to a list of tuples [(lower, upper), ...] for each feature
220
+ boundaries = list(zip(lower_boundaries.tolist(), upper_boundaries.tolist()))
221
+
222
+
223
+ self.upper_boundaries=upper_boundaries
224
+ self.lower_boundaries=lower_boundaries
225
+
226
+
227
+ return boundaries
228
+
229
+ def categories_within_boundaries(self):
230
+ """
231
+ For each categorical feature, checks if embedding weights fall within the specified upper and lower boundaries.
232
+ Returns a dictionary with categorical feature indices as keys and lists of category indices that fall within the boundaries.
233
+ """
234
+ categories_within_bounds = {}
235
+ emb_st=0
236
+ for cat_index, emb_layer in zip(range(len(self.categorical_indices)), self.embeddings):
237
+ # Extract upper and lower boundaries for this categorical feature
238
+ lower_bound=self.lower_boundaries[emb_st:emb_st+self.embedding_sizes[cat_index]]
239
+ upper_bound=self.upper_boundaries[emb_st:emb_st+self.embedding_sizes[cat_index]]
240
+ emb_st=emb_st+self.embedding_sizes[cat_index]
241
+ # Initialize list to hold categories that fall within boundaries
242
+ categories_within = []
243
+
244
+ # Iterate over each embedding vector in the layer
245
+ for i, weight in enumerate(emb_layer.weight):
246
+ # Check if the embedding weight falls within the boundaries
247
+ if torch.all(weight >= lower_bound) and torch.all(weight <= upper_bound):
248
+ categories_within.append(i) # Using index i as category identifier
249
+
250
+ # Store the categories that fall within the boundaries for this feature
251
+ categories_within_bounds[cat_index] = categories_within
252
+
253
+ return categories_within_bounds
254
+
255
+ def global_importance(self):
256
+ final_layer_weight=torch.clone(self.final_linear.weight).detach().numpy()
257
+ importances=np.sum(np.abs(final_layer_weight),0)
258
+ importances=importances.reshape(importances.shape[0]//self.nn_input_dim,self.nn_input_dim)
259
+ importances=np.sum(importances,0)
260
+ importances_features=[]
261
+ st=0
262
+ for i in range(len(self.attribute_names)):
263
+ try:
264
+ importances_features.append(np.sum(importances[st:st+self.embedding_sizes[i]]))
265
+ st=st+self.embedding_sizes[i]
266
+ except:
267
+
268
+ st=st+1
269
+ return np.argsort(importances_features)[::-1],np.sort(importances_features)[::-1]
270
+
271
+ def influence_matrix(self):
272
+ """
273
+ Finds ADG from how each feature effects other's threshold via weight matrices
274
+ """
275
+
276
+ def create_block_sum_matrix(sizes, matrix):
277
+ L = len(sizes)
278
+ # Initialize the output matrix with zeros, using PyTorch
279
+ block_sum_matrix = torch.zeros((L, L))
280
+
281
+ # Define the starting row and column indices for slicing
282
+ start_row = 0
283
+ for i, row_size in enumerate(sizes):
284
+ start_col = 0
285
+ for j, col_size in enumerate(sizes):
286
+ # Calculate the sum of the current block using PyTorch
287
+ block_sum = torch.sum(matrix[start_row:start_row+row_size, start_col:start_col+col_size])
288
+ block_sum_matrix[i, j] = block_sum
289
+ # Update the starting column index for the next block in the row
290
+ start_col += col_size
291
+ # Update the starting row index for the next block in the column
292
+ start_row += row_size
293
+
294
+ return block_sum_matrix
295
+
296
+ def add_ones_until_target(initial_list, target_sum):
297
+ # Continue adding 1s until the sum of the list equals the target sum
298
+ while sum(initial_list) < target_sum:
299
+ initial_list.append(1)
300
+ return initial_list
301
+
302
+ for i in range(0, self.depth * 3, 3):
303
+ # encode, drop and find next tau
304
+ weight_now=self.layers[i + 2].weight
305
+ weight_now_reshaped=weight_now.reshape((weight_now.shape[0], weight_now.shape[1]//self.nn_input_dim,self.nn_input_dim)) #shape: output x depth x input
306
+ if i==0:
307
+ # effects=np.sum(np.abs(weight_now_reshaped.numpy()),axis=1)/self.depth #shape: output x input
308
+ effects=torch.sum(torch.abs(weight_now_reshaped), dim=1) / self.depth
309
+ else:
310
+ effects=effects+torch.sum(torch.abs(weight_now_reshaped), dim=1) / self.depth
311
+
312
+ effects=effects.t() #shape: input x output
313
+
314
+ modified_list = add_ones_until_target(copy.deepcopy(self.embedding_sizes), effects.shape[0])
315
+
316
+
317
+ effects=create_block_sum_matrix(modified_list,effects)
318
+
319
+ return effects
320
+
321
+
322
+ def explain_without_causal_effects(self,x):
323
+ """
324
+ Explains decisions of the neural network for input sample.
325
+ For numericals, extracts upper and lower boundaries on the sample
326
+ For categoricals displays possible categories
327
+ Also calculates contributions of each feature to final result
328
+ """
329
+ self.find_boundaries(x) #find upper, lower boundaries for all nn inputs
330
+
331
+ #find valid categories for categorical features
332
+ valid_categories=self.categories_within_boundaries()
333
+
334
+ #numerical boundaries
335
+ upper_numerical=self.upper_boundaries[sum(self.embedding_sizes):].detach().numpy()
336
+ lower_numerical=self.lower_boundaries[sum(self.embedding_sizes):].detach().numpy()
337
+
338
+ #Find contribution from each feature in final linear layer, distribute bias evenly
339
+ contributions=self.encodings * self.final_linear.weight + self.final_linear.bias.unsqueeze(dim=-1)/self.final_linear.weight.shape[1]
340
+ contributions=contributions.detach().resize_((contributions.shape[0], contributions.shape[1]//self.nn_input_dim,self.nn_input_dim))
341
+ contributions=torch.sum(contributions,dim=1)
342
+
343
+ # Initialize an empty list to store the summed contributions
344
+ summed_contributions = []
345
+
346
+ # Initialize start index for slicing
347
+ start_idx = 0
348
+
349
+ #Sum contribution of each categorical within respective embedding
350
+ for size in self.embedding_sizes:
351
+ # Calculate end index for the current chunk
352
+ end_idx = start_idx + size
353
+
354
+ # Sum the contributions in the current chunk
355
+ chunk_sum = contributions[:, start_idx:end_idx].sum(dim=1, keepdim=True)
356
+
357
+ # Append the summed chunk to the list
358
+ summed_contributions.append(chunk_sum)
359
+
360
+ # Update the start index for the next chunk
361
+ start_idx = end_idx
362
+
363
+ # If there are remaining elements not covered by embedding_sizes, add them as is (numerical features)
364
+ if start_idx < contributions.shape[1]:
365
+ remaining = contributions[:, start_idx:]
366
+ summed_contributions.append(remaining)
367
+
368
+ # Concatenate the summed contributions back into a tensor
369
+ summed_contributions = torch.cat(summed_contributions, dim=1)
370
+ # This is to handle multi-class explanations, for binary this is 0 automatically
371
+ # Note: multi-output regression is not supported yet. This will just return largest regressed value's explanations
372
+ highest_index=torch.argmax(summed_contributions.sum(dim=1))
373
+ # This is contribution from each feature
374
+ result=summed_contributions[highest_index]
375
+ self.result=result
376
+
377
+ #Explanation and Contribution formats are in ordered format (categoricals first, numericals later)
378
+ #Bring them to original format in user input
379
+ #Combine categoricals and numericals explanations and contributions
380
+ Explanation = [None] * (len(self.categorical_indices) + len(upper_numerical))
381
+ Contribution = np.zeros((len(self.categorical_indices) + len(upper_numerical),))
382
+
383
+ # Fill in the categorical samples
384
+ for j, cat_index in enumerate(self.categorical_indices):
385
+ Explanation[cat_index] = valid_categories[j]
386
+ Contribution[cat_index] = result[j].numpy()
387
+
388
+
389
+ #INVERSE TRANSFORM PART 1-------------------------------------------------------------------------------------------
390
+ #Inverse transform upper and lower_numericals
391
+ len_num=len(upper_numerical)
392
+ if len_num>0:
393
+ upper_numerical=self.preprocessor.scaler.inverse_transform(upper_numerical.reshape(1,-1))
394
+ lower_numerical=self.preprocessor.scaler.inverse_transform(lower_numerical.reshape(1,-1))
395
+ if len_num>1:
396
+ upper_numerical=np.squeeze(upper_numerical)
397
+ lower_numerical=np.squeeze(lower_numerical)
398
+ upper_iter = iter(upper_numerical)
399
+ lower_iter = iter(lower_numerical)
400
+
401
+
402
+ cnt=0
403
+ for i in range(len(Explanation)):
404
+ if Explanation[i] is None:
405
+ #Note the denormalization here
406
+ Explanation[i] = next(lower_iter),next(upper_iter)
407
+ if len(self.categorical_indices)>0:
408
+ Contribution[i] = result[j+cnt+1].numpy()
409
+ else:
410
+ Contribution[i] = result[cnt].numpy()
411
+ cnt=cnt+1
412
+
413
+ attribute_names_list = []
414
+ revised_explanations_list = []
415
+ contributions_list = []
416
+ # Process each feature to fill lists
417
+
418
+ for idx, attr_name in enumerate(self.attribute_names):
419
+ if isinstance(Explanation[idx], list): # Categorical
420
+ #INVERSE TRANSFORM PART 2-------------------------------------------------------------------------------------------
421
+ #Inverse transform categoricals
422
+ category_names = [key for key, value in self.label_encoders[attr_name].items() if value in Explanation[idx]]
423
+ revised_explanation = " ,OR, ".join(category_names)
424
+ elif isinstance(Explanation[idx], tuple): # Numerical
425
+ revised_explanation = f"{Explanation[idx][0].item()} to {Explanation[idx][1].item()}"
426
+ else:
427
+ revised_explanation = "Unknown" #shouldn't really happen
428
+
429
+ # Append to lists
430
+ attribute_names_list.append(attr_name)
431
+ revised_explanations_list.append(revised_explanation)
432
+ contributions_list.append(Contribution[idx] if idx < len(Contribution) else None)
433
+
434
+
435
+
436
+ # Construct DataFrame
437
+ Explanation_df = pd.DataFrame({
438
+ 'Name': attribute_names_list,
439
+ 'Category': revised_explanations_list,
440
+ 'Contribution': contributions_list
441
+ })
442
+
443
+
444
+
445
+
446
+ result=self.preprocessor.inverse_transform_y(self.output)
447
+ # Explanation_df['Result'] = [result] * len(Explanation_df)
448
+
449
+ return copy.deepcopy(Explanation_df),self.output.clone(),copy.deepcopy(result),copy.deepcopy(Explanation)
450
+
451
+ def explain(self,x,include_causal_analysis=False):
452
+ """
453
+ Fixes all features but one, sweeps that feature across its own categories, reports the average change from other categories to current one.
454
+ """
455
+
456
+ def update_intervals(available_intervals, incoming_interval):
457
+ updated_intervals = []
458
+ for interval in available_intervals:
459
+ if incoming_interval[1] <= interval[0] or incoming_interval[0] >= interval[1]:
460
+ # The incoming interval does not overlap, keep the interval as is
461
+ updated_intervals.append(interval)
462
+ else:
463
+ # There is some overlap, possibly split the interval
464
+ if incoming_interval[0] > interval[0]:
465
+ # Add the left part that doesn't overlap
466
+ updated_intervals.append((interval[0], incoming_interval[0]))
467
+ if incoming_interval[1] < interval[1]:
468
+ # Add the right part that doesn't overlap
469
+ updated_intervals.append((incoming_interval[1], interval[1]))
470
+ return updated_intervals
471
+
472
+ def sample_from_intervals(available_intervals):
473
+ if not available_intervals:
474
+ return None
475
+ # Choose a random interval
476
+ chosen_interval = random.choice(available_intervals)
477
+ # Sample a random point within this interval
478
+ return random.uniform(chosen_interval[0], chosen_interval[1])
479
+
480
+
481
+
482
+
483
+ Explanation_df,output,result,Explanation=self.explain_without_causal_effects(x)
484
+ if include_causal_analysis:
485
+ # Causal analysis
486
+ causal_effect=np.zeros((x.shape[-1],))
487
+ numerical_cnt=0
488
+ for idx, attr_name in enumerate(self.attribute_names):
489
+ if isinstance(Explanation[idx], list): # Categorical
490
+ all_category_names = [value for key, value in self.label_encoders[attr_name].items()]
491
+ sweeped_category_names = [value for key, value in self.label_encoders[attr_name].items() if value in Explanation[idx]]
492
+
493
+ if list(set(all_category_names)-set(sweeped_category_names)) == []:
494
+ is_category_empty=True
495
+ else:
496
+ is_category_empty=False
497
+
498
+ cnt=0
499
+ while is_category_empty==False:
500
+ new_x=x.clone()
501
+ next_category=list(set(all_category_names)-set(sweeped_category_names))[0]
502
+ new_x[0,idx]=float(next_category)
503
+ Explanation_df_new,output_new,result_new,Explanation_new=self.explain_without_causal_effects(new_x)
504
+ sweeped_category_names = sweeped_category_names+[value for key, value in self.label_encoders[attr_name].items() if value in Explanation_new[idx]]
505
+
506
+ if list(set(all_category_names)-set(sweeped_category_names)) == []:
507
+ is_category_empty=True
508
+ else:
509
+ is_category_empty=False
510
+
511
+ causal_effect[idx]=causal_effect[idx]+(output-output_new).detach().numpy()[0,0]
512
+ cnt=cnt+1
513
+ if cnt>0:
514
+ causal_effect[idx]=causal_effect[idx]/cnt
515
+
516
+ else:
517
+
518
+ search_complete=False
519
+ # Initial available interval . we know -100,100 from initial setting up lower, upper bounds
520
+ available_intervals = [(-100, 100)]
521
+
522
+ # Example incoming intervals
523
+ #numerical boundaries
524
+ self.explain_without_causal_effects(x)
525
+ upper_numerical=self.upper_boundaries[sum(self.embedding_sizes):].detach().numpy()
526
+ lower_numerical=self.lower_boundaries[sum(self.embedding_sizes):].detach().numpy()
527
+ incoming_interval = (lower_numerical[numerical_cnt],upper_numerical[numerical_cnt])
528
+ available_intervals = update_intervals(available_intervals, incoming_interval)
529
+ cnt=0
530
+ while not(search_complete):
531
+ new_sample=sample_from_intervals(available_intervals)
532
+ new_x=x.clone()
533
+ new_x[0,idx]=new_sample
534
+ Explanation_df_new,output_new,result_new,Explanation_new=self.explain_without_causal_effects(new_x)
535
+ causal_effect[idx]=causal_effect[idx]+(output-output_new).detach().numpy()[0,0]
536
+ cnt=cnt+1
537
+ upper_numerical=self.upper_boundaries[sum(self.embedding_sizes):].detach().numpy()
538
+ lower_numerical=self.lower_boundaries[sum(self.embedding_sizes):].detach().numpy()
539
+ incoming_interval = (lower_numerical[numerical_cnt],upper_numerical[numerical_cnt])
540
+ available_intervals = update_intervals(available_intervals, incoming_interval)
541
+ if available_intervals == []:
542
+ search_complete=True
543
+ if cnt>0:
544
+ causal_effect[idx]=causal_effect[idx]/cnt
545
+ numerical_cnt=numerical_cnt+1
546
+
547
+
548
+
549
+ Explanation_df['Causal Effects'] = causal_effect
550
+ return Explanation_df,output,result
551
+
552
+
553
+
554
+ def sample_from_boundaries(self):
555
+ """
556
+ Assumes higher and lower boundaries are already extracted (eg self.explain is run on one input already)
557
+ Samples a value for each feature within the specified upper and lower boundaries stored in the class instance.
558
+ For numericals, samples a value, for categoricals samples a category from possible categories
559
+ Returns:
560
+ - A tensor containing sampled values within the given boundaries for each feature.
561
+ """
562
+ #First sample from categories
563
+ categories_within_bounds=self.categories_within_boundaries()
564
+ try:
565
+ sampled_indices = [random.choice(categories) for categories in categories_within_bounds.values()]
566
+ except:
567
+ categories_within_bounds=self.categories_within_boundaries()
568
+
569
+ #Then from numericals
570
+ samples = []
571
+ cnt=0
572
+ for lower, upper in zip(self.lower_boundaries[sum(self.embedding_sizes):], self.upper_boundaries[sum(self.embedding_sizes):]):
573
+ # Sample from a uniform distribution between lower and upper boundaries
574
+ sample = lower + (upper - lower) * torch.rand(1)
575
+ samples.append(sample)
576
+ cnt=cnt+1
577
+
578
+
579
+ #Combine categoricals and numericals
580
+ # Initialize an empty list to hold the combined samples
581
+ combined_samples = [None] * (len(self.categorical_indices) + len(samples))
582
+
583
+ # Fill in the categorical samples
584
+ for i, cat_index in enumerate(self.categorical_indices):
585
+ combined_samples[cat_index] = torch.tensor([sampled_indices[i]], dtype=torch.float)
586
+
587
+ # Fill in the numerical samples
588
+ num_samples_iter = iter(samples)
589
+ for i in range(len(combined_samples)):
590
+ if combined_samples[i] is None:
591
+ combined_samples[i] = next(num_samples_iter)
592
+
593
+ # Combine into a single tensor
594
+ combined_tensor = torch.cat(combined_samples, dim=-1)
595
+ return combined_tensor.unsqueeze(dim=0)
596
+
597
+
598
+ def generate(self):
599
+ """
600
+ Generates a data sample from learned network
601
+ """
602
+ def sample_with_tau(tau,max_bound,min_bound):
603
+ # Sample according to tau, lower and upper bounds
604
+ sampled=torch.zeros((self.nn_input_dim))
605
+ st=0
606
+ # Randomly pick from valid categories
607
+ for embedding in self.embeddings:
608
+ categories_within = []
609
+
610
+ # Iterate over each embedding vector in the layer
611
+ for i, weight in enumerate(embedding.weight):
612
+ # Check if the embedding weight falls within the boundaries
613
+ if torch.all(weight >= min_bound[st:st+embedding.weight.shape[-1]]) and torch.all(weight <= max_bound[st:st+embedding.weight.shape[-1]]):
614
+ categories_within.append(i) # Using index i as category identifier
615
+ feature_now=embedding.weight[np.random.choice(categories_within),:]
616
+ cnt=0
617
+ for j in range(st,st+embedding.weight.shape[-1]):
618
+ if feature_now[cnt]>-tau[0,j]:
619
+ sampled[j]=1.0
620
+ elif feature_now[cnt]<=-tau[0,j]:
621
+ sampled[j]=-1.0
622
+ cnt=cnt+1
623
+ st=st+embedding.weight.shape[-1]
624
+
625
+ #Randomly sample for numericals
626
+ for i in range(st,self.nn_input_dim):
627
+ if -tau[0,i]>max_bound[i]: #In this case you have to pick -1
628
+ sampled[i]=-1.0
629
+ elif -tau[0,i]<=min_bound[i]: #In this case you have to pick 1
630
+ sampled[i]=1.0
631
+ else:
632
+ sampled[i] = (torch.randint(low=0, high=2, size=(1,)) * 2 - 1).float()
633
+ return sampled
634
+
635
+ def bound_update(tau,max_bound,min_bound,sampled):
636
+ for i in range(self.nn_input_dim):
637
+ if sampled[i]>0: #means input is larger than -tau, so -tau might set a lower bound
638
+ if -tau[0,i]>min_bound[i]:
639
+ min_bound[i]=-tau[0,i]
640
+ elif sampled[i]<=0: #means input is smaller than -tau, so -tau might set an upper bound
641
+ if -tau[0,i]<max_bound[i]:
642
+ max_bound[i]=-tau[0,i]
643
+ return max_bound,min_bound
644
+
645
+ # Read first tau
646
+ tau=self.tau_initial
647
+
648
+ # Set initial maximum and minimum bounds
649
+ max_bound=torch.zeros((self.nn_input_dim))+100.0
650
+ min_bound=torch.zeros((self.nn_input_dim))-100.0
651
+
652
+
653
+ for i in range(0, self.depth * 3, 3):
654
+ encoding_layer = self.layers[i] #NOT USED HERE, WE ENCODE RANDOMLY MANUALLY
655
+ dropout_layer = self.layers[i + 1]
656
+ linear_layer = self.layers[i + 2]
657
+ #Sample with current tau
658
+ sample_now=sample_with_tau(tau,max_bound,min_bound)
659
+ #Update bounds with new sample
660
+ max_bound,min_bound=bound_update(tau,max_bound,min_bound,sample_now)
661
+ encoded_x = dropout_layer(sample_now.unsqueeze(dim=0))
662
+ if i==0:
663
+ encodings=encoded_x
664
+ taus=-tau
665
+ else:
666
+ encodings=torch.cat((encodings,encoded_x),dim=-1)
667
+ taus=torch.cat((taus,-tau),dim=-1)
668
+
669
+ tau = linear_layer(encodings) #not used for last layer
670
+
671
+
672
+ self.encodings=encodings
673
+ self.taus=taus
674
+ self.upper_boundaries=torch.clone(max_bound)
675
+ self.lower_boundaries=torch.clone(min_bound)
676
+
677
+ generated_sample=self.sample_from_boundaries()
678
+ ##Check if manually found and network generated boundaries are same
679
+ # if torch.equal(self.upper_boundaries,max_bound) and torch.equal(self.lower_boundaries,min_bound):
680
+ # print(True)
681
+
682
+ self.explain_without_causal_effects(generated_sample)
683
+ generated_sample_original_format=self.preprocessor.inverse_transform_X(generated_sample)
684
+ result=self.preprocessor.inverse_transform_y(self.output)
685
+
686
+ return generated_sample,generated_sample_original_format,result
687
+
688
+ def generate_from_same_category(self,x):
689
+ self.explain_without_causal_effects(x)
690
+ generated_sample=self.sample_from_boundaries()
691
+ generated_sample_original_format=self.preprocessor.inverse_transform_X(generated_sample)
692
+ result=self.preprocessor.inverse_transform_y(self.output)
693
+ return generated_sample,generated_sample_original_format,result
694
+
695
+
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
Presentation_Product.pdf ADDED
Binary file (648 kB). View file
 
Presentation_Technical.pdf ADDED
Binary file (297 kB). View file
 
README.md CHANGED
@@ -1,13 +1,27 @@
1
- ---
2
- title: LEURN
3
- emoji: 🚀
4
- colorFrom: blue
5
- colorTo: green
6
- sdk: streamlit
7
- sdk_version: 1.32.2
8
- app_file: app.py
9
- pinned: false
10
- license: apache-2.0
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LEURN
2
+ Official Repository for LEURN: Learning Explainable Univariate Rules with Neural Networks
3
+ https://arxiv.org/abs/2303.14937
4
+
5
+ Detailed information about LEURN is given in the presentations.
6
+ A demo is provided for training, making local explanations and data generation in DEMO.py
7
+
8
+ NEW! Streamlit demo is now available
9
+ Just activate the environment and run the following in your command line.
10
+ streamlit run UI.py
11
+ Make sure you check the explanation video at:
12
+ https://www.linkedin.com/posts/caglaraytekin_ai-machinelearning-dataanalysis-activity-7172866316691869697-5-nB?utm_source=share&utm_medium=member_desktop
13
+
14
+ NEW! LEURN now includes Causal Effects
15
+ Thanks to its unique design, LEURN can make controlled experiments at lightning speed, discovering average causal effects.
16
+ ![plot](./Causality_Example.png)
17
+
18
+ Main difference of this implementation from the paper:
19
+ - LEURN is now much simpler and uses binarized tanh (k=1 always) with no degradation in performance.
20
+
21
+ Notes:
22
+ - For top performance, a thorough hyperparameter search as described in paper is needed.
23
+ - Human-in-the-loop continuous training is not implemented in this repository.
24
+ - Deepcause provides consultancy services to make the most out of LEURN
25
+
26
+ Contact:
27
+ caglar@deepcause.ai
TRAINER.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ @author: Caglar Aytekin
3
+ contact: caglar@deepcause.ai
4
+ """
5
+ import torch
6
+ import torch.nn as nn
7
+ from torch.utils.data import DataLoader, TensorDataset
8
+ from sklearn.metrics import accuracy_score as accuracy
9
+ from sklearn.metrics import roc_auc_score
10
+ from torch.optim.lr_scheduler import StepLR
11
+ import numpy as np
12
+ import copy
13
+ class Trainer:
14
+ def __init__(self, model, X_train, X_val, y_train, y_val,lr,batch_size,epochs,problem_type,verbose=True):
15
+ self.model = model
16
+ self.optimizer = torch.optim.Adam(model.parameters(), lr=lr)
17
+ self.problem_type=problem_type
18
+ self.verbose=verbose
19
+ if self.problem_type==0:
20
+ self.criterion = nn.MSELoss()
21
+ elif self.problem_type==1:
22
+ self.criterion = nn.BCEWithLogitsLoss()
23
+ elif self.problem_type==2:
24
+ self.criterion = nn.CrossEntropyLoss()
25
+ y_train=y_train.squeeze().long()
26
+ y_val=y_val.squeeze().long()
27
+
28
+
29
+ train_dataset = TensorDataset(X_train, y_train)
30
+ val_dataset = TensorDataset(X_val, y_val)
31
+ self.train_loader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True)
32
+ self.val_loader = DataLoader(dataset=val_dataset, batch_size=len(val_dataset), shuffle=False)
33
+ self.batch_size=batch_size
34
+ self.epochs=epochs
35
+ self.best_metric = float('inf') if problem_type == 0 else float('-inf')
36
+ self.scheduler = StepLR(self.optimizer, step_size=epochs//3, gamma=0.2)
37
+
38
+ def train_epoch(self):
39
+ self.model.train()
40
+ total_loss = 0
41
+ total=0
42
+ correct=0
43
+ for inputs, labels in self.train_loader:
44
+ self.optimizer.zero_grad()
45
+ outputs = self.model(inputs)
46
+ loss = self.criterion(outputs, labels)# + torch.sum(torch.abs(self.model.causal_discovery()))*1
47
+ loss.backward()
48
+ self.optimizer.step()
49
+ total_loss += loss.item()
50
+ total += len(labels.squeeze())
51
+ if self.problem_type==1:
52
+ correct += (torch.round(torch.sigmoid(outputs.data)).squeeze() == labels.squeeze()).sum().item()
53
+ elif self.problem_type==2:
54
+ correct += (torch.max(outputs.data, 1)[1] == labels.squeeze()).sum().item()
55
+ return total_loss/len(self.train_loader) , correct/total
56
+
57
+ def validate(self):
58
+ self.model.eval()
59
+ val_loss = 0
60
+ total=0
61
+ val_predictions = []
62
+ val_targets = []
63
+ with torch.no_grad():
64
+ for inputs, labels in self.val_loader:
65
+ outputs = self.model(inputs)
66
+ val_loss += self.criterion(outputs, labels).item()
67
+ total += len(labels.squeeze())
68
+ if self.problem_type==1:
69
+ val_predictions.extend(torch.sigmoid(outputs).view(-1).cpu().numpy())
70
+ elif self.problem_type==2:
71
+ val_predictions.extend(torch.max(outputs.data, 1)[1].view(-1).cpu().numpy())
72
+ val_targets.extend(labels.view(-1).cpu().numpy())
73
+
74
+ if self.problem_type==1:
75
+ val_roc_auc =roc_auc_score(val_targets, val_predictions)
76
+ val_acc = accuracy(val_targets, np.round(val_predictions))
77
+ elif self.problem_type==2:
78
+ val_acc = accuracy(val_targets,val_predictions)
79
+ val_roc_auc=0
80
+ else:
81
+ val_roc_auc=0
82
+ val_acc=0
83
+ return val_loss /len(self.val_loader), val_acc,val_roc_auc
84
+
85
+ def train(self):
86
+ for epoch in range(self.epochs):
87
+ #Increase alpha up to 1-tenth of entire epochs
88
+ alpha_now=np.minimum(1.0,float(epoch)/float(self.epochs/10))
89
+ # print(alpha_now)
90
+ self.model.set_alpha(alpha_now)
91
+ if epoch>self.epochs//10:
92
+ save_permit=True
93
+ else:
94
+ save_permit=False
95
+ tr_loss, tr_acc = self.train_epoch()
96
+ val_loss, val_acc , val_roc_auc= self.validate()
97
+
98
+ if self.problem_type == 0:
99
+ if self.verbose:
100
+ print(f'Epoch {epoch}: Train Loss {tr_loss:.4f}, Val Loss {val_loss:.4f}')
101
+ if (val_loss < self.best_metric)and(save_permit):
102
+ self.best_metric = val_loss
103
+ # Save model checkpoint
104
+ self.model.nninput=None #Delete data remaining from training
105
+ self.encodings=None
106
+ self.taus=None
107
+ # torch.save(self.model, 'best_model.pth')
108
+ # torch.save(self.model.state_dict(), 'best_model_weights.pth')
109
+ self.best_model=copy.deepcopy(self.model.state_dict())
110
+ # print("Saving model with best validation loss.")
111
+
112
+ # Problem type 1: Focus on loss, accuracy, and AUC
113
+ elif self.problem_type == 1:
114
+ if self.verbose:
115
+ print(f'Epoch {epoch}: Train Loss {tr_loss:.4f}, Train Acc {tr_acc:.4f}, Val Loss {val_loss:.4f}, Val Acc {val_acc:.4f}, Val ROC AUC {val_roc_auc:.4f}')
116
+ if (val_roc_auc > self.best_metric)and(save_permit):
117
+ self.best_metric = val_roc_auc
118
+ # Save model checkpoint
119
+ self.model.nninput=None #Delete data remaining from training
120
+ self.encodings=None
121
+ self.taus=None
122
+ # torch.save(self.model, 'best_model.pth')
123
+ # torch.save(self.model.state_dict(), 'best_model_weights.pth')
124
+ self.best_model=copy.deepcopy(self.model.state_dict())
125
+ # print("Saving model with best validation ROC AUC.")
126
+
127
+ # Problem type 2: Focus on loss and accuracy
128
+ elif self.problem_type == 2:
129
+ if self.verbose:
130
+ print(f'Epoch {epoch}: Train Loss {tr_loss:.4f}, Train Acc {tr_acc:.4f}, Val Loss {val_loss:.4f}, Val Acc {val_acc:.4f}')
131
+ if (val_acc > self.best_metric)and(save_permit):
132
+ self.best_metric = val_acc
133
+ # Save model checkpoint
134
+ self.model.nninput=None #Delete data remaining from training
135
+ self.encodings=None
136
+ self.taus=None
137
+ # torch.save(self.model, 'best_model.pth')
138
+ # torch.save(self.model.state_dict(), 'best_model_weights.pth')
139
+ self.best_model=copy.deepcopy(self.model.state_dict())
140
+ # print("Saving model with best validation accuracy.")
141
+ self.scheduler.step()
142
+ # Load best validation model
143
+ self.model.load_state_dict(self.best_model)
144
+
145
+ # self.model = torch.load('best_model.pth')
146
+
147
+
148
+
149
+ def evaluate(self,X_test, y_test,verbose=True):
150
+ test_loader=DataLoader(dataset=TensorDataset(X_test, y_test), batch_size=len(y_test), shuffle=True)
151
+ self.model.eval()
152
+ test_loss = 0
153
+ total=0
154
+ test_predictions = []
155
+ test_targets = []
156
+ with torch.no_grad():
157
+ for inputs, labels in test_loader:
158
+ outputs = self.model(inputs)
159
+ test_loss += self.criterion(outputs, labels).item()
160
+ total += len(labels.squeeze())
161
+ if self.problem_type==1:
162
+ test_predictions.extend(torch.sigmoid(outputs).view(-1).cpu().numpy())
163
+ elif self.problem_type==2:
164
+ test_predictions.extend(torch.max(outputs.data, 1)[1].view(-1).cpu().numpy())
165
+ test_targets.extend(labels.view(-1).cpu().numpy())
166
+
167
+ if self.problem_type==1:
168
+ test_roc_auc =roc_auc_score(test_targets, test_predictions)
169
+ test_acc = accuracy(test_targets, np.round(test_predictions))
170
+ if verbose:
171
+ print('ROC-AUC: ', test_roc_auc)
172
+ return test_roc_auc
173
+ elif self.problem_type==2:
174
+ test_acc = accuracy(test_targets,test_predictions)
175
+ test_roc_auc=0
176
+ if verbose:
177
+ print('ACC: ', test_acc)
178
+ return test_acc
179
+ else:
180
+ test_roc_auc=0
181
+ test_acc=0
182
+ if verbose:
183
+ print('MSE: ', test_loss /len(test_loader))
184
+ return test_loss /len(test_loader)
185
+
186
+
app.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import numpy as np
4
+ from LEURN import LEURN
5
+ import torch
6
+ from DATA import split_and_processing
7
+ from TRAINER import Trainer
8
+ import numpy as np
9
+ import openml
10
+
11
+ # Streamlit application layout
12
+ st.title("LEURN")
13
+
14
+ # Initialize or reset session states if necessary
15
+ if 'init' not in st.session_state:
16
+ st.session_state['training_completed'] = False
17
+ st.session_state['data_chosen'] = False
18
+ st.session_state['init'] = True
19
+ st.session_state['selected_row']=False
20
+ st.session_state['explanation_made']=False
21
+ st.session_state['result']=False
22
+
23
+
24
+ # Upload csv or excel
25
+ st.subheader("File Uploader")
26
+ uploaded_file = st.file_uploader("Upload your Excel/CSV file", type=["csv", "xlsx"])
27
+ if uploaded_file is not None:
28
+ # Reading the uploaded file
29
+ df = pd.read_csv(uploaded_file) if uploaded_file.type == "text/csv" else pd.read_excel(uploaded_file)
30
+ st.write("Data Preview:")
31
+ st.write(df.head())
32
+
33
+ st.subheader("Categorical Feature and Target Selection")
34
+ # Selecting the target variable
35
+ target = st.selectbox("Select the target variable", options=df.columns)
36
+
37
+ # Define features and target
38
+ X = df.drop(target, axis=1)
39
+ y = df[target]
40
+ attribute_names = X.columns
41
+
42
+
43
+ # Select categorical variables
44
+ st.write("Select categorical variables:")
45
+ categoricals = [st.checkbox(f"{col} is categorical", key=col) for col in X.columns]
46
+
47
+ # User input for model parameters
48
+ st.subheader("Model Training Parameters")
49
+ depth = st.selectbox("Select Model Depth", options=[1, 2, 3, 4, 5], index=2)
50
+ batch_size = st.selectbox("Select Batch Size", options=[64, 128, 256, 512, 1024, 2048, 4096], index=4)
51
+ lr = st.selectbox("Select Learning Rate", options=[1e-4, 5e-4, 1e-3, 5e-3, 1e-2], index=3)
52
+ epochs = st.number_input("Enter Number of Epochs", min_value=1, max_value=1000, value=300)
53
+ droprate = st.slider("Select Dropout Rate", min_value=0.0, max_value=1.0, value=0.0, step=0.05)
54
+ output_type = st.radio("Select Output Type (0: regression, 1: binary classification, 2: multi-class classification)", options=[0, 1, 2], index=0)
55
+
56
+ if st.button("Train Neural Network"):
57
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
58
+ #Split and process
59
+ X_train, X_val, X_test, y_train, y_val, y_test, preprocessor = split_and_processing(X, y, categoricals, output_type, attribute_names)
60
+ #Initialize model
61
+ model = LEURN(preprocessor, depth=depth, droprate=droprate).to(device)
62
+ #Train model
63
+ model_trainer = Trainer(model, X_train, X_val, y_train, y_val, lr=lr, batch_size=batch_size, epochs=epochs, problem_type=output_type, verbose=False)
64
+ model_trainer.train()
65
+ #Load best model
66
+ model.load_state_dict(model_trainer.best_model)
67
+ #Get performances
68
+ perf_train = model_trainer.evaluate(X_train, y_train)
69
+ perf_val = model_trainer.evaluate(X_val, y_val)
70
+ perf_test = model_trainer.evaluate(X_test, y_test)
71
+ st.session_state['perf_train']=perf_train
72
+ st.session_state['perf_val']=perf_val
73
+ st.session_state['perf_test']=perf_test
74
+
75
+ #Save test dataset and model to explain/generate later
76
+ X_test_inverse = preprocessor.inverse_transform_X(X_test)
77
+ X_test_inverse.to_csv('test.csv',index=False)
78
+ st.session_state['training_completed'] = True
79
+ st.session_state['model'] = model # Adjusted for compatibility
80
+
81
+
82
+
83
+ if st.session_state['training_completed'] == True:
84
+
85
+ #Print performances
86
+ st.write("Here are performances, try different hyperparameters if not satisfied")
87
+ if output_type == 0:
88
+ st.subheader("Training Results (MSE)")
89
+ elif output_type == 1:
90
+ st.subheader("Training Results (ROC-AUC)")
91
+ else:
92
+ st.subheader("Training Results (ACC)")
93
+
94
+ st.write(f"Training Score: {st.session_state['perf_train']:.4f}")
95
+ st.write(f"Validation Score: {st.session_state['perf_val']:.4f}")
96
+ st.write(f"Test Score: {st.session_state['perf_test']:.4f}")
97
+
98
+
99
+ # File uploader for explanation
100
+
101
+ st.subheader("Explain New Inputs")
102
+ uploaded_file_to_explain = st.file_uploader("Upload your Excel/CSV file to explain. Uploaded file should not have the target variable.", type=["csv", "xlsx"])
103
+ print(uploaded_file_to_explain)
104
+ if uploaded_file_to_explain is not None:
105
+ # Reading the uploaded file
106
+
107
+ X_test_inverse = pd.read_csv(uploaded_file_to_explain) if uploaded_file_to_explain.type == "text/csv" else pd.read_excel(uploaded_file_to_explain)
108
+
109
+ # Save DataFrame
110
+ st.session_state['X_test_inverse_df'] = X_test_inverse.to_json()
111
+ st.session_state['data_chosen'] = True # Flag to indicate data is chosen
112
+
113
+
114
+ if st.session_state['data_chosen'] == True:
115
+ # Load DataFrame from session state
116
+ X_test_inverse = pd.read_json(st.session_state['X_test_inverse_df'])
117
+
118
+ # Always display the DataFrame to ensure it's visible for selection
119
+ st.write("Test DataFrame:")
120
+ st.write(X_test_inverse)
121
+
122
+ # Let users select a row, selection is dynamic and updates session state
123
+ selected_index = st.selectbox("Select a row:", options=X_test_inverse.index, key="selected_index")
124
+
125
+ selected_row = X_test_inverse.loc[[st.session_state['selected_index']]]
126
+ st.write("Selected Data for Explanation:")
127
+ st.write(selected_row)
128
+ st.session_state['selected_row'] = selected_row
129
+
130
+ #Explain selected row
131
+ if st.button("Explain"):
132
+ model=st.session_state['model']
133
+ Exp_df_test_sample,result,result_original_format=model.explain(torch.from_numpy(model.preprocessor.transform_X(st.session_state['selected_row']).values.astype('float32')),include_causal_analysis=True)
134
+ st.session_state['explanation_made']=True
135
+ st.session_state['Exp_df_test_sample']=Exp_df_test_sample
136
+ st.session_state['result_original_format']=result_original_format
137
+ st.session_state['result']=result
138
+
139
+ #Print explanations
140
+ if st.session_state['explanation_made']==True:
141
+ st.write("Explanation DataFrame:")
142
+ st.write(st.session_state['Exp_df_test_sample'])
143
+ st.write("Predicted Output: (Network format)")
144
+ st.write(st.session_state['result'].detach().numpy().astype('str'))
145
+ if output_type==1:
146
+ if np.sign(st.session_state['result'].detach().numpy())>0:
147
+ st.write("Result here is positive; this means output class below is represented by positive sign. In the explanation dataframe, positive contributions increase class likelihood")
148
+ else:
149
+ st.write("Result here is negative; this means output class below is represented by negative sign. In the explanation dataframe, negative contributions increase class likelihood")
150
+
151
+ st.write("Predicted Output: (original format)")
152
+ st.write(st.session_state['result_original_format'])
153
+
154
+ #Data generation part
155
+ st.subheader("Generate Data From Scratch")
156
+ if st.button("Generate"):
157
+ model=st.session_state['model']
158
+ generated_sample_nn_friendly, generated_sample_original_input_format,output=model.generate()
159
+ Exp_df_generated_sample,result,result_original_format=model.explain(generated_sample_nn_friendly,include_causal_analysis=True)
160
+ st.write("Explanation DataFrame:")
161
+ st.write(Exp_df_generated_sample)
162
+ st.write("Predicted Output: (Network format)")
163
+ st.write(result.detach().numpy().astype('str'))
164
+ if output_type==1:
165
+ if np.sign(result.detach().numpy())>0:
166
+ st.write("Result here is positive; this means output class below is represented by positive sign. In the explanation dataframe, positive contributions increase class likelihood")
167
+ else:
168
+ st.write("Result here is negative; this means output class below is represented by negative sign. In the explanation dataframe, negative contributions increase class likelihood")
169
+
170
+ st.write("Predicted Output: (original format)")
171
+ st.write(result_original_format)
172
+
173
+
174
+
175
+
176
+
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ torch
2
+ pandas
3
+ openpyxl
4
+ openml
5
+ numpy
6
+ scikit-learn
7
+ streamlit==1.29.0