oikan 0.0.2.4__py3-none-any.whl → 0.0.3.1__py3-none-any.whl
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.
- oikan/__init__.py +14 -0
- oikan/exceptions.py +5 -13
- oikan/model.py +307 -426
- oikan/neural.py +43 -0
- oikan/symbolic.py +52 -25
- oikan/utils.py +59 -37
- oikan-0.0.3.1.dist-info/METADATA +233 -0
- oikan-0.0.3.1.dist-info/RECORD +11 -0
- {oikan-0.0.2.4.dist-info → oikan-0.0.3.1.dist-info}/WHEEL +1 -1
- oikan-0.0.2.4.dist-info/METADATA +0 -214
- oikan-0.0.2.4.dist-info/RECORD +0 -10
- {oikan-0.0.2.4.dist-info → oikan-0.0.3.1.dist-info}/licenses/LICENSE +0 -0
- {oikan-0.0.2.4.dist-info → oikan-0.0.3.1.dist-info}/top_level.txt +0 -0
oikan/model.py
CHANGED
@@ -1,460 +1,341 @@
|
|
1
|
+
import numpy as np
|
1
2
|
import torch
|
2
3
|
import torch.nn as nn
|
3
|
-
import
|
4
|
-
from sklearn.
|
5
|
-
from .
|
6
|
-
from
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
"""Edge-based activation function learner"""
|
11
|
-
def __init__(self):
|
12
|
-
super().__init__()
|
13
|
-
self.activation = EdgeActivation()
|
14
|
-
|
15
|
-
def forward(self, x):
|
16
|
-
return self.activation(x)
|
17
|
-
|
18
|
-
def get_symbolic_repr(self, threshold=1e-4):
|
19
|
-
return self.activation.get_symbolic_repr(threshold)
|
4
|
+
import torch.optim as optim
|
5
|
+
from sklearn.preprocessing import PolynomialFeatures
|
6
|
+
from sklearn.linear_model import Lasso
|
7
|
+
from abc import ABC, abstractmethod
|
8
|
+
import json
|
9
|
+
from .neural import TabularNet
|
10
|
+
from .utils import evaluate_basis_functions, get_features_involved
|
20
11
|
|
21
|
-
class
|
22
|
-
"""
|
23
|
-
|
24
|
-
super().__init__()
|
25
|
-
self.input_dim = input_dim
|
26
|
-
self.output_dim = output_dim
|
27
|
-
|
28
|
-
self.edges = nn.ModuleList([
|
29
|
-
nn.ModuleList([SymbolicEdge() for _ in range(output_dim)])
|
30
|
-
for _ in range(input_dim)
|
31
|
-
])
|
32
|
-
|
33
|
-
# Updated initialization using Xavier uniform initialization
|
34
|
-
self.combination_weights = nn.Parameter(
|
35
|
-
nn.init.xavier_uniform_(torch.empty(input_dim, output_dim))
|
36
|
-
)
|
37
|
-
|
38
|
-
def forward(self, x):
|
39
|
-
x_split = x.split(1, dim=1) # list of (batch, 1) tensors for each input feature
|
40
|
-
edge_outputs = torch.stack([
|
41
|
-
torch.stack([edge(x_i).squeeze() for edge in edge_list], dim=1)
|
42
|
-
for x_i, edge_list in zip(x_split, self.edges)
|
43
|
-
], dim=1) # shape: (batch, input_dim, output_dim)
|
44
|
-
combined = edge_outputs * self.combination_weights.unsqueeze(0)
|
45
|
-
return combined.sum(dim=1)
|
12
|
+
class OIKAN(ABC):
|
13
|
+
"""
|
14
|
+
Base class for the OIKAN neuro-symbolic framework.
|
46
15
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
for
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
16
|
+
Parameters:
|
17
|
+
-----------
|
18
|
+
hidden_sizes : list, optional (default=[64, 64])
|
19
|
+
List of hidden layer sizes for the neural network.
|
20
|
+
activation : str, optional (default='relu')
|
21
|
+
Activation function for the neural network ('relu' or 'tanh').
|
22
|
+
augmentation_factor : int, optional (default=10)
|
23
|
+
Number of augmented samples per original sample.
|
24
|
+
polynomial_degree : int, optional (default=2)
|
25
|
+
Maximum degree of polynomial features for symbolic regression.
|
26
|
+
alpha : float, optional (default=0.1)
|
27
|
+
L1 regularization strength for Lasso in symbolic regression.
|
28
|
+
sigma : float, optional (default=0.1)
|
29
|
+
Standard deviation of Gaussian noise for data augmentation.
|
30
|
+
epochs : int, optional (default=100)
|
31
|
+
Number of epochs for neural network training.
|
32
|
+
lr : float, optional (default=0.001)
|
33
|
+
Learning rate for neural network optimization.
|
34
|
+
batch_size : int, optional (default=32)
|
35
|
+
Batch size for neural network training.
|
36
|
+
verbose : bool, optional (default=False)
|
37
|
+
Whether to display training progress.
|
38
|
+
"""
|
39
|
+
def __init__(self, hidden_sizes=[64, 64], activation='relu', augmentation_factor=10,
|
40
|
+
polynomial_degree=2, alpha=0.1, sigma=0.1, epochs=100, lr=0.001, batch_size=32,
|
41
|
+
verbose=False):
|
42
|
+
self.hidden_sizes = hidden_sizes
|
43
|
+
self.activation = activation
|
44
|
+
self.augmentation_factor = augmentation_factor
|
45
|
+
self.polynomial_degree = polynomial_degree
|
46
|
+
self.alpha = alpha
|
47
|
+
self.sigma = sigma
|
48
|
+
self.epochs = epochs
|
49
|
+
self.lr = lr
|
50
|
+
self.batch_size = batch_size
|
51
|
+
self.verbose = verbose
|
52
|
+
self.neural_net = None
|
53
|
+
self.symbolic_model = None
|
61
54
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
self.hidden_dims = hidden_dims
|
66
|
-
self.dropout = dropout # Dropout probability for uncertainty quantification
|
67
|
-
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # Auto device chooser
|
68
|
-
self.model = None
|
69
|
-
self._is_fitted = False
|
70
|
-
self.__name = "OIKAN v0.0.2" # Manual configured version
|
71
|
-
self.loss_history = [] # <-- new attribute to store loss values
|
72
|
-
|
73
|
-
def _build_network(self, input_dim, output_dim):
|
74
|
-
layers = []
|
75
|
-
prev_dim = input_dim
|
76
|
-
for hidden_dim in self.hidden_dims:
|
77
|
-
layers.append(KANLayer(prev_dim, hidden_dim))
|
78
|
-
layers.append(nn.BatchNorm1d(hidden_dim)) # Added batch normalization
|
79
|
-
layers.append(nn.ReLU()) # Added activation function
|
80
|
-
layers.append(nn.Dropout(self.dropout)) # Apply dropout for uncertainty quantification
|
81
|
-
prev_dim = hidden_dim
|
82
|
-
layers.append(KANLayer(prev_dim, output_dim))
|
83
|
-
return nn.Sequential(*layers).to(self.device)
|
84
|
-
|
85
|
-
def _validate_data(self, X, y=None):
|
86
|
-
if not isinstance(X, torch.Tensor):
|
87
|
-
X = torch.FloatTensor(X)
|
88
|
-
if y is not None and not isinstance(y, torch.Tensor):
|
89
|
-
y = torch.FloatTensor(y)
|
90
|
-
return X.to(self.device), (y.to(self.device) if y is not None else None)
|
55
|
+
@abstractmethod
|
56
|
+
def fit(self, X, y):
|
57
|
+
pass
|
91
58
|
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
for term in edge_formula.split(" + "):
|
96
|
-
if term and term != "0":
|
97
|
-
if "*" in term:
|
98
|
-
coef_str, rest = term.split("*", 1)
|
99
|
-
try:
|
100
|
-
coef = float(coef_str)
|
101
|
-
terms.append(f"{(coef * weight):.4f}*{rest}")
|
102
|
-
except Exception:
|
103
|
-
terms.append(term) # fallback
|
104
|
-
else:
|
105
|
-
try:
|
106
|
-
terms.append(f"{(float(term) * weight):.4f}")
|
107
|
-
except Exception:
|
108
|
-
terms.append(term)
|
109
|
-
return " + ".join(terms) if terms else "0"
|
59
|
+
@abstractmethod
|
60
|
+
def predict(self, X):
|
61
|
+
pass
|
110
62
|
|
111
|
-
def
|
112
|
-
"""
|
113
|
-
if
|
114
|
-
raise
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
for i in range(n_features):
|
123
|
-
for j in range(n_classes):
|
124
|
-
weight = first_layer.combination_weights[i, j].item()
|
125
|
-
if abs(weight) > 1e-4:
|
126
|
-
# Use improved threshold for formula extraction
|
127
|
-
edge_formula = first_layer.edges[i][j].get_symbolic_repr(threshold=1e-6)
|
128
|
-
formulas[i][j] = self._process_edge_formula(edge_formula, weight)
|
129
|
-
else:
|
130
|
-
formulas[i][j] = "0"
|
131
|
-
self.symbolic_formula = formulas
|
132
|
-
return formulas
|
133
|
-
else: # Regressor
|
63
|
+
def get_formula(self):
|
64
|
+
"""Returns the symbolic formula(s) as a string or list of strings."""
|
65
|
+
if self.symbolic_model is None:
|
66
|
+
raise ValueError("Model not fitted yet.")
|
67
|
+
basis_functions = self.symbolic_model['basis_functions']
|
68
|
+
if 'coefficients' in self.symbolic_model:
|
69
|
+
coefficients = self.symbolic_model['coefficients']
|
70
|
+
formula = " + ".join([f"{coefficients[i]:.3f}*{basis_functions[i]}"
|
71
|
+
for i in range(len(coefficients)) if coefficients[i] != 0])
|
72
|
+
return formula if formula else "0"
|
73
|
+
else:
|
134
74
|
formulas = []
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
formulas.append(self._process_edge_formula(edge_formula, 1.0))
|
140
|
-
self.symbolic_formula = formulas
|
75
|
+
for c, coef in enumerate(self.symbolic_model['coefficients_list']):
|
76
|
+
formula = " + ".join([f"{coef[i]:.3f}*{basis_functions[i]}"
|
77
|
+
for i in range(len(coef)) if coef[i] != 0])
|
78
|
+
formulas.append(f"Class {self.classes_[c]}: {formula if formula else '0'}")
|
141
79
|
return formulas
|
142
80
|
|
143
|
-
def
|
144
|
-
"""
|
81
|
+
def feature_importances(self):
|
82
|
+
"""
|
83
|
+
Computes the importance of each original feature based on the symbolic model.
|
145
84
|
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
- A general formula, including softmax for classification
|
150
|
-
- Recommendations and performance results.
|
85
|
+
Returns:
|
86
|
+
--------
|
87
|
+
numpy.ndarray : Normalized feature importances.
|
151
88
|
"""
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
if hasattr(self, 'classes_'):
|
158
|
-
# For classifiers: formulas is a 2D list [feature][class]
|
159
|
-
for i, feature in enumerate(formulas):
|
160
|
-
for j, form in enumerate(feature):
|
161
|
-
formulas_text += f"Feature {i} - Class {j}: {form}\n"
|
162
|
-
general = ("\nGeneral Formula (with softmax):\n"
|
163
|
-
"For each class j: y_j = softmax( sum_i [ symbolic_formula(feature_i, class_j) ] )\n")
|
164
|
-
recs = ("\nRecommendations:\n"
|
165
|
-
"• Use the symbolic formulas for streamlined inference in production.\n"
|
166
|
-
"• Verify predictions with both the neural network and the compiled symbolic predictor.\n")
|
167
|
-
else:
|
168
|
-
# For regressors: formulas is a list
|
169
|
-
for i, form in enumerate(formulas):
|
170
|
-
formulas_text += f"Feature {i}: {form}\n"
|
171
|
-
general = ("\nGeneral Formula:\n"
|
172
|
-
"y = sum_i [ symbolic_formula(feature_i) ]\n")
|
173
|
-
recs = ("\nRecommendations:\n"
|
174
|
-
"• Consider the symbolic formula for lightweight and interpretable inference.\n"
|
175
|
-
"• Validate approximation accuracy against the neural model.\n")
|
176
|
-
|
177
|
-
# Disclaimer regarding experimental usage
|
178
|
-
disclaimer = ("\nDisclaimer:\n"
|
179
|
-
"This experimental model is intended for research purposes only and is not production-ready. "
|
180
|
-
"Feel free to fork and build your own project based on this research: "
|
181
|
-
"https://github.com/silvermete0r/oikan\n")
|
89
|
+
if self.symbolic_model is None:
|
90
|
+
raise ValueError("Model not fitted yet.")
|
91
|
+
basis_functions = self.symbolic_model['basis_functions']
|
92
|
+
n_features = self.symbolic_model['n_features']
|
93
|
+
importances = np.zeros(n_features)
|
182
94
|
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
95
|
+
# Handle regression case
|
96
|
+
if 'coefficients' in self.symbolic_model:
|
97
|
+
coefficients = self.symbolic_model['coefficients']
|
98
|
+
for i, func in enumerate(basis_functions):
|
99
|
+
if coefficients[i] != 0:
|
100
|
+
features_involved = get_features_involved(func)
|
101
|
+
for idx in features_involved:
|
102
|
+
importances[idx] += np.abs(coefficients[i])
|
103
|
+
# Handle classification case with multiple coefficient sets
|
104
|
+
else:
|
105
|
+
for coef in self.symbolic_model['coefficients_list']:
|
106
|
+
for i, func in enumerate(basis_functions):
|
107
|
+
if coef[i] != 0:
|
108
|
+
features_involved = get_features_involved(func)
|
109
|
+
for idx in features_involved:
|
110
|
+
importances[idx] += np.abs(coef[i])
|
192
111
|
|
193
|
-
|
194
|
-
return
|
195
|
-
|
196
|
-
def _eval_formula(self, formula, x):
|
197
|
-
"""Helper to evaluate a symbolic formula for an input vector x using ADVANCED_LIB basis functions."""
|
198
|
-
import re
|
199
|
-
total = 0
|
200
|
-
pattern = re.compile(r"(-?\d+\.\d+)\*?([\w\(\)\^]+)")
|
201
|
-
matches = pattern.findall(formula)
|
202
|
-
for coef_str, func_name in matches:
|
203
|
-
try:
|
204
|
-
coef = float(coef_str)
|
205
|
-
for key, (notation, func) in ADVANCED_LIB.items():
|
206
|
-
if notation.strip() == func_name.strip():
|
207
|
-
total += coef * func(x)
|
208
|
-
break
|
209
|
-
except Exception:
|
210
|
-
continue
|
211
|
-
return total
|
212
|
-
|
213
|
-
def symbolic_predict(self, X):
|
214
|
-
"""Predict using only the extracted symbolic formula (regressor)."""
|
215
|
-
if not self._is_fitted:
|
216
|
-
raise NotFittedError("Model must be fitted before prediction")
|
217
|
-
X = np.array(X) if not isinstance(X, np.ndarray) else X
|
218
|
-
formulas = self.get_symbolic_formula() # For regressor: list of formula strings.
|
219
|
-
predictions = np.zeros((X.shape[0], 1))
|
220
|
-
for i, formula in enumerate(formulas):
|
221
|
-
x = X[:, i]
|
222
|
-
predictions[:, 0] += self._eval_formula(formula, x)
|
223
|
-
return predictions
|
112
|
+
total = importances.sum()
|
113
|
+
return importances / total if total > 0 else importances
|
224
114
|
|
225
|
-
def
|
226
|
-
|
227
|
-
|
228
|
-
with open(filename, "r") as f:
|
229
|
-
content = f.read()
|
230
|
-
# Regex to extract coefficient and function notation.
|
231
|
-
# Matches patterns like: "(-?\d+\.\d+)\*?([\w\(\)\^]+)"
|
232
|
-
matches = re.findall(r"(-?\d+\.\d+)\*?([\w\(\)\^]+)", content)
|
233
|
-
compiled_terms = []
|
234
|
-
for coef_str, func_name in matches:
|
235
|
-
try:
|
236
|
-
coef = float(coef_str)
|
237
|
-
# Search for a matching basis function in ADVANCED_LIB (e.g. 'x', 'x^2', etc.)
|
238
|
-
for key, (notation, func) in ADVANCED_LIB.items():
|
239
|
-
if notation.strip() == func_name.strip():
|
240
|
-
compiled_terms.append((coef, func))
|
241
|
-
break
|
242
|
-
except Exception:
|
243
|
-
continue
|
244
|
-
def prediction_function(x):
|
245
|
-
pred = 0
|
246
|
-
for coef, func in compiled_terms:
|
247
|
-
pred += coef * func(x)
|
248
|
-
return pred
|
249
|
-
return prediction_function
|
250
|
-
|
251
|
-
def save_model(self, filepath="models/oikan_model.pth"):
|
252
|
-
"""Save the current model's state dictionary and extra attributes to a file."""
|
253
|
-
if self.model is None:
|
254
|
-
raise NotFittedError("No model to save. Build and train a model first.")
|
255
|
-
save_dict = {'state_dict': self.model.state_dict()}
|
256
|
-
if hasattr(self, "classes_"):
|
257
|
-
# Save classes_ as a list so that it can be reloaded.
|
258
|
-
save_dict['classes_'] = self.classes_.tolist()
|
259
|
-
torch.save(save_dict, filepath)
|
260
|
-
print(f"Model saved to {filepath}")
|
261
|
-
|
262
|
-
def load_model(self, filepath="models/oikan_model.pth", input_dim=None, output_dim=None):
|
263
|
-
"""Load the model's state dictionary and extra attributes from a file.
|
115
|
+
def save(self, path):
|
116
|
+
"""
|
117
|
+
Saves the symbolic model to a .json file.
|
264
118
|
|
265
|
-
|
266
|
-
|
119
|
+
Parameters:
|
120
|
+
-----------
|
121
|
+
path : str
|
122
|
+
File path to save the model. Should end with .json
|
267
123
|
"""
|
268
|
-
if self.
|
269
|
-
|
270
|
-
raise NotFittedError("No model architecture available. Provide input_dim and output_dim to rebuild the model.")
|
271
|
-
self.model = self._build_network(input_dim, output_dim)
|
272
|
-
loaded = torch.load(filepath, map_location=self.device)
|
273
|
-
if isinstance(loaded, dict) and 'state_dict' in loaded:
|
274
|
-
self.model.load_state_dict(loaded['state_dict'])
|
275
|
-
if 'classes_' in loaded:
|
276
|
-
self.classes_ = torch.tensor(loaded['classes_'])
|
277
|
-
else:
|
278
|
-
self.model.load_state_dict(loaded)
|
279
|
-
self._is_fitted = True # Mark model as fitted after loading
|
280
|
-
print(f"Model loaded from {filepath}")
|
281
|
-
|
282
|
-
def get_loss_history(self):
|
283
|
-
"""Retrieve training loss history."""
|
284
|
-
return self.loss_history
|
285
|
-
|
286
|
-
class OIKANRegressor(BaseOIKAN, RegressorMixin):
|
287
|
-
"""OIKAN implementation for regression tasks"""
|
288
|
-
def fit(self, X, y, epochs=100, lr=0.01, verbose=True):
|
289
|
-
X, y = self._validate_data(X, y)
|
290
|
-
if len(y.shape) == 1:
|
291
|
-
y = y.reshape(-1, 1)
|
124
|
+
if self.symbolic_model is None:
|
125
|
+
raise ValueError("Model not fitted yet.")
|
292
126
|
|
293
|
-
if
|
294
|
-
|
127
|
+
if not path.endswith('.json'):
|
128
|
+
path = path + '.json'
|
295
129
|
|
296
|
-
|
297
|
-
|
130
|
+
# Convert numpy arrays and other non-serializable types to lists
|
131
|
+
model_data = {
|
132
|
+
'n_features': self.symbolic_model['n_features'],
|
133
|
+
'degree': self.symbolic_model['degree'],
|
134
|
+
'basis_functions': self.symbolic_model['basis_functions']
|
135
|
+
}
|
298
136
|
|
299
|
-
self.
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
if torch.isnan(loss):
|
307
|
-
print("Warning: NaN loss detected, reinitializing model...")
|
308
|
-
self.model = None
|
309
|
-
return self.fit(X, y, epochs, lr/10, verbose)
|
310
|
-
|
311
|
-
loss.backward()
|
312
|
-
|
313
|
-
# Clip gradients
|
314
|
-
torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
|
315
|
-
|
316
|
-
optimizer.step()
|
317
|
-
|
318
|
-
self.loss_history.append(loss.item()) # <-- save loss value for epoch
|
319
|
-
|
320
|
-
if verbose and (epoch + 1) % 10 == 0:
|
321
|
-
print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}")
|
322
|
-
|
323
|
-
self._is_fitted = True
|
324
|
-
return self
|
325
|
-
|
326
|
-
def predict(self, X):
|
327
|
-
if not self._is_fitted:
|
328
|
-
raise NotFittedError("Model must be fitted before prediction")
|
329
|
-
|
330
|
-
X = self._validate_data(X)[0]
|
331
|
-
self.model.eval()
|
332
|
-
with torch.no_grad():
|
333
|
-
return self.model(X).cpu().numpy()
|
334
|
-
|
335
|
-
class OIKANClassifier(BaseOIKAN, ClassifierMixin):
|
336
|
-
"""OIKAN implementation for classification tasks"""
|
337
|
-
def fit(self, X, y, epochs=100, lr=0.01, verbose=True):
|
338
|
-
X, y = self._validate_data(X, y)
|
339
|
-
self.classes_ = torch.unique(y)
|
340
|
-
n_classes = len(self.classes_)
|
137
|
+
if 'coefficients' in self.symbolic_model:
|
138
|
+
model_data['coefficients'] = self.symbolic_model['coefficients']
|
139
|
+
else:
|
140
|
+
model_data['coefficients_list'] = [coef for coef in self.symbolic_model['coefficients_list']]
|
141
|
+
if hasattr(self, 'classes_'):
|
142
|
+
model_data['classes'] = self.classes_.tolist()
|
341
143
|
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
144
|
+
with open(path, 'w') as f:
|
145
|
+
json.dump(model_data, f, indent=2)
|
146
|
+
|
147
|
+
def load(self, path):
|
148
|
+
"""
|
149
|
+
Loads the symbolic model from a .json file.
|
348
150
|
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
logits = logits.squeeze()
|
357
|
-
else:
|
358
|
-
y_tensor = y.long()
|
359
|
-
loss = criterion(logits, y_tensor)
|
360
|
-
loss.backward()
|
361
|
-
optimizer.step()
|
362
|
-
|
363
|
-
self.loss_history.append(loss.item()) # <-- save loss value for epoch
|
151
|
+
Parameters:
|
152
|
+
-----------
|
153
|
+
path : str
|
154
|
+
File path to load the model from. Should end with .json
|
155
|
+
"""
|
156
|
+
if not path.endswith('.json'):
|
157
|
+
path = path + '.json'
|
364
158
|
|
365
|
-
|
366
|
-
|
159
|
+
with open(path, 'r') as f:
|
160
|
+
model_data = json.load(f)
|
367
161
|
|
368
|
-
self.
|
369
|
-
|
162
|
+
self.symbolic_model = {
|
163
|
+
'n_features': model_data['n_features'],
|
164
|
+
'degree': model_data['degree'],
|
165
|
+
'basis_functions': model_data['basis_functions']
|
166
|
+
}
|
167
|
+
|
168
|
+
if 'coefficients' in model_data:
|
169
|
+
self.symbolic_model['coefficients'] = model_data['coefficients']
|
170
|
+
else:
|
171
|
+
self.symbolic_model['coefficients_list'] = model_data['coefficients_list']
|
172
|
+
if 'classes' in model_data:
|
173
|
+
self.classes_ = np.array(model_data['classes'])
|
370
174
|
|
371
|
-
def
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
probs = torch.sigmoid(logits)
|
381
|
-
return np.column_stack([1 - probs.cpu().numpy(), probs.cpu().numpy()])
|
382
|
-
else:
|
383
|
-
return torch.softmax(logits, dim=1).cpu().numpy()
|
175
|
+
def _train_neural_net(self, X, y, output_size, loss_fn):
|
176
|
+
"""Trains the neural network on the input data."""
|
177
|
+
input_size = X.shape[1]
|
178
|
+
self.neural_net = TabularNet(input_size, self.hidden_sizes, output_size, self.activation)
|
179
|
+
optimizer = optim.Adam(self.neural_net.parameters(), lr=self.lr)
|
180
|
+
dataset = torch.utils.data.TensorDataset(torch.tensor(X, dtype=torch.float32),
|
181
|
+
torch.tensor(y, dtype=torch.float32))
|
182
|
+
loader = torch.utils.data.DataLoader(dataset, batch_size=self.batch_size, shuffle=True)
|
183
|
+
self.neural_net.train()
|
384
184
|
|
385
|
-
|
386
|
-
|
387
|
-
|
185
|
+
if self.verbose:
|
186
|
+
from tqdm import tqdm
|
187
|
+
epoch_iterator = tqdm(range(self.epochs), desc="Training")
|
188
|
+
else:
|
189
|
+
epoch_iterator = range(self.epochs)
|
388
190
|
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
191
|
+
for epoch in epoch_iterator:
|
192
|
+
total_loss = 0
|
193
|
+
for batch_X, batch_y in loader:
|
194
|
+
optimizer.zero_grad()
|
195
|
+
outputs = self.neural_net(batch_X)
|
196
|
+
loss = loss_fn(outputs, batch_y)
|
197
|
+
loss.backward()
|
198
|
+
optimizer.step()
|
199
|
+
total_loss += loss.item()
|
200
|
+
|
201
|
+
if self.verbose:
|
202
|
+
epoch_iterator.set_postfix({'loss': f'{total_loss/len(loader):.4f}'})
|
203
|
+
|
204
|
+
def _generate_augmented_data(self, X):
|
205
|
+
"""Generates augmented data by adding Gaussian noise."""
|
206
|
+
n_samples = X.shape[0]
|
207
|
+
X_aug = []
|
208
|
+
for _ in range(self.augmentation_factor):
|
209
|
+
noise = np.random.normal(0, self.sigma, X.shape)
|
210
|
+
X_perturbed = X + noise
|
211
|
+
X_aug.append(X_perturbed)
|
212
|
+
return np.vstack(X_aug)
|
213
|
+
|
214
|
+
def _perform_symbolic_regression(self, X, y):
|
215
|
+
"""Performs symbolic regression using polynomial features and Lasso."""
|
216
|
+
poly = PolynomialFeatures(degree=self.polynomial_degree, include_bias=True)
|
217
|
+
X_poly = poly.fit_transform(X)
|
218
|
+
model = Lasso(alpha=self.alpha, fit_intercept=False)
|
219
|
+
model.fit(X_poly, y)
|
220
|
+
if len(y.shape) == 1 or y.shape[1] == 1:
|
221
|
+
coef = model.coef_.flatten()
|
222
|
+
selected_indices = np.where(np.abs(coef) > 1e-6)[0]
|
223
|
+
self.symbolic_model = {
|
224
|
+
'n_features': X.shape[1],
|
225
|
+
'degree': self.polynomial_degree,
|
226
|
+
'basis_functions': poly.get_feature_names_out()[selected_indices].tolist(),
|
227
|
+
'coefficients': coef[selected_indices].tolist()
|
228
|
+
}
|
229
|
+
else:
|
230
|
+
coefficients_list = []
|
231
|
+
# Note: Using the same basis functions across classes for simplicity
|
232
|
+
selected_indices = set()
|
233
|
+
for c in range(y.shape[1]):
|
234
|
+
coef = model.coef_[c]
|
235
|
+
indices = np.where(np.abs(coef) > 1e-6)[0]
|
236
|
+
selected_indices.update(indices)
|
237
|
+
selected_indices = list(selected_indices)
|
238
|
+
basis_functions = poly.get_feature_names_out()[selected_indices].tolist()
|
239
|
+
for c in range(y.shape[1]):
|
240
|
+
coef = model.coef_[c]
|
241
|
+
coef_selected = coef[selected_indices].tolist()
|
242
|
+
coefficients_list.append(coef_selected)
|
243
|
+
self.symbolic_model = {
|
244
|
+
'n_features': X.shape[1],
|
245
|
+
'degree': self.polynomial_degree,
|
246
|
+
'basis_functions': basis_functions,
|
247
|
+
'coefficients_list': coefficients_list
|
248
|
+
}
|
249
|
+
|
250
|
+
class OIKANRegressor(OIKAN):
|
251
|
+
"""OIKAN model for regression tasks."""
|
252
|
+
def fit(self, X, y):
|
253
|
+
"""
|
254
|
+
Fits the regressor to the data.
|
411
255
|
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
256
|
+
Parameters:
|
257
|
+
-----------
|
258
|
+
X : array-like of shape (n_samples, n_features)
|
259
|
+
Training data.
|
260
|
+
y : array-like of shape (n_samples,)
|
261
|
+
Target values.
|
262
|
+
"""
|
263
|
+
X = np.asarray(X)
|
264
|
+
y = np.asarray(y).reshape(-1, 1)
|
265
|
+
self._train_neural_net(X, y, output_size=1, loss_fn=nn.MSELoss())
|
266
|
+
X_aug = self._generate_augmented_data(X)
|
267
|
+
self.neural_net.eval()
|
268
|
+
with torch.no_grad():
|
269
|
+
y_aug = self.neural_net(torch.tensor(X_aug, dtype=torch.float32)).detach().numpy()
|
270
|
+
self._perform_symbolic_regression(X_aug, y_aug)
|
271
|
+
|
272
|
+
def predict(self, X):
|
273
|
+
"""
|
274
|
+
Predicts target values for the input data.
|
416
275
|
|
417
|
-
|
418
|
-
|
419
|
-
|
276
|
+
Parameters:
|
277
|
+
-----------
|
278
|
+
X : array-like of shape (n_samples, n_features)
|
279
|
+
Input data.
|
420
280
|
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
281
|
+
Returns:
|
282
|
+
--------
|
283
|
+
y_pred : ndarray of shape (n_samples,)
|
284
|
+
Predicted values.
|
285
|
+
"""
|
286
|
+
if self.symbolic_model is None:
|
287
|
+
raise ValueError("Model not fitted yet.")
|
288
|
+
X = np.asarray(X)
|
289
|
+
X_transformed = evaluate_basis_functions(X, self.symbolic_model['basis_functions'],
|
290
|
+
self.symbolic_model['n_features'])
|
291
|
+
return np.dot(X_transformed, self.symbolic_model['coefficients'])
|
292
|
+
|
293
|
+
class OIKANClassifier(OIKAN):
|
294
|
+
"""OIKAN model for classification tasks."""
|
295
|
+
def fit(self, X, y):
|
296
|
+
"""
|
297
|
+
Fits the classifier to the data.
|
427
298
|
|
428
|
-
|
299
|
+
Parameters:
|
300
|
+
-----------
|
301
|
+
X : array-like of shape (n_samples, n_features)
|
302
|
+
Training data.
|
303
|
+
y : array-like of shape (n_samples,)
|
304
|
+
Target labels.
|
305
|
+
"""
|
306
|
+
X = np.asarray(X)
|
307
|
+
from sklearn.preprocessing import LabelEncoder
|
308
|
+
le = LabelEncoder()
|
309
|
+
y_encoded = le.fit_transform(y)
|
310
|
+
self.classes_ = le.classes_
|
429
311
|
n_classes = len(self.classes_)
|
430
|
-
|
312
|
+
y_onehot = nn.functional.one_hot(torch.tensor(y_encoded), num_classes=n_classes).float()
|
313
|
+
self._train_neural_net(X, y_onehot, output_size=n_classes, loss_fn=nn.CrossEntropyLoss())
|
314
|
+
X_aug = self._generate_augmented_data(X)
|
315
|
+
self.neural_net.eval()
|
316
|
+
with torch.no_grad():
|
317
|
+
logits_aug = self.neural_net(torch.tensor(X_aug, dtype=torch.float32)).detach().numpy()
|
318
|
+
self._perform_symbolic_regression(X_aug, logits_aug)
|
319
|
+
|
320
|
+
def predict(self, X):
|
321
|
+
"""
|
322
|
+
Predicts class labels for the input data.
|
431
323
|
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
weight = first_layer.combination_weights[i, j].item()
|
437
|
-
|
438
|
-
if abs(weight) > 1e-4:
|
439
|
-
# Improved precision by using a lower threshold
|
440
|
-
edge_formula = edge.get_symbolic_repr(threshold=1e-6)
|
441
|
-
terms = []
|
442
|
-
for term in edge_formula.split(" + "):
|
443
|
-
if term and term != "0":
|
444
|
-
if "*" in term:
|
445
|
-
coef, rest = term.split("*", 1)
|
446
|
-
coef = float(coef) * weight
|
447
|
-
terms.append(f"{coef:.4f}*{rest}")
|
448
|
-
else:
|
449
|
-
terms.append(f"{float(term) * weight:.4f}")
|
450
|
-
|
451
|
-
formulas[i][j] = " + ".join(terms) if terms else "0"
|
452
|
-
else:
|
453
|
-
formulas[i][j] = "0"
|
324
|
+
Parameters:
|
325
|
+
-----------
|
326
|
+
X : array-like of shape (n_samples, n_features)
|
327
|
+
Input data.
|
454
328
|
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
329
|
+
Returns:
|
330
|
+
--------
|
331
|
+
y_pred : ndarray of shape (n_samples,)
|
332
|
+
Predicted class labels.
|
333
|
+
"""
|
334
|
+
if self.symbolic_model is None:
|
335
|
+
raise ValueError("Model not fitted yet.")
|
336
|
+
X = np.asarray(X)
|
337
|
+
X_transformed = evaluate_basis_functions(X, self.symbolic_model['basis_functions'],
|
338
|
+
self.symbolic_model['n_features'])
|
339
|
+
logits = np.dot(X_transformed, np.array(self.symbolic_model['coefficients_list']).T)
|
340
|
+
probabilities = nn.functional.softmax(torch.tensor(logits), dim=1).numpy()
|
341
|
+
return self.classes_[np.argmax(probabilities, axis=1)]
|