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/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 numpy as np
4
- from sklearn.base import BaseEstimator, RegressorMixin, ClassifierMixin
5
- from .utils import ADVANCED_LIB, EdgeActivation
6
- from .exceptions import *
7
- from datetime import datetime as dt
8
-
9
- class SymbolicEdge(nn.Module):
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
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 KANLayer(nn.Module):
22
- """Kolmogorov-Arnold Network layer with interpretable edges"""
23
- def __init__(self, input_dim, output_dim):
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
- def get_symbolic_formula(self):
48
- """Extract interpretable formulas for each output"""
49
- formulas = []
50
- for j in range(self.output_dim):
51
- terms = []
52
- for i in range(self.input_dim):
53
- weight = self.combination_weights[i, j].item()
54
- if abs(weight) > 1e-4:
55
- # Pass lower threshold for improved precision
56
- edge_formula = self.edges[i][j].get_symbolic_repr(threshold=1e-6)
57
- if edge_formula != "0":
58
- terms.append(f"({weight:.4f} * ({edge_formula}))")
59
- formulas.append(" + ".join(terms) if terms else "0")
60
- return formulas
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
- class BaseOIKAN(BaseEstimator):
63
- """Base OIKAN model implementing common functionality"""
64
- def __init__(self, hidden_dims=[32, 16], dropout=0.1):
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
- def _process_edge_formula(self, edge_formula, weight):
93
- """Helper to scale symbolic formula terms by a given weight"""
94
- terms = []
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 get_symbolic_formula(self):
112
- """Generate and cache symbolic formulas for production‐ready inference."""
113
- if not self._is_fitted:
114
- raise NotFittedError("Model must be fitted before extracting formulas")
115
- if hasattr(self, "symbolic_formula"):
116
- return self.symbolic_formula
117
- if hasattr(self, 'classes_'): # Classifier
118
- n_features = self.model[0].input_dim
119
- n_classes = len(self.classes_)
120
- formulas = [[None for _ in range(n_classes)] for _ in range(n_features)]
121
- first_layer = self.model[0]
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
- first_layer = self.model[0]
136
- for i in range(first_layer.input_dim):
137
- # Use improved threshold for formula extraction in regressor branch
138
- edge_formula = first_layer.edges[i][0].get_symbolic_repr(threshold=1e-6)
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 save_symbolic_formula(self, filename="outputs/symbolic_formula.txt"):
144
- """Save the cached symbolic formulas to file for production use.
88
+ def feature_importances(self):
89
+ """
90
+ Computes the importance of each original feature based on the symbolic model.
145
91
 
146
- The file will contain:
147
- - A header with the version and timestamp
148
- - The symbolic formulas for each feature (and class for classification)
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
- header = f"Generated by {self.__name} | Timestamp: {dt.now()}\n\n"
153
- header += "Symbolic Formulas:\n"
154
- header += "====================\n"
155
- formulas = self.get_symbolic_formula()
156
- formulas_text = ""
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")
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
- # 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")
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
- output = header + formulas_text + general + recs + disclaimer
184
- with open(filename, "w") as f:
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 get_feature_scores(self):
189
- """Get feature importance scores based on edge weights."""
190
- if not self._is_fitted:
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
- weights = self.model[0].combination_weights.detach().cpu().numpy()
194
- return np.mean(np.abs(weights), axis=1)
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
- from .utils import ensure_tensor
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 isinstance(x, (list, tuple)):
202
- x = np.array(x)
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
- total = torch.zeros_like(ensure_tensor(x))
205
- pattern = re.compile(r"(-?\d+\.\d+)\*?([\w\(\)\^]+)")
206
- matches = pattern.findall(formula)
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
- for coef_str, func_name in matches:
209
- try:
210
- coef = float(coef_str)
211
- for key, (notation, func) in ADVANCED_LIB.items():
212
- if notation.strip() == func_name.strip():
213
- result = func(x)
214
- if isinstance(result, torch.Tensor):
215
- total += coef * result
216
- else:
217
- total += coef * ensure_tensor(result)
218
- break
219
- except Exception as e:
220
- print(f"Warning: Error evaluating term {coef_str}*{func_name}: {str(e)}")
221
- continue
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
- return total.cpu().numpy() if isinstance(total, torch.Tensor) else total
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 symbolic_predict(self, X):
226
- """Predict using only the extracted symbolic formula (regressor)."""
227
- if not self._is_fitted:
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
- X = np.array(X) if not isinstance(X, np.ndarray) else X
231
- formulas = self.get_symbolic_formula()
232
- predictions = np.zeros((X.shape[0], 1))
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
- try:
235
- for i, formula in enumerate(formulas):
236
- x = X[:, i]
237
- pred = self._eval_formula(formula, x)
238
- if isinstance(pred, torch.Tensor):
239
- pred = pred.cpu().numpy()
240
- predictions[:, 0] += pred
241
- except Exception as e:
242
- raise RuntimeError(f"Error in symbolic prediction: {str(e)}")
243
-
244
- return predictions
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
- def compile_symbolic_formula(self, filename="output/final_symbolic_formula.txt"):
247
- import re
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 save_model(self, filepath="models/oikan_model.pth"):
273
- """Save the current model's state dictionary and extra attributes to a file."""
274
- if self.model is None:
275
- raise NotFittedError("No model to save. Build and train a model first.")
276
- save_dict = {'state_dict': self.model.state_dict()}
277
- if hasattr(self, "classes_"):
278
- # Save classes_ as a list so that it can be reloaded.
279
- save_dict['classes_'] = self.classes_.tolist()
280
- torch.save(save_dict, filepath)
281
- print(f"Model saved to {filepath}")
282
-
283
- def load_model(self, filepath="models/oikan_model.pth", input_dim=None, output_dim=None):
284
- """Load the model's state dictionary and extra attributes from a file.
285
-
286
- If the model architecture does not exist, it is automatically rebuilt using provided
287
- input_dim and output_dim.
288
- """
289
- if self.model is None:
290
- if input_dim is None or output_dim is None:
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.model.load_state_dict(loaded)
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
- def get_loss_history(self):
304
- """Retrieve training loss history."""
305
- return self.loss_history
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
- class OIKANRegressor(BaseOIKAN, RegressorMixin):
308
- """OIKAN implementation for regression tasks"""
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 predict(self, X):
348
- if not self._is_fitted:
349
- raise NotFittedError("Model must be fitted before prediction")
350
-
351
- X = self._validate_data(X)[0]
352
- self.model.eval()
353
- with torch.no_grad():
354
- return self.model(X).cpu().numpy()
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
- class OIKANClassifier(BaseOIKAN, ClassifierMixin):
357
- """OIKAN implementation for classification tasks"""
358
- def fit(self, X, y, epochs=100, lr=0.01, verbose=True):
359
- X, y = self._validate_data(X, y)
360
- self.classes_ = torch.unique(y)
361
- n_classes = len(self.classes_)
362
-
363
- if self.model is None:
364
- self.model = self._build_network(X.shape[1], 1 if n_classes == 2 else n_classes)
365
-
366
- criterion = (nn.BCEWithLogitsLoss() if n_classes == 2
367
- else nn.CrossEntropyLoss())
368
- optimizer = torch.optim.Adam(self.model.parameters(), lr=lr)
369
-
370
- self.model.train()
371
- self.loss_history = [] # <-- reset loss history at start of training
372
- for epoch in range(epochs):
373
- optimizer.zero_grad()
374
- logits = self.model(X)
375
- if n_classes == 2:
376
- y_tensor = y.float()
377
- logits = logits.squeeze()
378
- else:
379
- y_tensor = y.long()
380
- loss = criterion(logits, y_tensor)
381
- loss.backward()
382
- optimizer.step()
383
-
384
- self.loss_history.append(loss.item()) # <-- save loss value for epoch
385
-
386
- if verbose and (epoch + 1) % 10 == 0:
387
- print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}")
388
-
389
- self._is_fitted = True
390
- return self
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
- def predict_proba(self, X):
393
- if not self._is_fitted:
394
- raise NotFittedError("Model must be fitted before prediction")
395
-
396
- X = self._validate_data(X)[0]
397
- self.model.eval()
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
- logits = self.model(X)
400
- if len(self.classes_) == 2:
401
- probs = torch.sigmoid(logits)
402
- return np.column_stack([1 - probs.cpu().numpy(), probs.cpu().numpy()])
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
- proba = self.predict_proba(X)
408
- return self.classes_[np.argmax(proba, axis=1)]
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
- # Clip probabilities to avoid numerical issues
439
- probas = np.clip(probas, 1e-7, 1.0)
440
- probas = probas / probas.sum(axis=1, keepdims=True)
330
+ Parameters:
331
+ -----------
332
+ X : array-like of shape (n_samples, n_features)
333
+ Input data.
441
334
 
442
- return probas
443
-
444
- def get_symbolic_formula(self):
445
- """Extract symbolic formulas for all features and outputs."""
446
- if not self._is_fitted:
447
- raise NotFittedError("Model must be fitted before extracting formulas")
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
- n_features = self.model[0].input_dim
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
- formulas = [[[] for _ in range(n_classes)] for _ in range(n_features)]
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
- first_layer = self.model[0]
454
- for i in range(n_features):
455
- for j in range(n_classes):
456
- edge = first_layer.edges[i][j]
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
- return formulas
477
-
478
- def symbolic_predict(self, X):
479
- """Predict classes using only the extracted symbolic formula."""
480
- proba = self.symbolic_predict_proba(X)
481
- return self.classes_[np.argmax(proba, axis=1)]
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)]