clustering-tfjs
Version:
High-performance TypeScript clustering algorithms (K-Means, Spectral, Agglomerative) with TensorFlow.js acceleration and scikit-learn compatibility
226 lines (225 loc) • 9.04 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.improved_jacobi_eigen = improved_jacobi_eigen;
exports.laplacian_eigen_decomposition = laplacian_eigen_decomposition;
const tf = __importStar(require("../tf-adapter"));
/**
* Improved Jacobi eigendecomposition for symmetric matrices.
*
* Enhancements over the basic Jacobi method:
* 1. Cyclic Jacobi - systematically sweep through all pairs
* 2. Threshold scaling - adapt threshold as we converge
* 3. Better handling of small pivots
* 4. Post-processing to ensure non-negative eigenvalues for PSD matrices
*/
function improved_jacobi_eigen(matrix, { maxIterations = 3000, tolerance = 1e-14, isPSD = false, // Is matrix positive semi-definite?
} = {}) {
if (matrix.shape.length !== 2 || matrix.shape[0] !== matrix.shape[1]) {
throw new Error('Input tensor must be square (n × n).');
}
const A = matrix.arraySync();
const n = A.length;
// Check if already diagonal
const isApproximatelyDiagonal = () => {
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
if (i === j)
continue;
if (Math.abs(A[i][j]) > tolerance * 10)
return false;
}
}
return true;
};
if (isApproximatelyDiagonal()) {
const diag = A.map((row, i) => row[i]);
const V = A.map((_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
return { eigenvalues: diag, eigenvectors: V };
}
// Deep copy for working matrix
const D = A.map((row) => [...row]);
// Eigenvector accumulator
const V = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
// Compute Frobenius norm of off-diagonal elements
const offDiagNorm = (M) => {
let sum = 0;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
sum += M[i][j] * M[i][j];
}
}
return Math.sqrt(2 * sum); // Account for symmetry
};
let sweep = 0;
let changed = true;
// Adaptive threshold based on matrix norm
const matrixNorm = Math.sqrt(A.reduce((sum, row) => sum + row.reduce((s, v) => s + v * v, 0), 0));
let threshold = tolerance * matrixNorm;
while (sweep < maxIterations && changed) {
changed = false;
// Cyclic Jacobi: sweep through all pairs
for (let p = 0; p < n - 1; p++) {
for (let q = p + 1; q < n; q++) {
const a_pq = D[p][q];
// Skip if already small
if (Math.abs(a_pq) < threshold)
continue;
changed = true;
const a_pp = D[p][p];
const a_qq = D[q][q];
// Compute rotation angle
const diff = a_qq - a_pp;
let t;
if (Math.abs(a_pq) < Math.abs(diff) * 1e-15) {
// Very small angle - use approximation
t = a_pq / diff;
}
else {
const theta = diff / (2 * a_pq);
t = 1 / (Math.abs(theta) + Math.sqrt(1 + theta * theta));
if (theta < 0)
t = -t;
}
const c = 1 / Math.sqrt(1 + t * t);
const s = t * c;
const tau = s / (1 + c);
// Update diagonal elements
D[p][p] = a_pp - t * a_pq;
D[q][q] = a_qq + t * a_pq;
D[p][q] = D[q][p] = 0;
// Update row p and column p (j < p)
for (let j = 0; j < p; j++) {
const g = D[j][p];
const h = D[j][q];
D[j][p] = D[p][j] = g - s * (h + g * tau);
D[j][q] = D[q][j] = h + s * (g - h * tau);
}
// Update row p and column p (p < j < q)
for (let j = p + 1; j < q; j++) {
const g = D[p][j];
const h = D[j][q];
D[p][j] = D[j][p] = g - s * (h + g * tau);
D[j][q] = D[q][j] = h + s * (g - h * tau);
}
// Update row p and column p (j > q)
for (let j = q + 1; j < n; j++) {
const g = D[p][j];
const h = D[q][j];
D[p][j] = D[j][p] = g - s * (h + g * tau);
D[q][j] = D[j][q] = h + s * (g - h * tau);
}
// Update eigenvectors
for (let j = 0; j < n; j++) {
const g = V[j][p];
const h = V[j][q];
V[j][p] = g - s * (h + g * tau);
V[j][q] = h + s * (g - h * tau);
}
}
}
sweep++;
// Adaptive threshold reduction
if (sweep % 5 === 0) {
const currentNorm = offDiagNorm(D);
if (currentNorm < threshold * n) {
threshold *= 0.1;
}
}
}
if (sweep === maxIterations) {
console.warn(`Improved Jacobi solver reached max iterations (${maxIterations}). ` +
`Final off-diagonal norm: ${offDiagNorm(D).toExponential(3)}`);
}
// Extract eigenvalues
let eigenvalues = D.map((row, i) => row[i]);
// Post-processing for PSD matrices
if (isPSD) {
// Clamp small negative values to zero
// For normalized Laplacians, eigenvalues should be in [0, 2]
// Any negative value is due to numerical error
const threshold = 1e-8; // More relaxed threshold for PSD matrices
eigenvalues = eigenvalues.map((v) => (v < threshold ? 0 : v));
}
// Sort eigen-pairs
const indexed = eigenvalues.map((val, idx) => ({ val, idx }));
indexed.sort((a, b) => a.val - b.val);
const sortedValues = indexed.map((p) => p.val);
const sortedVectors = Array.from({ length: n }, () => new Array(n));
for (let newIdx = 0; newIdx < n; newIdx++) {
const oldIdx = indexed[newIdx].idx;
for (let row = 0; row < n; row++) {
sortedVectors[row][newIdx] = V[row][oldIdx];
}
}
return { eigenvalues: sortedValues, eigenvectors: sortedVectors };
}
/**
* Specialized version for normalized Laplacians.
* Takes advantage of the known properties:
* - Symmetric
* - Positive semi-definite
* - Eigenvalues in [0, 2]
* - Smallest eigenvalue(s) ≈ 0 for connected components
*/
function laplacian_eigen_decomposition(laplacian, k) {
return tf.tidy(() => {
const { eigenvalues, eigenvectors } = improved_jacobi_eigen(laplacian, {
isPSD: true,
maxIterations: 3000,
tolerance: 1e-14,
});
// For Laplacians, we know smallest eigenvalues should be very close to 0
// Count how many are numerically zero
const TOL = 1e-5; // More relaxed than general case
let numZeros = 0;
for (const val of eigenvalues) {
if (val <= TOL)
numZeros++;
else
break;
}
// Return k + numZeros columns
const n = eigenvectors.length;
const numCols = Math.min(k + numZeros, n);
const selected = Array.from({ length: n }, () => new Array(numCols));
for (let col = 0; col < numCols; col++) {
for (let row = 0; row < n; row++) {
selected[row][col] = eigenvectors[row][col];
}
}
return tf.tensor2d(selected, [n, numCols], 'float32');
});
}