s3db.js 12.3.0 → 13.0.0

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.
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Classification Model
3
+ *
4
+ * Binary and multi-class classification using TensorFlow.js
5
+ * Predicts categorical labels/classes
6
+ */
7
+
8
+ import { BaseModel } from './base-model.class.js';
9
+ import { ModelConfigError, DataValidationError } from '../ml.errors.js';
10
+
11
+ export class ClassificationModel extends BaseModel {
12
+ constructor(config = {}) {
13
+ super(config);
14
+
15
+ // Classification-specific config
16
+ this.config.modelConfig = {
17
+ ...this.config.modelConfig,
18
+ units: config.modelConfig?.units || 64, // Hidden layer units
19
+ activation: config.modelConfig?.activation || 'relu',
20
+ dropout: config.modelConfig?.dropout || 0.2 // Dropout rate for regularization
21
+ };
22
+
23
+ // Class mapping (label -> index)
24
+ this.classes = [];
25
+ this.classToIndex = {};
26
+ this.indexToClass = {};
27
+ }
28
+
29
+ /**
30
+ * Build classification model architecture
31
+ */
32
+ buildModel() {
33
+ const numFeatures = this.config.features.length;
34
+ const numClasses = this.classes.length;
35
+
36
+ if (numClasses < 2) {
37
+ throw new ModelConfigError(
38
+ 'Classification requires at least 2 classes',
39
+ { model: this.config.name, numClasses }
40
+ );
41
+ }
42
+
43
+ // Create sequential model
44
+ this.model = this.tf.sequential();
45
+
46
+ // Input + first hidden layer
47
+ this.model.add(this.tf.layers.dense({
48
+ inputShape: [numFeatures],
49
+ units: this.config.modelConfig.units,
50
+ activation: this.config.modelConfig.activation,
51
+ useBias: true
52
+ }));
53
+
54
+ // Dropout for regularization
55
+ if (this.config.modelConfig.dropout > 0) {
56
+ this.model.add(this.tf.layers.dropout({
57
+ rate: this.config.modelConfig.dropout
58
+ }));
59
+ }
60
+
61
+ // Second hidden layer
62
+ this.model.add(this.tf.layers.dense({
63
+ units: Math.floor(this.config.modelConfig.units / 2),
64
+ activation: this.config.modelConfig.activation
65
+ }));
66
+
67
+ // Output layer
68
+ const isBinary = numClasses === 2;
69
+ this.model.add(this.tf.layers.dense({
70
+ units: isBinary ? 1 : numClasses,
71
+ activation: isBinary ? 'sigmoid' : 'softmax'
72
+ }));
73
+
74
+ // Compile model
75
+ this.model.compile({
76
+ optimizer: this.tf.train.adam(this.config.modelConfig.learningRate),
77
+ loss: isBinary ? 'binaryCrossentropy' : 'categoricalCrossentropy',
78
+ metrics: ['accuracy']
79
+ });
80
+
81
+ if (this.config.verbose) {
82
+ console.log(`[MLPlugin] ${this.config.name} - Built classification model (${numClasses} classes, ${isBinary ? 'binary' : 'multi-class'})`);
83
+ this.model.summary();
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Prepare training data (override to handle class labels)
89
+ * @private
90
+ */
91
+ _prepareData(data) {
92
+ const features = [];
93
+ const targets = [];
94
+
95
+ // Extract unique classes
96
+ const uniqueClasses = [...new Set(data.map(r => r[this.config.target]))];
97
+ this.classes = uniqueClasses.sort();
98
+
99
+ // Build class mappings
100
+ this.classes.forEach((cls, idx) => {
101
+ this.classToIndex[cls] = idx;
102
+ this.indexToClass[idx] = cls;
103
+ });
104
+
105
+ if (this.config.verbose) {
106
+ console.log(`[MLPlugin] ${this.config.name} - Detected ${this.classes.length} classes:`, this.classes);
107
+ }
108
+
109
+ for (const record of data) {
110
+ // Validate record has required fields
111
+ const missingFeatures = this.config.features.filter(f => !(f in record));
112
+ if (missingFeatures.length > 0) {
113
+ throw new DataValidationError(
114
+ `Missing features in training data: ${missingFeatures.join(', ')}`,
115
+ { model: this.config.name, missingFeatures, record }
116
+ );
117
+ }
118
+
119
+ if (!(this.config.target in record)) {
120
+ throw new DataValidationError(
121
+ `Missing target "${this.config.target}" in training data`,
122
+ { model: this.config.name, target: this.config.target, record }
123
+ );
124
+ }
125
+
126
+ // Extract features
127
+ const featureValues = this._extractFeatures(record);
128
+ features.push(featureValues);
129
+
130
+ // Extract target (class label)
131
+ const targetClass = record[this.config.target];
132
+ if (!(targetClass in this.classToIndex)) {
133
+ throw new DataValidationError(
134
+ `Unknown class "${targetClass}" in training data`,
135
+ { model: this.config.name, targetClass, knownClasses: this.classes }
136
+ );
137
+ }
138
+
139
+ targets.push(this.classToIndex[targetClass]);
140
+ }
141
+
142
+ // Calculate normalization parameters for features
143
+ this._calculateNormalizer(features, targets);
144
+
145
+ // Normalize features only (not targets)
146
+ const normalizedFeatures = features.map(f => this._normalizeFeatures(f));
147
+
148
+ // Convert to tensors
149
+ return {
150
+ xs: this.tf.tensor2d(normalizedFeatures),
151
+ ys: this._prepareTargetTensor(targets)
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Prepare target tensor for classification (one-hot encoding or binary)
157
+ * @protected
158
+ */
159
+ _prepareTargetTensor(targets) {
160
+ const isBinary = this.classes.length === 2;
161
+
162
+ if (isBinary) {
163
+ // Binary classification: [0, 1] labels
164
+ return this.tf.tensor2d(targets.map(t => [t]));
165
+ } else {
166
+ // Multi-class: one-hot encoding
167
+ return this.tf.oneHot(targets, this.classes.length);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Calculate normalization parameters (skip target normalization for classification)
173
+ * @private
174
+ */
175
+ _calculateNormalizer(features, targets) {
176
+ const numFeatures = features[0].length;
177
+
178
+ // Initialize normalizer for features only
179
+ for (let i = 0; i < numFeatures; i++) {
180
+ const featureName = this.config.features[i];
181
+ const values = features.map(f => f[i]);
182
+ this.normalizer.features[featureName] = {
183
+ min: Math.min(...values),
184
+ max: Math.max(...values)
185
+ };
186
+ }
187
+
188
+ // No normalization for target (class indices)
189
+ this.normalizer.target = { min: 0, max: 1 };
190
+ }
191
+
192
+ /**
193
+ * Make a prediction (override to return class label)
194
+ */
195
+ async predict(input) {
196
+ if (!this.isTrained) {
197
+ throw new ModelNotTrainedError(`Model "${this.config.name}" is not trained yet`, {
198
+ model: this.config.name
199
+ });
200
+ }
201
+
202
+ try {
203
+ // Validate input
204
+ this._validateInput(input);
205
+
206
+ // Extract and normalize features
207
+ const features = this._extractFeatures(input);
208
+ const normalizedFeatures = this._normalizeFeatures(features);
209
+
210
+ // Convert to tensor
211
+ const inputTensor = this.tf.tensor2d([normalizedFeatures]);
212
+
213
+ // Predict
214
+ const predictionTensor = this.model.predict(inputTensor);
215
+ const predictionArray = await predictionTensor.data();
216
+
217
+ // Cleanup
218
+ inputTensor.dispose();
219
+ predictionTensor.dispose();
220
+
221
+ const isBinary = this.classes.length === 2;
222
+
223
+ let predictedClassIndex;
224
+ let confidence;
225
+
226
+ if (isBinary) {
227
+ // Binary classification: threshold at 0.5
228
+ confidence = predictionArray[0];
229
+ predictedClassIndex = confidence >= 0.5 ? 1 : 0;
230
+ } else {
231
+ // Multi-class: argmax
232
+ predictedClassIndex = predictionArray.indexOf(Math.max(...predictionArray));
233
+ confidence = predictionArray[predictedClassIndex];
234
+ }
235
+
236
+ const predictedClass = this.indexToClass[predictedClassIndex];
237
+
238
+ this.stats.predictions++;
239
+
240
+ return {
241
+ prediction: predictedClass,
242
+ confidence,
243
+ probabilities: isBinary ? {
244
+ [this.classes[0]]: 1 - predictionArray[0],
245
+ [this.classes[1]]: predictionArray[0]
246
+ } : Object.fromEntries(
247
+ this.classes.map((cls, idx) => [cls, predictionArray[idx]])
248
+ )
249
+ };
250
+ } catch (error) {
251
+ this.stats.errors++;
252
+ if (error instanceof ModelNotTrainedError || error instanceof DataValidationError) {
253
+ throw error;
254
+ }
255
+ throw new PredictionError(`Prediction failed: ${error.message}`, {
256
+ model: this.config.name,
257
+ input,
258
+ originalError: error.message
259
+ });
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Calculate confusion matrix
265
+ * @param {Array} data - Test data
266
+ * @returns {Object} Confusion matrix and metrics
267
+ */
268
+ async calculateConfusionMatrix(data) {
269
+ if (!this.isTrained) {
270
+ throw new ModelNotTrainedError(`Model "${this.config.name}" is not trained yet`, {
271
+ model: this.config.name
272
+ });
273
+ }
274
+
275
+ const matrix = {};
276
+ const numClasses = this.classes.length;
277
+
278
+ // Initialize matrix
279
+ for (const actualClass of this.classes) {
280
+ matrix[actualClass] = {};
281
+ for (const predictedClass of this.classes) {
282
+ matrix[actualClass][predictedClass] = 0;
283
+ }
284
+ }
285
+
286
+ // Populate matrix
287
+ for (const record of data) {
288
+ const { prediction } = await this.predict(record);
289
+ const actual = record[this.config.target];
290
+ matrix[actual][prediction]++;
291
+ }
292
+
293
+ // Calculate metrics
294
+ let totalCorrect = 0;
295
+ let total = 0;
296
+
297
+ for (const cls of this.classes) {
298
+ totalCorrect += matrix[cls][cls];
299
+ total += Object.values(matrix[cls]).reduce((sum, val) => sum + val, 0);
300
+ }
301
+
302
+ const accuracy = total > 0 ? totalCorrect / total : 0;
303
+
304
+ return {
305
+ matrix,
306
+ accuracy,
307
+ total,
308
+ correct: totalCorrect
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Export model with classification-specific data
314
+ */
315
+ async export() {
316
+ const baseExport = await super.export();
317
+
318
+ return {
319
+ ...baseExport,
320
+ type: 'classification',
321
+ classes: this.classes,
322
+ classToIndex: this.classToIndex,
323
+ indexToClass: this.indexToClass
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Import model (override to restore class mappings)
329
+ */
330
+ async import(data) {
331
+ await super.import(data);
332
+ this.classes = data.classes || [];
333
+ this.classToIndex = data.classToIndex || {};
334
+ this.indexToClass = data.indexToClass || {};
335
+ }
336
+ }
337
+
338
+ export default ClassificationModel;
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Neural Network Model
3
+ *
4
+ * Generic customizable neural network using TensorFlow.js
5
+ * Flexible architecture for complex non-linear problems
6
+ */
7
+
8
+ import { BaseModel } from './base-model.class.js';
9
+ import { ModelConfigError } from '../ml.errors.js';
10
+
11
+ export class NeuralNetworkModel extends BaseModel {
12
+ constructor(config = {}) {
13
+ super(config);
14
+
15
+ // Neural network-specific config
16
+ this.config.modelConfig = {
17
+ ...this.config.modelConfig,
18
+ layers: config.modelConfig?.layers || [
19
+ { units: 64, activation: 'relu', dropout: 0.2 },
20
+ { units: 32, activation: 'relu', dropout: 0.1 }
21
+ ], // Array of hidden layer configurations
22
+ outputActivation: config.modelConfig?.outputActivation || 'linear', // Output layer activation
23
+ outputUnits: config.modelConfig?.outputUnits || 1, // Number of output units
24
+ loss: config.modelConfig?.loss || 'meanSquaredError', // Loss function
25
+ metrics: config.modelConfig?.metrics || ['mse', 'mae'] // Metrics to track
26
+ };
27
+
28
+ // Validate layers configuration
29
+ this._validateLayersConfig();
30
+ }
31
+
32
+ /**
33
+ * Validate layers configuration
34
+ * @private
35
+ */
36
+ _validateLayersConfig() {
37
+ if (!Array.isArray(this.config.modelConfig.layers) || this.config.modelConfig.layers.length === 0) {
38
+ throw new ModelConfigError(
39
+ 'Neural network must have at least one hidden layer',
40
+ { model: this.config.name, layers: this.config.modelConfig.layers }
41
+ );
42
+ }
43
+
44
+ for (const [index, layer] of this.config.modelConfig.layers.entries()) {
45
+ if (!layer.units || typeof layer.units !== 'number' || layer.units < 1) {
46
+ throw new ModelConfigError(
47
+ `Layer ${index} must have a valid "units" property (positive number)`,
48
+ { model: this.config.name, layer, index }
49
+ );
50
+ }
51
+
52
+ if (layer.activation && !this._isValidActivation(layer.activation)) {
53
+ throw new ModelConfigError(
54
+ `Layer ${index} has invalid activation function "${layer.activation}"`,
55
+ { model: this.config.name, layer, index, validActivations: ['relu', 'sigmoid', 'tanh', 'softmax', 'elu', 'selu'] }
56
+ );
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Check if activation function is valid
63
+ * @private
64
+ */
65
+ _isValidActivation(activation) {
66
+ const validActivations = ['relu', 'sigmoid', 'tanh', 'softmax', 'elu', 'selu', 'linear'];
67
+ return validActivations.includes(activation);
68
+ }
69
+
70
+ /**
71
+ * Build custom neural network architecture
72
+ */
73
+ buildModel() {
74
+ const numFeatures = this.config.features.length;
75
+
76
+ // Create sequential model
77
+ this.model = this.tf.sequential();
78
+
79
+ // Add hidden layers
80
+ for (const [index, layerConfig] of this.config.modelConfig.layers.entries()) {
81
+ const isFirstLayer = index === 0;
82
+
83
+ // Dense layer
84
+ const layerOptions = {
85
+ units: layerConfig.units,
86
+ activation: layerConfig.activation || 'relu',
87
+ useBias: true
88
+ };
89
+
90
+ if (isFirstLayer) {
91
+ layerOptions.inputShape = [numFeatures];
92
+ }
93
+
94
+ this.model.add(this.tf.layers.dense(layerOptions));
95
+
96
+ // Dropout (if specified)
97
+ if (layerConfig.dropout && layerConfig.dropout > 0) {
98
+ this.model.add(this.tf.layers.dropout({
99
+ rate: layerConfig.dropout
100
+ }));
101
+ }
102
+
103
+ // Batch normalization (if specified)
104
+ if (layerConfig.batchNormalization) {
105
+ this.model.add(this.tf.layers.batchNormalization());
106
+ }
107
+ }
108
+
109
+ // Output layer
110
+ this.model.add(this.tf.layers.dense({
111
+ units: this.config.modelConfig.outputUnits,
112
+ activation: this.config.modelConfig.outputActivation
113
+ }));
114
+
115
+ // Compile model
116
+ this.model.compile({
117
+ optimizer: this.tf.train.adam(this.config.modelConfig.learningRate),
118
+ loss: this.config.modelConfig.loss,
119
+ metrics: this.config.modelConfig.metrics
120
+ });
121
+
122
+ if (this.config.verbose) {
123
+ console.log(`[MLPlugin] ${this.config.name} - Built custom neural network:`);
124
+ console.log(` - Hidden layers: ${this.config.modelConfig.layers.length}`);
125
+ console.log(` - Total parameters:`, this._countParameters());
126
+ this.model.summary();
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Count total trainable parameters
132
+ * @private
133
+ */
134
+ _countParameters() {
135
+ if (!this.model) return 0;
136
+
137
+ let totalParams = 0;
138
+ for (const layer of this.model.layers) {
139
+ if (layer.countParams) {
140
+ totalParams += layer.countParams();
141
+ }
142
+ }
143
+ return totalParams;
144
+ }
145
+
146
+ /**
147
+ * Add layer to model (before building)
148
+ * @param {Object} layerConfig - Layer configuration
149
+ */
150
+ addLayer(layerConfig) {
151
+ if (this.model) {
152
+ throw new ModelConfigError(
153
+ 'Cannot add layer after model is built. Use addLayer() before training.',
154
+ { model: this.config.name }
155
+ );
156
+ }
157
+
158
+ this.config.modelConfig.layers.push(layerConfig);
159
+ }
160
+
161
+ /**
162
+ * Set output configuration
163
+ * @param {Object} outputConfig - Output layer configuration
164
+ */
165
+ setOutput(outputConfig) {
166
+ if (this.model) {
167
+ throw new ModelConfigError(
168
+ 'Cannot change output after model is built. Use setOutput() before training.',
169
+ { model: this.config.name }
170
+ );
171
+ }
172
+
173
+ if (outputConfig.activation) {
174
+ this.config.modelConfig.outputActivation = outputConfig.activation;
175
+ }
176
+ if (outputConfig.units) {
177
+ this.config.modelConfig.outputUnits = outputConfig.units;
178
+ }
179
+ if (outputConfig.loss) {
180
+ this.config.modelConfig.loss = outputConfig.loss;
181
+ }
182
+ if (outputConfig.metrics) {
183
+ this.config.modelConfig.metrics = outputConfig.metrics;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Get model architecture summary
189
+ */
190
+ getArchitecture() {
191
+ return {
192
+ inputFeatures: this.config.features,
193
+ hiddenLayers: this.config.modelConfig.layers.map((layer, index) => ({
194
+ index,
195
+ units: layer.units,
196
+ activation: layer.activation || 'relu',
197
+ dropout: layer.dropout || 0,
198
+ batchNormalization: layer.batchNormalization || false
199
+ })),
200
+ outputLayer: {
201
+ units: this.config.modelConfig.outputUnits,
202
+ activation: this.config.modelConfig.outputActivation
203
+ },
204
+ totalParameters: this._countParameters(),
205
+ loss: this.config.modelConfig.loss,
206
+ metrics: this.config.modelConfig.metrics
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Train with early stopping callback
212
+ * @param {Array} data - Training data
213
+ * @param {Object} earlyStoppingConfig - Early stopping configuration
214
+ * @returns {Object} Training results
215
+ */
216
+ async trainWithEarlyStopping(data, earlyStoppingConfig = {}) {
217
+ const {
218
+ patience = 10,
219
+ minDelta = 0.001,
220
+ monitor = 'val_loss',
221
+ restoreBestWeights = true
222
+ } = earlyStoppingConfig;
223
+
224
+ // Prepare data
225
+ const { xs, ys } = this._prepareData(data);
226
+
227
+ // Build model if not already built
228
+ if (!this.model) {
229
+ this.buildModel();
230
+ }
231
+
232
+ // Early stopping callback
233
+ let bestValue = Infinity;
234
+ let patienceCounter = 0;
235
+ let bestWeights = null;
236
+
237
+ const callbacks = {
238
+ onEpochEnd: async (epoch, logs) => {
239
+ const monitorValue = logs[monitor] || logs.loss;
240
+
241
+ if (this.config.verbose && epoch % 10 === 0) {
242
+ console.log(`[MLPlugin] ${this.config.name} - Epoch ${epoch}: ${monitor}=${monitorValue.toFixed(4)}`);
243
+ }
244
+
245
+ // Check for improvement
246
+ if (monitorValue < bestValue - minDelta) {
247
+ bestValue = monitorValue;
248
+ patienceCounter = 0;
249
+
250
+ if (restoreBestWeights) {
251
+ bestWeights = await this.model.getWeights();
252
+ }
253
+ } else {
254
+ patienceCounter++;
255
+
256
+ if (patienceCounter >= patience) {
257
+ if (this.config.verbose) {
258
+ console.log(`[MLPlugin] ${this.config.name} - Early stopping at epoch ${epoch}`);
259
+ }
260
+ this.model.stopTraining = true;
261
+ }
262
+ }
263
+ }
264
+ };
265
+
266
+ // Train
267
+ const history = await this.model.fit(xs, ys, {
268
+ epochs: this.config.modelConfig.epochs,
269
+ batchSize: this.config.modelConfig.batchSize,
270
+ validationSplit: this.config.modelConfig.validationSplit,
271
+ verbose: this.config.verbose ? 1 : 0,
272
+ callbacks
273
+ });
274
+
275
+ // Restore best weights
276
+ if (restoreBestWeights && bestWeights) {
277
+ this.model.setWeights(bestWeights);
278
+ }
279
+
280
+ // Update stats
281
+ this.isTrained = true;
282
+ this.stats.trainedAt = new Date().toISOString();
283
+ this.stats.samples = data.length;
284
+ this.stats.loss = history.history.loss[history.history.loss.length - 1];
285
+
286
+ // Cleanup
287
+ xs.dispose();
288
+ ys.dispose();
289
+
290
+ return {
291
+ loss: this.stats.loss,
292
+ epochs: history.epoch.length,
293
+ samples: this.stats.samples,
294
+ stoppedEarly: history.epoch.length < this.config.modelConfig.epochs
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Export model with neural network-specific data
300
+ */
301
+ async export() {
302
+ const baseExport = await super.export();
303
+
304
+ return {
305
+ ...baseExport,
306
+ type: 'neural-network',
307
+ architecture: this.getArchitecture()
308
+ };
309
+ }
310
+ }
311
+
312
+ export default NeuralNetworkModel;