oikan 0.0.2.4__py3-none-any.whl → 0.0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oikan/model.py CHANGED
@@ -1,460 +1,341 @@
1
+ import numpy as np
1
2
  import torch
2
3
  import torch.nn as nn
3
- import 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)
195
-
196
- def _eval_formula(self, formula, x):
197
- """Helper to evaluate a symbolic formula for an input vector x using ADVANCED_LIB basis functions."""
198
- import re
199
- total = 0
200
- pattern = re.compile(r"(-?\d+\.\d+)\*?([\w\(\)\^]+)")
201
- matches = pattern.findall(formula)
202
- for coef_str, func_name in matches:
203
- try:
204
- coef = float(coef_str)
205
- for key, (notation, func) in ADVANCED_LIB.items():
206
- if notation.strip() == func_name.strip():
207
- total += coef * func(x)
208
- break
209
- except Exception:
210
- continue
211
- return total
212
-
213
- def symbolic_predict(self, X):
214
- """Predict using only the extracted symbolic formula (regressor)."""
215
- if not self._is_fitted:
216
- raise NotFittedError("Model must be fitted before prediction")
217
- X = np.array(X) if not isinstance(X, np.ndarray) else X
218
- formulas = self.get_symbolic_formula() # For regressor: list of formula strings.
219
- predictions = np.zeros((X.shape[0], 1))
220
- for i, formula in enumerate(formulas):
221
- x = X[:, i]
222
- predictions[:, 0] += self._eval_formula(formula, x)
223
- return predictions
112
+ total = importances.sum()
113
+ return importances / total if total > 0 else importances
224
114
 
225
- def compile_symbolic_formula(self, filename="output/final_symbolic_formula.txt"):
226
- import re
227
- from .utils import ADVANCED_LIB # needed to retrieve basis functions
228
- with open(filename, "r") as f:
229
- content = f.read()
230
- # Regex to extract coefficient and function notation.
231
- # Matches patterns like: "(-?\d+\.\d+)\*?([\w\(\)\^]+)"
232
- matches = re.findall(r"(-?\d+\.\d+)\*?([\w\(\)\^]+)", content)
233
- compiled_terms = []
234
- for coef_str, func_name in matches:
235
- try:
236
- coef = float(coef_str)
237
- # Search for a matching basis function in ADVANCED_LIB (e.g. 'x', 'x^2', etc.)
238
- for key, (notation, func) in ADVANCED_LIB.items():
239
- if notation.strip() == func_name.strip():
240
- compiled_terms.append((coef, func))
241
- break
242
- except Exception:
243
- continue
244
- def prediction_function(x):
245
- pred = 0
246
- for coef, func in compiled_terms:
247
- pred += coef * func(x)
248
- return pred
249
- return prediction_function
250
-
251
- def save_model(self, filepath="models/oikan_model.pth"):
252
- """Save the current model's state dictionary and extra attributes to a file."""
253
- if self.model is None:
254
- raise NotFittedError("No model to save. Build and train a model first.")
255
- save_dict = {'state_dict': self.model.state_dict()}
256
- if hasattr(self, "classes_"):
257
- # Save classes_ as a list so that it can be reloaded.
258
- save_dict['classes_'] = self.classes_.tolist()
259
- torch.save(save_dict, filepath)
260
- print(f"Model saved to {filepath}")
261
-
262
- def load_model(self, filepath="models/oikan_model.pth", input_dim=None, output_dim=None):
263
- """Load the model's state dictionary and extra attributes from a file.
115
+ def save(self, path):
116
+ """
117
+ Saves the symbolic model to a .json file.
264
118
 
265
- If the model architecture does not exist, it is automatically rebuilt using provided
266
- input_dim and output_dim.
119
+ Parameters:
120
+ -----------
121
+ path : str
122
+ File path to save the model. Should end with .json
267
123
  """
268
- if self.model is None:
269
- if input_dim is None or output_dim is None:
270
- raise NotFittedError("No model architecture available. Provide input_dim and output_dim to rebuild the model.")
271
- self.model = self._build_network(input_dim, output_dim)
272
- loaded = torch.load(filepath, map_location=self.device)
273
- if isinstance(loaded, dict) and 'state_dict' in loaded:
274
- self.model.load_state_dict(loaded['state_dict'])
275
- if 'classes_' in loaded:
276
- self.classes_ = torch.tensor(loaded['classes_'])
277
- else:
278
- self.model.load_state_dict(loaded)
279
- self._is_fitted = True # Mark model as fitted after loading
280
- print(f"Model loaded from {filepath}")
281
-
282
- def get_loss_history(self):
283
- """Retrieve training loss history."""
284
- return self.loss_history
285
-
286
- class OIKANRegressor(BaseOIKAN, RegressorMixin):
287
- """OIKAN implementation for regression tasks"""
288
- def fit(self, X, y, epochs=100, lr=0.01, verbose=True):
289
- X, y = self._validate_data(X, y)
290
- if len(y.shape) == 1:
291
- y = y.reshape(-1, 1)
124
+ if self.symbolic_model is None:
125
+ raise ValueError("Model not fitted yet.")
292
126
 
293
- if self.model is None:
294
- self.model = self._build_network(X.shape[1], y.shape[1])
127
+ if not path.endswith('.json'):
128
+ path = path + '.json'
295
129
 
296
- criterion = nn.MSELoss()
297
- optimizer = torch.optim.Adam(self.model.parameters(), lr=lr, weight_decay=1e-5)
130
+ # Convert numpy arrays and other non-serializable types to lists
131
+ model_data = {
132
+ 'n_features': self.symbolic_model['n_features'],
133
+ 'degree': self.symbolic_model['degree'],
134
+ 'basis_functions': self.symbolic_model['basis_functions']
135
+ }
298
136
 
299
- self.model.train()
300
- self.loss_history = [] # <-- reset loss history at start of training
301
- for epoch in range(epochs):
302
- optimizer.zero_grad()
303
- y_pred = self.model(X)
304
- loss = criterion(y_pred, y)
305
-
306
- if torch.isnan(loss):
307
- print("Warning: NaN loss detected, reinitializing model...")
308
- self.model = None
309
- return self.fit(X, y, epochs, lr/10, verbose)
310
-
311
- loss.backward()
312
-
313
- # Clip gradients
314
- torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
315
-
316
- optimizer.step()
317
-
318
- self.loss_history.append(loss.item()) # <-- save loss value for epoch
319
-
320
- if verbose and (epoch + 1) % 10 == 0:
321
- print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}")
322
-
323
- self._is_fitted = True
324
- return self
325
-
326
- def predict(self, X):
327
- if not self._is_fitted:
328
- raise NotFittedError("Model must be fitted before prediction")
329
-
330
- X = self._validate_data(X)[0]
331
- self.model.eval()
332
- with torch.no_grad():
333
- return self.model(X).cpu().numpy()
334
-
335
- class OIKANClassifier(BaseOIKAN, ClassifierMixin):
336
- """OIKAN implementation for classification tasks"""
337
- def fit(self, X, y, epochs=100, lr=0.01, verbose=True):
338
- X, y = self._validate_data(X, y)
339
- self.classes_ = torch.unique(y)
340
- n_classes = len(self.classes_)
137
+ if 'coefficients' in self.symbolic_model:
138
+ model_data['coefficients'] = self.symbolic_model['coefficients']
139
+ else:
140
+ model_data['coefficients_list'] = [coef for coef in self.symbolic_model['coefficients_list']]
141
+ if hasattr(self, 'classes_'):
142
+ model_data['classes'] = self.classes_.tolist()
341
143
 
342
- if self.model is None:
343
- self.model = self._build_network(X.shape[1], 1 if n_classes == 2 else n_classes)
344
-
345
- criterion = (nn.BCEWithLogitsLoss() if n_classes == 2
346
- else nn.CrossEntropyLoss())
347
- optimizer = torch.optim.Adam(self.model.parameters(), lr=lr)
144
+ with open(path, 'w') as f:
145
+ json.dump(model_data, f, indent=2)
146
+
147
+ def load(self, path):
148
+ """
149
+ Loads the symbolic model from a .json file.
348
150
 
349
- self.model.train()
350
- self.loss_history = [] # <-- reset loss history at start of training
351
- for epoch in range(epochs):
352
- optimizer.zero_grad()
353
- logits = self.model(X)
354
- if n_classes == 2:
355
- y_tensor = y.float()
356
- logits = logits.squeeze()
357
- else:
358
- y_tensor = y.long()
359
- loss = criterion(logits, y_tensor)
360
- loss.backward()
361
- optimizer.step()
362
-
363
- self.loss_history.append(loss.item()) # <-- save loss value for epoch
151
+ Parameters:
152
+ -----------
153
+ path : str
154
+ File path to load the model from. Should end with .json
155
+ """
156
+ if not path.endswith('.json'):
157
+ path = path + '.json'
364
158
 
365
- if verbose and (epoch + 1) % 10 == 0:
366
- print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}")
159
+ with open(path, 'r') as f:
160
+ model_data = json.load(f)
367
161
 
368
- self._is_fitted = True
369
- return self
162
+ self.symbolic_model = {
163
+ 'n_features': model_data['n_features'],
164
+ 'degree': model_data['degree'],
165
+ 'basis_functions': model_data['basis_functions']
166
+ }
167
+
168
+ if 'coefficients' in model_data:
169
+ self.symbolic_model['coefficients'] = model_data['coefficients']
170
+ else:
171
+ self.symbolic_model['coefficients_list'] = model_data['coefficients_list']
172
+ if 'classes' in model_data:
173
+ self.classes_ = np.array(model_data['classes'])
370
174
 
371
- def predict_proba(self, X):
372
- if not self._is_fitted:
373
- raise NotFittedError("Model must be fitted before prediction")
374
-
375
- X = self._validate_data(X)[0]
376
- self.model.eval()
377
- with torch.no_grad():
378
- logits = self.model(X)
379
- if len(self.classes_) == 2:
380
- probs = torch.sigmoid(logits)
381
- return np.column_stack([1 - probs.cpu().numpy(), probs.cpu().numpy()])
382
- else:
383
- return torch.softmax(logits, dim=1).cpu().numpy()
175
+ def _train_neural_net(self, X, y, output_size, loss_fn):
176
+ """Trains the neural network on the input data."""
177
+ input_size = X.shape[1]
178
+ self.neural_net = TabularNet(input_size, self.hidden_sizes, output_size, self.activation)
179
+ optimizer = optim.Adam(self.neural_net.parameters(), lr=self.lr)
180
+ dataset = torch.utils.data.TensorDataset(torch.tensor(X, dtype=torch.float32),
181
+ torch.tensor(y, dtype=torch.float32))
182
+ loader = torch.utils.data.DataLoader(dataset, batch_size=self.batch_size, shuffle=True)
183
+ self.neural_net.train()
384
184
 
385
- def predict(self, X):
386
- proba = self.predict_proba(X)
387
- return self.classes_[np.argmax(proba, axis=1)]
185
+ if self.verbose:
186
+ from tqdm import tqdm
187
+ epoch_iterator = tqdm(range(self.epochs), desc="Training")
188
+ else:
189
+ epoch_iterator = range(self.epochs)
388
190
 
389
- def symbolic_predict_proba(self, X):
390
- """Predict class probabilities using only the extracted symbolic formula."""
391
- if not self._is_fitted:
392
- raise NotFittedError("Model must be fitted before prediction")
393
-
394
- if not isinstance(X, np.ndarray):
395
- X = np.array(X)
396
-
397
- # Scale input data similar to training
398
- X_scaled = (X - X.mean(axis=0)) / (X.std(axis=0) + 1e-8)
399
-
400
- formulas = self.get_symbolic_formula()
401
- n_classes = len(self.classes_)
402
- predictions = np.zeros((X.shape[0], n_classes))
403
-
404
- # Evaluate each feature's contribution to each class
405
- for i in range(X.shape[1]): # For each feature
406
- x = X_scaled[:, i] # Use scaled data
407
- for j in range(n_classes): # For each class
408
- formula = formulas[i][j]
409
- if formula and formula != "0":
410
- predictions[:, j] += self._eval_formula(formula, x)
191
+ for epoch in epoch_iterator:
192
+ total_loss = 0
193
+ for batch_X, batch_y in loader:
194
+ optimizer.zero_grad()
195
+ outputs = self.neural_net(batch_X)
196
+ loss = loss_fn(outputs, batch_y)
197
+ loss.backward()
198
+ optimizer.step()
199
+ total_loss += loss.item()
200
+
201
+ if self.verbose:
202
+ epoch_iterator.set_postfix({'loss': f'{total_loss/len(loader):.4f}'})
203
+
204
+ def _generate_augmented_data(self, X):
205
+ """Generates augmented data by adding Gaussian noise."""
206
+ n_samples = X.shape[0]
207
+ X_aug = []
208
+ for _ in range(self.augmentation_factor):
209
+ noise = np.random.normal(0, self.sigma, X.shape)
210
+ X_perturbed = X + noise
211
+ X_aug.append(X_perturbed)
212
+ return np.vstack(X_aug)
213
+
214
+ def _perform_symbolic_regression(self, X, y):
215
+ """Performs symbolic regression using polynomial features and Lasso."""
216
+ poly = PolynomialFeatures(degree=self.polynomial_degree, include_bias=True)
217
+ X_poly = poly.fit_transform(X)
218
+ model = Lasso(alpha=self.alpha, fit_intercept=False)
219
+ model.fit(X_poly, y)
220
+ if len(y.shape) == 1 or y.shape[1] == 1:
221
+ coef = model.coef_.flatten()
222
+ selected_indices = np.where(np.abs(coef) > 1e-6)[0]
223
+ self.symbolic_model = {
224
+ 'n_features': X.shape[1],
225
+ 'degree': self.polynomial_degree,
226
+ 'basis_functions': poly.get_feature_names_out()[selected_indices].tolist(),
227
+ 'coefficients': coef[selected_indices].tolist()
228
+ }
229
+ else:
230
+ coefficients_list = []
231
+ # Note: Using the same basis functions across classes for simplicity
232
+ selected_indices = set()
233
+ for c in range(y.shape[1]):
234
+ coef = model.coef_[c]
235
+ indices = np.where(np.abs(coef) > 1e-6)[0]
236
+ selected_indices.update(indices)
237
+ selected_indices = list(selected_indices)
238
+ basis_functions = poly.get_feature_names_out()[selected_indices].tolist()
239
+ for c in range(y.shape[1]):
240
+ coef = model.coef_[c]
241
+ coef_selected = coef[selected_indices].tolist()
242
+ coefficients_list.append(coef_selected)
243
+ self.symbolic_model = {
244
+ 'n_features': X.shape[1],
245
+ 'degree': self.polynomial_degree,
246
+ 'basis_functions': basis_functions,
247
+ 'coefficients_list': coefficients_list
248
+ }
249
+
250
+ class OIKANRegressor(OIKAN):
251
+ """OIKAN model for regression tasks."""
252
+ def fit(self, X, y):
253
+ """
254
+ Fits the regressor to the data.
411
255
 
412
- # Apply softmax with temperature for better separation
413
- temperature = 1.0
414
- exp_preds = np.exp(predictions / temperature)
415
- probas = exp_preds / exp_preds.sum(axis=1, keepdims=True)
256
+ Parameters:
257
+ -----------
258
+ X : array-like of shape (n_samples, n_features)
259
+ Training data.
260
+ y : array-like of shape (n_samples,)
261
+ Target values.
262
+ """
263
+ X = np.asarray(X)
264
+ y = np.asarray(y).reshape(-1, 1)
265
+ self._train_neural_net(X, y, output_size=1, loss_fn=nn.MSELoss())
266
+ X_aug = self._generate_augmented_data(X)
267
+ self.neural_net.eval()
268
+ with torch.no_grad():
269
+ y_aug = self.neural_net(torch.tensor(X_aug, dtype=torch.float32)).detach().numpy()
270
+ self._perform_symbolic_regression(X_aug, y_aug)
271
+
272
+ def predict(self, X):
273
+ """
274
+ Predicts target values for the input data.
416
275
 
417
- # Clip probabilities to avoid numerical issues
418
- probas = np.clip(probas, 1e-7, 1.0)
419
- probas = probas / probas.sum(axis=1, keepdims=True)
276
+ Parameters:
277
+ -----------
278
+ X : array-like of shape (n_samples, n_features)
279
+ Input data.
420
280
 
421
- return probas
422
-
423
- def get_symbolic_formula(self):
424
- """Extract symbolic formulas for all features and outputs."""
425
- if not self._is_fitted:
426
- 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.
427
298
 
428
- 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_
429
311
  n_classes = len(self.classes_)
430
- 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.
431
323
 
432
- first_layer = self.model[0]
433
- for i in range(n_features):
434
- for j in range(n_classes):
435
- edge = first_layer.edges[i][j]
436
- weight = first_layer.combination_weights[i, j].item()
437
-
438
- if abs(weight) > 1e-4:
439
- # Improved precision by using a lower threshold
440
- edge_formula = edge.get_symbolic_repr(threshold=1e-6)
441
- terms = []
442
- for term in edge_formula.split(" + "):
443
- if term and term != "0":
444
- if "*" in term:
445
- coef, rest = term.split("*", 1)
446
- coef = float(coef) * weight
447
- terms.append(f"{coef:.4f}*{rest}")
448
- else:
449
- terms.append(f"{float(term) * weight:.4f}")
450
-
451
- formulas[i][j] = " + ".join(terms) if terms else "0"
452
- else:
453
- formulas[i][j] = "0"
324
+ Parameters:
325
+ -----------
326
+ X : array-like of shape (n_samples, n_features)
327
+ Input data.
454
328
 
455
- return formulas
456
-
457
- def symbolic_predict(self, X):
458
- """Predict classes using only the extracted symbolic formula."""
459
- proba = self.symbolic_predict_proba(X)
460
- 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)]