154 lines
4.2 KiB
JavaScript
154 lines
4.2 KiB
JavaScript
import fs from 'fs';
|
|
|
|
function sigmoid(z) {
|
|
// Cap z to avoid overflow
|
|
if (z > 20) return 1.0;
|
|
if (z < -20) return 0.0;
|
|
return 1 / (1 + Math.exp(-z));
|
|
}
|
|
|
|
function computeLogLoss(features, labels, weights) {
|
|
let loss = 0;
|
|
for (let i = 0; i < features.length; i++) {
|
|
const f = features[i];
|
|
let z = weights[0];
|
|
for (let j = 0; j < f.length; j++) z += weights[j + 1] * f[j];
|
|
const p = sigmoid(z);
|
|
// clip p to avoid log(0)
|
|
const pSafe = Math.max(1e-15, Math.min(1 - 1e-15, p));
|
|
loss += -labels[i] * Math.log(pSafe) - (1 - labels[i]) * Math.log(1 - pSafe);
|
|
}
|
|
return loss / features.length;
|
|
}
|
|
|
|
export class LogisticModel {
|
|
constructor() {
|
|
this.weights = null;
|
|
this.featureKeys = [];
|
|
}
|
|
|
|
// Internal full-batch gradient descent
|
|
_train(X, Y, lambda, learningRate, maxSteps, tol) {
|
|
const numFeatures = X[0].length;
|
|
let w = new Array(numFeatures + 1).fill(0.0);
|
|
const N = X.length;
|
|
|
|
let prevLoss = Infinity;
|
|
|
|
for (let step = 0; step < maxSteps; step++) {
|
|
const grad = new Array(numFeatures + 1).fill(0.0);
|
|
|
|
for (let i = 0; i < N; i++) {
|
|
let z = w[0];
|
|
for (let j = 0; j < numFeatures; j++) z += w[j + 1] * X[i][j];
|
|
|
|
const p = sigmoid(z);
|
|
const err = p - Y[i];
|
|
|
|
grad[0] += err;
|
|
for (let j = 0; j < numFeatures; j++) {
|
|
grad[j + 1] += err * X[i][j];
|
|
}
|
|
}
|
|
|
|
// Add L2 penalty (skip intercept grad[0])
|
|
for (let j = 0; j < numFeatures; j++) {
|
|
grad[j + 1] += lambda * w[j + 1];
|
|
}
|
|
|
|
// Update weights
|
|
let maxDelta = 0;
|
|
for (let j = 0; j < w.length; j++) {
|
|
const update = (learningRate * grad[j]) / N;
|
|
w[j] -= update;
|
|
if (Math.abs(update) > maxDelta) maxDelta = Math.abs(update);
|
|
}
|
|
|
|
if (maxDelta < tol) {
|
|
// Converged
|
|
break;
|
|
}
|
|
}
|
|
return w;
|
|
}
|
|
|
|
fit(featuresArray, labels) {
|
|
if (!featuresArray.length) return;
|
|
this.featureKeys = Object.keys(featuresArray[0]);
|
|
|
|
// Convert to 2D array
|
|
const X = featuresArray.map(f => this.featureKeys.map(k => f[k]));
|
|
const Y = labels;
|
|
const N = X.length;
|
|
|
|
// 1. Carve a 80/20 validation slice from TRAIN to tune lambda
|
|
const splitIdx = Math.floor(N * 0.8);
|
|
const X_train = X.slice(0, splitIdx);
|
|
const Y_train = Y.slice(0, splitIdx);
|
|
const X_val = X.slice(splitIdx);
|
|
const Y_val = Y.slice(splitIdx);
|
|
|
|
// 2. Tune lambda
|
|
const lambdas = [0.01, 0.1, 1.0, 10.0, 100.0, 500.0, 1000.0];
|
|
let bestLambda = 0;
|
|
let bestLoss = Infinity;
|
|
|
|
console.log(`\nTuning L2 Regularization (Lambda) on Validation Slice...`);
|
|
for (const l of lambdas) {
|
|
const w = this._train(X_train, Y_train, l, 0.5, 2000, 1e-5);
|
|
const loss = computeLogLoss(X_val, Y_val, w);
|
|
console.log(` Lambda ${l.toString().padEnd(6)} -> LogLoss: ${loss.toFixed(4)}`);
|
|
if (loss < bestLoss) {
|
|
bestLoss = loss;
|
|
bestLambda = l;
|
|
}
|
|
}
|
|
|
|
console.log(`Selected Best Lambda: ${bestLambda}`);
|
|
|
|
// 3. Refit on FULL TRAIN set using best lambda
|
|
this.weights = this._train(X, Y, bestLambda, 0.5, 5000, 1e-5);
|
|
|
|
return this.getCoefficients();
|
|
}
|
|
|
|
predict(featuresArray) {
|
|
if (!this.weights) throw new Error("Model not trained");
|
|
|
|
const probs = [];
|
|
for (let i = 0; i < featuresArray.length; i++) {
|
|
const f = featuresArray[i];
|
|
let z = this.weights[0]; // intercept
|
|
for (let j = 0; j < this.featureKeys.length; j++) {
|
|
z += this.weights[j + 1] * f[this.featureKeys[j]];
|
|
}
|
|
probs.push(sigmoid(z));
|
|
}
|
|
|
|
return probs;
|
|
}
|
|
|
|
getCoefficients() {
|
|
if (!this.weights) return {};
|
|
const coeffs = { intercept: this.weights[0] };
|
|
for (let i = 0; i < this.featureKeys.length; i++) {
|
|
coeffs[this.featureKeys[i]] = this.weights[i + 1];
|
|
}
|
|
return coeffs;
|
|
}
|
|
|
|
save(filepath) {
|
|
fs.writeFileSync(filepath, JSON.stringify({
|
|
featureKeys: this.featureKeys,
|
|
weights: this.weights
|
|
}, null, 2));
|
|
}
|
|
|
|
load(filepath) {
|
|
if (!fs.existsSync(filepath)) return;
|
|
const data = JSON.parse(fs.readFileSync(filepath, 'utf8'));
|
|
this.featureKeys = data.featureKeys;
|
|
this.weights = data.weights;
|
|
}
|
|
}
|