oikan 0.0.2.5__py3-none-any.whl → 0.0.3.2__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 +359 -441
- oikan/neural.py +43 -0
- oikan/utils.py +59 -49
- oikan-0.0.3.2.dist-info/METADATA +233 -0
- oikan-0.0.3.2.dist-info/RECORD +10 -0
- {oikan-0.0.2.5.dist-info → oikan-0.0.3.2.dist-info}/WHEEL +1 -1
- oikan-0.0.2.5.dist-info/METADATA +0 -195
- oikan-0.0.2.5.dist-info/RECORD +0 -9
- {oikan-0.0.2.5.dist-info → oikan-0.0.3.2.dist-info}/licenses/LICENSE +0 -0
- {oikan-0.0.2.5.dist-info → oikan-0.0.3.2.dist-info}/top_level.txt +0 -0
oikan/model.py
CHANGED
@@ -1,481 +1,399 @@
|
|
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
|
-
|
11
|
-
|
12
|
-
|
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
|
11
|
+
from sklearn.model_selection import train_test_split
|
12
|
+
from sklearn.metrics import r2_score, accuracy_score
|
13
|
+
import sys
|
20
14
|
|
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)
|
15
|
+
class OIKAN(ABC):
|
16
|
+
"""
|
17
|
+
Base class for the OIKAN neuro-symbolic framework.
|
46
18
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
for
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
19
|
+
Parameters:
|
20
|
+
-----------
|
21
|
+
hidden_sizes : list, optional (default=[64, 64])
|
22
|
+
List of hidden layer sizes for the neural network.
|
23
|
+
activation : str, optional (default='relu')
|
24
|
+
Activation function for the neural network ('relu', 'tanh', 'leaky_relu', 'elu', 'swish', 'gelu').
|
25
|
+
augmentation_factor : int, optional (default=10)
|
26
|
+
Number of augmented samples per original sample.
|
27
|
+
polynomial_degree : int, optional (default=2)
|
28
|
+
Maximum degree of polynomial features for symbolic regression.
|
29
|
+
alpha : float, optional (default=0.1)
|
30
|
+
L1 regularization strength for Lasso in symbolic regression.
|
31
|
+
sigma : float, optional (default=0.1)
|
32
|
+
Standard deviation of Gaussian noise for data augmentation.
|
33
|
+
epochs : int, optional (default=100)
|
34
|
+
Number of epochs for neural network training.
|
35
|
+
lr : float, optional (default=0.001)
|
36
|
+
Learning rate for neural network optimization.
|
37
|
+
batch_size : int, optional (default=32)
|
38
|
+
Batch size for neural network training.
|
39
|
+
verbose : bool, optional (default=False)
|
40
|
+
Whether to display training progress.
|
41
|
+
evaluate_nn : bool, optional (default=False)
|
42
|
+
Whether to evaluate neural network performance before full training.
|
43
|
+
"""
|
44
|
+
def __init__(self, hidden_sizes=[64, 64], activation='relu', augmentation_factor=10,
|
45
|
+
polynomial_degree=2, alpha=0.1, sigma=0.1, epochs=100, lr=0.001, batch_size=32,
|
46
|
+
verbose=False, evaluate_nn=False):
|
47
|
+
self.hidden_sizes = hidden_sizes
|
48
|
+
self.activation = activation
|
49
|
+
self.augmentation_factor = augmentation_factor
|
50
|
+
self.polynomial_degree = polynomial_degree
|
51
|
+
self.alpha = alpha
|
52
|
+
self.sigma = sigma
|
53
|
+
self.epochs = epochs
|
54
|
+
self.lr = lr
|
55
|
+
self.batch_size = batch_size
|
56
|
+
self.verbose = verbose
|
57
|
+
self.evaluate_nn = evaluate_nn
|
58
|
+
self.neural_net = None
|
59
|
+
self.symbolic_model = None
|
60
|
+
self.evaluation_done = False
|
61
61
|
|
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)
|
62
|
+
@abstractmethod
|
63
|
+
def fit(self, X, y):
|
64
|
+
pass
|
91
65
|
|
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"
|
66
|
+
@abstractmethod
|
67
|
+
def predict(self, X):
|
68
|
+
pass
|
110
69
|
|
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
|
70
|
+
def get_formula(self):
|
71
|
+
"""Returns the symbolic formula(s) as a string (regression) or list of strings (classification)."""
|
72
|
+
if self.symbolic_model is None:
|
73
|
+
raise ValueError("Model not fitted yet.")
|
74
|
+
basis_functions = self.symbolic_model['basis_functions']
|
75
|
+
if 'coefficients' in self.symbolic_model:
|
76
|
+
coefficients = self.symbolic_model['coefficients']
|
77
|
+
formula = " + ".join([f"{coefficients[i]:.3f}*{basis_functions[i]}"
|
78
|
+
for i in range(len(coefficients)) if coefficients[i] != 0])
|
79
|
+
return formula if formula else "0"
|
80
|
+
else:
|
134
81
|
formulas = []
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
formulas.append(self._process_edge_formula(edge_formula, 1.0))
|
140
|
-
self.symbolic_formula = formulas
|
82
|
+
for c, coef in enumerate(self.symbolic_model['coefficients_list']):
|
83
|
+
formula = " + ".join([f"{coef[i]:.3f}*{basis_functions[i]}"
|
84
|
+
for i in range(len(coef)) if coef[i] != 0])
|
85
|
+
formulas.append(f"Class {self.classes_[c]}: {formula if formula else '0'}")
|
141
86
|
return formulas
|
142
87
|
|
143
|
-
def
|
144
|
-
"""
|
88
|
+
def feature_importances(self):
|
89
|
+
"""
|
90
|
+
Computes the importance of each original feature based on the symbolic model.
|
145
91
|
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
- A general formula, including softmax for classification
|
150
|
-
- Recommendations and performance results.
|
92
|
+
Returns:
|
93
|
+
--------
|
94
|
+
numpy.ndarray : Normalized feature importances.
|
151
95
|
"""
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
96
|
+
if self.symbolic_model is None:
|
97
|
+
raise ValueError("Model not fitted yet.")
|
98
|
+
basis_functions = self.symbolic_model['basis_functions']
|
99
|
+
n_features = self.symbolic_model['n_features']
|
100
|
+
importances = np.zeros(n_features)
|
101
|
+
|
102
|
+
# Handle regression case
|
103
|
+
if 'coefficients' in self.symbolic_model:
|
104
|
+
coefficients = self.symbolic_model['coefficients']
|
105
|
+
for i, func in enumerate(basis_functions):
|
106
|
+
if coefficients[i] != 0:
|
107
|
+
features_involved = get_features_involved(func)
|
108
|
+
for idx in features_involved:
|
109
|
+
importances[idx] += np.abs(coefficients[i])
|
110
|
+
# Handle classification case with multiple coefficient sets
|
167
111
|
else:
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
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")
|
112
|
+
for coef in self.symbolic_model['coefficients_list']:
|
113
|
+
for i, func in enumerate(basis_functions):
|
114
|
+
if coef[i] != 0:
|
115
|
+
features_involved = get_features_involved(func)
|
116
|
+
for idx in features_involved:
|
117
|
+
importances[idx] += np.abs(coef[i])
|
182
118
|
|
183
|
-
|
184
|
-
|
185
|
-
f.write(output)
|
186
|
-
print(f"Symbolic formulas saved to {filename}")
|
119
|
+
total = importances.sum()
|
120
|
+
return importances / total if total > 0 else importances
|
187
121
|
|
188
|
-
def
|
189
|
-
"""
|
190
|
-
|
191
|
-
raise NotFittedError("Model must be fitted before computing scores")
|
122
|
+
def save(self, path):
|
123
|
+
"""
|
124
|
+
Saves the symbolic model to a .json file.
|
192
125
|
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
"""
|
198
|
-
|
199
|
-
|
126
|
+
Parameters:
|
127
|
+
-----------
|
128
|
+
path : str
|
129
|
+
File path to save the model. Should end with .json
|
130
|
+
"""
|
131
|
+
if self.symbolic_model is None:
|
132
|
+
raise ValueError("Model not fitted yet.")
|
133
|
+
|
134
|
+
if not path.endswith('.json'):
|
135
|
+
path = path + '.json'
|
136
|
+
|
137
|
+
# Convert numpy arrays and other non-serializable types to lists
|
138
|
+
model_data = {
|
139
|
+
'n_features': self.symbolic_model['n_features'],
|
140
|
+
'degree': self.symbolic_model['degree'],
|
141
|
+
'basis_functions': self.symbolic_model['basis_functions']
|
142
|
+
}
|
200
143
|
|
201
|
-
if
|
202
|
-
|
144
|
+
if 'coefficients' in self.symbolic_model:
|
145
|
+
model_data['coefficients'] = self.symbolic_model['coefficients']
|
146
|
+
else:
|
147
|
+
model_data['coefficients_list'] = [coef for coef in self.symbolic_model['coefficients_list']]
|
148
|
+
if hasattr(self, 'classes_'):
|
149
|
+
model_data['classes'] = self.classes_.tolist()
|
203
150
|
|
204
|
-
|
205
|
-
|
206
|
-
|
151
|
+
with open(path, 'w') as f:
|
152
|
+
json.dump(model_data, f, indent=2)
|
153
|
+
|
154
|
+
def load(self, path):
|
155
|
+
"""
|
156
|
+
Loads the symbolic model from a .json file.
|
207
157
|
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
158
|
+
Parameters:
|
159
|
+
-----------
|
160
|
+
path : str
|
161
|
+
File path to load the model from. Should end with .json
|
162
|
+
"""
|
163
|
+
if not path.endswith('.json'):
|
164
|
+
path = path + '.json'
|
165
|
+
|
166
|
+
with open(path, 'r') as f:
|
167
|
+
model_data = json.load(f)
|
168
|
+
|
169
|
+
self.symbolic_model = {
|
170
|
+
'n_features': model_data['n_features'],
|
171
|
+
'degree': model_data['degree'],
|
172
|
+
'basis_functions': model_data['basis_functions']
|
173
|
+
}
|
222
174
|
|
223
|
-
|
175
|
+
if 'coefficients' in model_data:
|
176
|
+
self.symbolic_model['coefficients'] = model_data['coefficients']
|
177
|
+
else:
|
178
|
+
self.symbolic_model['coefficients_list'] = model_data['coefficients_list']
|
179
|
+
if 'classes' in model_data:
|
180
|
+
self.classes_ = np.array(model_data['classes'])
|
224
181
|
|
225
|
-
def
|
226
|
-
"""
|
227
|
-
|
228
|
-
raise NotFittedError("Model must be fitted before prediction")
|
182
|
+
def _evaluate_neural_net(self, X, y, output_size, loss_fn):
|
183
|
+
"""Evaluates neural network performance on train-test split."""
|
184
|
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
|
229
185
|
|
230
|
-
|
231
|
-
|
232
|
-
|
186
|
+
input_size = X.shape[1]
|
187
|
+
self.neural_net = TabularNet(input_size, self.hidden_sizes, output_size, self.activation)
|
188
|
+
optimizer = optim.Adam(self.neural_net.parameters(), lr=self.lr)
|
233
189
|
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
190
|
+
# Train on the training set
|
191
|
+
self._train_neural_net(X_train, y_train, output_size, loss_fn)
|
192
|
+
|
193
|
+
# Evaluate on test set
|
194
|
+
self.neural_net.eval()
|
195
|
+
with torch.no_grad():
|
196
|
+
y_pred = self.neural_net(torch.tensor(X_test, dtype=torch.float32))
|
197
|
+
if output_size == 1: # Regression
|
198
|
+
y_pred = y_pred.numpy()
|
199
|
+
score = r2_score(y_test, y_pred)
|
200
|
+
metric_name = "R² Score"
|
201
|
+
else: # Classification
|
202
|
+
y_pred = torch.argmax(y_pred, dim=1).numpy()
|
203
|
+
y_test = torch.argmax(y_test, dim=1).numpy()
|
204
|
+
score = accuracy_score(y_test, y_pred)
|
205
|
+
metric_name = "Accuracy"
|
206
|
+
|
207
|
+
print(f"\nNeural Network Evaluation:")
|
208
|
+
print(f"Train size: {len(X_train)}, Test size: {len(X_test)}")
|
209
|
+
print(f"{metric_name}: {score:.4f}")
|
210
|
+
|
211
|
+
# Ask user for confirmation
|
212
|
+
response = input("\nProceed with full training and symbolic regression? [Y/n]: ").lower()
|
213
|
+
if response not in ['y', 'yes']:
|
214
|
+
sys.exit("Training cancelled by user.")
|
245
215
|
|
246
|
-
|
247
|
-
|
248
|
-
from .utils import ADVANCED_LIB # needed to retrieve basis functions
|
249
|
-
with open(filename, "r") as f:
|
250
|
-
content = f.read()
|
251
|
-
# Regex to extract coefficient and function notation.
|
252
|
-
# Matches patterns like: "(-?\d+\.\d+)\*?([\w\(\)\^]+)"
|
253
|
-
matches = re.findall(r"(-?\d+\.\d+)\*?([\w\(\)\^]+)", content)
|
254
|
-
compiled_terms = []
|
255
|
-
for coef_str, func_name in matches:
|
256
|
-
try:
|
257
|
-
coef = float(coef_str)
|
258
|
-
# Search for a matching basis function in ADVANCED_LIB (e.g. 'x', 'x^2', etc.)
|
259
|
-
for key, (notation, func) in ADVANCED_LIB.items():
|
260
|
-
if notation.strip() == func_name.strip():
|
261
|
-
compiled_terms.append((coef, func))
|
262
|
-
break
|
263
|
-
except Exception:
|
264
|
-
continue
|
265
|
-
def prediction_function(x):
|
266
|
-
pred = 0
|
267
|
-
for coef, func in compiled_terms:
|
268
|
-
pred += coef * func(x)
|
269
|
-
return pred
|
270
|
-
return prediction_function
|
216
|
+
# Retrain on full dataset
|
217
|
+
self._train_neural_net(X, y, output_size, loss_fn)
|
271
218
|
|
272
|
-
def
|
273
|
-
"""
|
274
|
-
if self.
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
raise NotFittedError("No model architecture available. Provide input_dim and output_dim to rebuild the model.")
|
292
|
-
self.model = self._build_network(input_dim, output_dim)
|
293
|
-
loaded = torch.load(filepath, map_location=self.device)
|
294
|
-
if isinstance(loaded, dict) and 'state_dict' in loaded:
|
295
|
-
self.model.load_state_dict(loaded['state_dict'])
|
296
|
-
if 'classes_' in loaded:
|
297
|
-
self.classes_ = torch.tensor(loaded['classes_'])
|
219
|
+
def _train_neural_net(self, X, y, output_size, loss_fn):
|
220
|
+
"""Trains the neural network on the input data."""
|
221
|
+
if self.evaluate_nn and not self.evaluation_done:
|
222
|
+
self.evaluation_done = True
|
223
|
+
self._evaluate_neural_net(X, y, output_size, loss_fn)
|
224
|
+
return
|
225
|
+
|
226
|
+
input_size = X.shape[1]
|
227
|
+
if self.neural_net is None:
|
228
|
+
self.neural_net = TabularNet(input_size, self.hidden_sizes, output_size, self.activation)
|
229
|
+
optimizer = optim.Adam(self.neural_net.parameters(), lr=self.lr)
|
230
|
+
dataset = torch.utils.data.TensorDataset(torch.tensor(X, dtype=torch.float32),
|
231
|
+
torch.tensor(y, dtype=torch.float32))
|
232
|
+
loader = torch.utils.data.DataLoader(dataset, batch_size=self.batch_size, shuffle=True)
|
233
|
+
self.neural_net.train()
|
234
|
+
|
235
|
+
if self.verbose:
|
236
|
+
from tqdm import tqdm
|
237
|
+
epoch_iterator = tqdm(range(self.epochs), desc="Training")
|
298
238
|
else:
|
299
|
-
self.
|
300
|
-
self._is_fitted = True # Mark model as fitted after loading
|
301
|
-
print(f"Model loaded from {filepath}")
|
239
|
+
epoch_iterator = range(self.epochs)
|
302
240
|
|
303
|
-
|
304
|
-
|
305
|
-
|
241
|
+
for epoch in epoch_iterator:
|
242
|
+
total_loss = 0
|
243
|
+
for batch_X, batch_y in loader:
|
244
|
+
optimizer.zero_grad()
|
245
|
+
outputs = self.neural_net(batch_X)
|
246
|
+
loss = loss_fn(outputs, batch_y)
|
247
|
+
loss.backward()
|
248
|
+
optimizer.step()
|
249
|
+
total_loss += loss.item()
|
306
250
|
|
307
|
-
|
308
|
-
|
309
|
-
def fit(self, X, y, epochs=100, lr=0.01, verbose=True):
|
310
|
-
X, y = self._validate_data(X, y)
|
311
|
-
if len(y.shape) == 1:
|
312
|
-
y = y.reshape(-1, 1)
|
313
|
-
|
314
|
-
if self.model is None:
|
315
|
-
self.model = self._build_network(X.shape[1], y.shape[1])
|
316
|
-
|
317
|
-
criterion = nn.MSELoss()
|
318
|
-
optimizer = torch.optim.Adam(self.model.parameters(), lr=lr, weight_decay=1e-5)
|
319
|
-
|
320
|
-
self.model.train()
|
321
|
-
self.loss_history = [] # <-- reset loss history at start of training
|
322
|
-
for epoch in range(epochs):
|
323
|
-
optimizer.zero_grad()
|
324
|
-
y_pred = self.model(X)
|
325
|
-
loss = criterion(y_pred, y)
|
326
|
-
|
327
|
-
if torch.isnan(loss):
|
328
|
-
print("Warning: NaN loss detected, reinitializing model...")
|
329
|
-
self.model = None
|
330
|
-
return self.fit(X, y, epochs, lr/10, verbose)
|
331
|
-
|
332
|
-
loss.backward()
|
333
|
-
|
334
|
-
# Clip gradients
|
335
|
-
torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
|
336
|
-
|
337
|
-
optimizer.step()
|
338
|
-
|
339
|
-
self.loss_history.append(loss.item()) # <-- save loss value for epoch
|
340
|
-
|
341
|
-
if verbose and (epoch + 1) % 10 == 0:
|
342
|
-
print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}")
|
343
|
-
|
344
|
-
self._is_fitted = True
|
345
|
-
return self
|
251
|
+
if self.verbose:
|
252
|
+
epoch_iterator.set_postfix({'loss': f'{total_loss/len(loader):.4f}'})
|
346
253
|
|
347
|
-
def
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
254
|
+
def _generate_augmented_data(self, X):
|
255
|
+
"""Generates augmented data by adding Gaussian noise."""
|
256
|
+
n_samples = X.shape[0]
|
257
|
+
X_aug = []
|
258
|
+
for _ in range(self.augmentation_factor):
|
259
|
+
noise = np.random.normal(0, self.sigma, X.shape)
|
260
|
+
X_perturbed = X + noise
|
261
|
+
X_aug.append(X_perturbed)
|
262
|
+
return np.vstack(X_aug)
|
355
263
|
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
self.
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
264
|
+
def _perform_symbolic_regression(self, X, y):
|
265
|
+
"""Performs symbolic regression using polynomial features and Lasso."""
|
266
|
+
poly = PolynomialFeatures(degree=self.polynomial_degree, include_bias=True)
|
267
|
+
X_poly = poly.fit_transform(X)
|
268
|
+
model = Lasso(alpha=self.alpha, fit_intercept=False)
|
269
|
+
model.fit(X_poly, y)
|
270
|
+
if len(y.shape) == 1 or y.shape[1] == 1:
|
271
|
+
coef = model.coef_.flatten()
|
272
|
+
selected_indices = np.where(np.abs(coef) > 1e-6)[0]
|
273
|
+
self.symbolic_model = {
|
274
|
+
'n_features': X.shape[1],
|
275
|
+
'degree': self.polynomial_degree,
|
276
|
+
'basis_functions': poly.get_feature_names_out()[selected_indices].tolist(),
|
277
|
+
'coefficients': coef[selected_indices].tolist()
|
278
|
+
}
|
279
|
+
else:
|
280
|
+
coefficients_list = []
|
281
|
+
# Note: Using the same basis functions across classes for simplicity
|
282
|
+
selected_indices = set()
|
283
|
+
for c in range(y.shape[1]):
|
284
|
+
coef = model.coef_[c]
|
285
|
+
indices = np.where(np.abs(coef) > 1e-6)[0]
|
286
|
+
selected_indices.update(indices)
|
287
|
+
selected_indices = list(selected_indices)
|
288
|
+
basis_functions = poly.get_feature_names_out()[selected_indices].tolist()
|
289
|
+
for c in range(y.shape[1]):
|
290
|
+
coef = model.coef_[c]
|
291
|
+
coef_selected = coef[selected_indices].tolist()
|
292
|
+
coefficients_list.append(coef_selected)
|
293
|
+
self.symbolic_model = {
|
294
|
+
'n_features': X.shape[1],
|
295
|
+
'degree': self.polynomial_degree,
|
296
|
+
'basis_functions': basis_functions,
|
297
|
+
'coefficients_list': coefficients_list
|
298
|
+
}
|
391
299
|
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
300
|
+
class OIKANRegressor(OIKAN):
|
301
|
+
"""OIKAN model for regression tasks."""
|
302
|
+
def fit(self, X, y):
|
303
|
+
"""
|
304
|
+
Fits the regressor to the data.
|
305
|
+
|
306
|
+
Parameters:
|
307
|
+
-----------
|
308
|
+
X : array-like of shape (n_samples, n_features)
|
309
|
+
Training data.
|
310
|
+
y : array-like of shape (n_samples,)
|
311
|
+
Target values.
|
312
|
+
"""
|
313
|
+
X = np.asarray(X)
|
314
|
+
y = np.asarray(y).reshape(-1, 1)
|
315
|
+
self._train_neural_net(X, y, output_size=1, loss_fn=nn.MSELoss())
|
316
|
+
if self.verbose:
|
317
|
+
print(f"Original data: features shape: {X.shape} | target shape: {y.shape}")
|
318
|
+
X_aug = self._generate_augmented_data(X)
|
319
|
+
self.neural_net.eval()
|
398
320
|
with torch.no_grad():
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
else:
|
404
|
-
return torch.softmax(logits, dim=1).cpu().numpy()
|
321
|
+
y_aug = self.neural_net(torch.tensor(X_aug, dtype=torch.float32)).detach().numpy()
|
322
|
+
if self.verbose:
|
323
|
+
print(f"Augmented data: features shape: {X_aug.shape} | target shape: {y_aug.shape}")
|
324
|
+
self._perform_symbolic_regression(X_aug, y_aug)
|
405
325
|
|
406
326
|
def predict(self, X):
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
def symbolic_predict_proba(self, X):
|
411
|
-
"""Predict class probabilities using only the extracted symbolic formula."""
|
412
|
-
if not self._is_fitted:
|
413
|
-
raise NotFittedError("Model must be fitted before prediction")
|
414
|
-
|
415
|
-
if not isinstance(X, np.ndarray):
|
416
|
-
X = np.array(X)
|
417
|
-
|
418
|
-
# Scale input data similar to training
|
419
|
-
X_scaled = (X - X.mean(axis=0)) / (X.std(axis=0) + 1e-8)
|
420
|
-
|
421
|
-
formulas = self.get_symbolic_formula()
|
422
|
-
n_classes = len(self.classes_)
|
423
|
-
predictions = np.zeros((X.shape[0], n_classes))
|
424
|
-
|
425
|
-
# Evaluate each feature's contribution to each class
|
426
|
-
for i in range(X.shape[1]): # For each feature
|
427
|
-
x = X_scaled[:, i] # Use scaled data
|
428
|
-
for j in range(n_classes): # For each class
|
429
|
-
formula = formulas[i][j]
|
430
|
-
if formula and formula != "0":
|
431
|
-
predictions[:, j] += self._eval_formula(formula, x)
|
432
|
-
|
433
|
-
# Apply softmax with temperature for better separation
|
434
|
-
temperature = 1.0
|
435
|
-
exp_preds = np.exp(predictions / temperature)
|
436
|
-
probas = exp_preds / exp_preds.sum(axis=1, keepdims=True)
|
327
|
+
"""
|
328
|
+
Predicts target values for the input data.
|
437
329
|
|
438
|
-
|
439
|
-
|
440
|
-
|
330
|
+
Parameters:
|
331
|
+
-----------
|
332
|
+
X : array-like of shape (n_samples, n_features)
|
333
|
+
Input data.
|
441
334
|
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
335
|
+
Returns:
|
336
|
+
--------
|
337
|
+
y_pred : ndarray of shape (n_samples,)
|
338
|
+
Predicted values.
|
339
|
+
"""
|
340
|
+
if self.symbolic_model is None:
|
341
|
+
raise ValueError("Model not fitted yet.")
|
342
|
+
X = np.asarray(X)
|
343
|
+
X_transformed = evaluate_basis_functions(X, self.symbolic_model['basis_functions'],
|
344
|
+
self.symbolic_model['n_features'])
|
345
|
+
return np.dot(X_transformed, self.symbolic_model['coefficients'])
|
346
|
+
|
347
|
+
class OIKANClassifier(OIKAN):
|
348
|
+
"""OIKAN model for classification tasks."""
|
349
|
+
def fit(self, X, y):
|
350
|
+
"""
|
351
|
+
Fits the classifier to the data.
|
448
352
|
|
449
|
-
|
353
|
+
Parameters:
|
354
|
+
-----------
|
355
|
+
X : array-like of shape (n_samples, n_features)
|
356
|
+
Training data.
|
357
|
+
y : array-like of shape (n_samples,)
|
358
|
+
Target labels.
|
359
|
+
"""
|
360
|
+
X = np.asarray(X)
|
361
|
+
from sklearn.preprocessing import LabelEncoder
|
362
|
+
le = LabelEncoder()
|
363
|
+
y_encoded = le.fit_transform(y)
|
364
|
+
self.classes_ = le.classes_
|
450
365
|
n_classes = len(self.classes_)
|
451
|
-
|
366
|
+
y_onehot = nn.functional.one_hot(torch.tensor(y_encoded), num_classes=n_classes).float()
|
367
|
+
self._train_neural_net(X, y_onehot, output_size=n_classes, loss_fn=nn.CrossEntropyLoss())
|
368
|
+
if self.verbose:
|
369
|
+
print(f"Original data: features shape: {X.shape} | target shape: {y.shape}")
|
370
|
+
X_aug = self._generate_augmented_data(X)
|
371
|
+
self.neural_net.eval()
|
372
|
+
with torch.no_grad():
|
373
|
+
logits_aug = self.neural_net(torch.tensor(X_aug, dtype=torch.float32)).detach().numpy()
|
374
|
+
if self.verbose:
|
375
|
+
print(f"Augmented data: features shape: {X_aug.shape} | target shape: {logits_aug.shape}")
|
376
|
+
self._perform_symbolic_regression(X_aug, logits_aug)
|
377
|
+
|
378
|
+
def predict(self, X):
|
379
|
+
"""
|
380
|
+
Predicts class labels for the input data.
|
452
381
|
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
weight = first_layer.combination_weights[i, j].item()
|
458
|
-
|
459
|
-
if abs(weight) > 1e-4:
|
460
|
-
# Improved precision by using a lower threshold
|
461
|
-
edge_formula = edge.get_symbolic_repr(threshold=1e-6)
|
462
|
-
terms = []
|
463
|
-
for term in edge_formula.split(" + "):
|
464
|
-
if term and term != "0":
|
465
|
-
if "*" in term:
|
466
|
-
coef, rest = term.split("*", 1)
|
467
|
-
coef = float(coef) * weight
|
468
|
-
terms.append(f"{coef:.4f}*{rest}")
|
469
|
-
else:
|
470
|
-
terms.append(f"{float(term) * weight:.4f}")
|
471
|
-
|
472
|
-
formulas[i][j] = " + ".join(terms) if terms else "0"
|
473
|
-
else:
|
474
|
-
formulas[i][j] = "0"
|
382
|
+
Parameters:
|
383
|
+
-----------
|
384
|
+
X : array-like of shape (n_samples, n_features)
|
385
|
+
Input data.
|
475
386
|
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
387
|
+
Returns:
|
388
|
+
--------
|
389
|
+
y_pred : ndarray of shape (n_samples,)
|
390
|
+
Predicted class labels.
|
391
|
+
"""
|
392
|
+
if self.symbolic_model is None:
|
393
|
+
raise ValueError("Model not fitted yet.")
|
394
|
+
X = np.asarray(X)
|
395
|
+
X_transformed = evaluate_basis_functions(X, self.symbolic_model['basis_functions'],
|
396
|
+
self.symbolic_model['n_features'])
|
397
|
+
logits = np.dot(X_transformed, np.array(self.symbolic_model['coefficients_list']).T)
|
398
|
+
probabilities = nn.functional.softmax(torch.tensor(logits), dim=1).numpy()
|
399
|
+
return self.classes_[np.argmax(probabilities, axis=1)]
|