oikan 0.0.2.5__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/model.py CHANGED
@@ -1,481 +1,341 @@
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
20
11
 
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)
12
+ class OIKAN(ABC):
13
+ """
14
+ Base class for the OIKAN neuro-symbolic framework.
46
15
 
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
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
- 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)
55
+ @abstractmethod
56
+ def fit(self, X, y):
57
+ pass
91
58
 
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"
59
+ @abstractmethod
60
+ def predict(self, X):
61
+ pass
110
62
 
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
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
- 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
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 save_symbolic_formula(self, filename="outputs/symbolic_formula.txt"):
144
- """Save the cached symbolic formulas to file for production use.
81
+ def feature_importances(self):
82
+ """
83
+ Computes the importance of each original feature based on the symbolic model.
145
84
 
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.
85
+ Returns:
86
+ --------
87
+ numpy.ndarray : Normalized feature importances.
151
88
  """
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")
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
- 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}")
187
-
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")
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
- weights = self.model[0].combination_weights.detach().cpu().numpy()
194
- return np.mean(np.abs(weights), axis=1)
112
+ total = importances.sum()
113
+ return importances / total if total > 0 else importances
195
114
 
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
200
-
201
- if isinstance(x, (list, tuple)):
202
- x = np.array(x)
115
+ def save(self, path):
116
+ """
117
+ Saves the symbolic model to a .json file.
203
118
 
204
- total = torch.zeros_like(ensure_tensor(x))
205
- pattern = re.compile(r"(-?\d+\.\d+)\*?([\w\(\)\^]+)")
206
- matches = pattern.findall(formula)
119
+ Parameters:
120
+ -----------
121
+ path : str
122
+ File path to save the model. Should end with .json
123
+ """
124
+ if self.symbolic_model is None:
125
+ raise ValueError("Model not fitted yet.")
126
+
127
+ if not path.endswith('.json'):
128
+ path = path + '.json'
129
+
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
+ }
207
136
 
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
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()
222
143
 
223
- return total.cpu().numpy() if isinstance(total, torch.Tensor) else total
144
+ with open(path, 'w') as f:
145
+ json.dump(model_data, f, indent=2)
224
146
 
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")
229
-
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))
147
+ def load(self, path):
148
+ """
149
+ Loads the symbolic model from a .json file.
233
150
 
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)}")
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'
158
+
159
+ with open(path, 'r') as f:
160
+ model_data = json.load(f)
243
161
 
244
- return predictions
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'])
245
174
 
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
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()
271
184
 
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_'])
185
+ if self.verbose:
186
+ from tqdm import tqdm
187
+ epoch_iterator = tqdm(range(self.epochs), desc="Training")
298
188
  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}")
189
+ epoch_iterator = range(self.epochs)
302
190
 
303
- def get_loss_history(self):
304
- """Retrieve training loss history."""
305
- return self.loss_history
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()
306
200
 
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
201
+ if self.verbose:
202
+ epoch_iterator.set_postfix({'loss': f'{total_loss/len(loader):.4f}'})
346
203
 
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()
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)
355
213
 
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
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
+ }
391
249
 
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()
250
+ class OIKANRegressor(OIKAN):
251
+ """OIKAN model for regression tasks."""
252
+ def fit(self, X, y):
253
+ """
254
+ Fits the regressor to the data.
255
+
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()
398
268
  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()
269
+ y_aug = self.neural_net(torch.tensor(X_aug, dtype=torch.float32)).detach().numpy()
270
+ self._perform_symbolic_regression(X_aug, y_aug)
405
271
 
406
272
  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)
273
+ """
274
+ Predicts target values for the input data.
437
275
 
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)
276
+ Parameters:
277
+ -----------
278
+ X : array-like of shape (n_samples, n_features)
279
+ Input data.
441
280
 
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")
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.
448
298
 
449
- n_features = self.model[0].input_dim
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_
450
311
  n_classes = len(self.classes_)
451
- formulas = [[[] for _ in range(n_classes)] for _ in range(n_features)]
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.
452
323
 
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"
324
+ Parameters:
325
+ -----------
326
+ X : array-like of shape (n_samples, n_features)
327
+ Input data.
475
328
 
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)]
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)]