clustering-tfjs
Version:
High-performance TypeScript clustering algorithms (K-Means, Spectral, Agglomerative) with TensorFlow.js acceleration and scikit-learn compatibility
303 lines (302 loc) • 13.4 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.degree_vector = degree_vector;
exports.normalised_laplacian = normalised_laplacian;
exports.jacobi_eigen_decomposition = jacobi_eigen_decomposition;
exports.smallest_eigenvectors = smallest_eigenvectors;
const tf = __importStar(require("../tf-adapter"));
const eigen_post_1 = require("./eigen_post");
/* -------------------------------------------------------------------------- */
/* Graph Laplacian – core utilities */
/* -------------------------------------------------------------------------- */
/**
* Computes the (row) degree vector for the provided *affinity / similarity*
* matrix.
*
* The input must be a **square** `tf.Tensor2D` whose entries represent edge
* weights `A[i,j]` of an undirected graph. For *k*-NN graphs the matrix is
* expected to be symmetrised (`A = max(A, Aᵀ)`).
*
* The returned tensor is a 1-D vector where `deg[i] = Σ_j A[i,j]`.
*/
function degree_vector(A) {
if (A.shape.length !== 2 || A.shape[0] !== A.shape[1]) {
throw new Error('Affinity matrix must be square (n × n).');
}
// Sum along axis 1 (rows) – (n)
return tf.tidy(() => A.sum(1));
}
function normalised_laplacian(A, returnDiag = false) {
return tf.tidy(() => {
const n = A.shape[0];
// First, zero out the diagonal of A to match scipy behavior
// "Diagonal entries of the input adjacency matrix are ignored and
// replaced with zeros for the purpose of normalization"
const diagMask = tf.sub(1, tf.eye(n));
const A_no_diag = A.mul(diagMask);
const deg = degree_vector(A_no_diag); // (n)
// d^{-1/2} – set entries with deg == 0 to 1 (for isolated nodes)
const invSqrt = tf.where(deg.equal(0), tf.onesLike(deg), deg.pow(-0.5)); // (n)
// Build outer product invSqrt[:,None] * invSqrt[None,:] (n,n)
const diagCol = invSqrt.reshape([n, 1]);
const diagRow = invSqrt.reshape([1, n]);
const scaling = diagCol.matMul(diagRow); // (n,n)
// Scale the affinity matrix (with diagonal already zeroed)
const scaledAffinity = A_no_diag.mul(scaling);
// L = I - scaledAffinity
// This ensures diagonal entries are exactly 1 for non-isolated nodes
const I = tf.eye(n);
const laplacian = I.sub(scaledAffinity);
if (returnDiag) {
// Return both Laplacian and sqrt(degrees) for eigenvector recovery
// Note: we return invSqrt which is D^(-1/2), so for recovery we need to divide by it
return {
laplacian: laplacian,
sqrtDegrees: invSqrt,
};
}
return laplacian;
});
}
/* -------------------------------------------------------------------------- */
/* Eigendecomposition (Jacobi) */
/* -------------------------------------------------------------------------- */
/**
* Lightweight *Jacobi* eigen-decomposition for **symmetric** matrices.
*
* TensorFlow.js currently does not expose an `eig` kernel for Node/GPU
* back-ends. For the small matrix sizes typical in unit tests and many
* practical spectral-clustering scenarios (n ≤ few hundred) a pure
* JavaScript Jacobi solver provides sufficiently fast and stable results
* without adding an external dependency.
*
* The implementation is deliberately kept simple and therefore **not**
* optimised for large dense matrices. It should *only* be used on matrices
* of moderate size.
*/
function jacobi_eigen_decomposition(matrix, { maxIterations = 2000, tolerance = 1e-12, } = {}) {
if (matrix.shape.length !== 2 || matrix.shape[0] !== matrix.shape[1]) {
throw new Error('Input tensor must be square (n × n).');
}
// Convert to regular JS arrays for numeric processing.
const A = matrix.arraySync();
const n = A.length;
// ---------------------------------------------------------------------
// Fast-path: if the matrix is already (almost) diagonal we can return
// immediately. This situation commonly arises for
// *disconnected* graphs where the *normalised* Laplacian is
// the identity matrix or block-diagonal with very small
// off-diagonal entries (numerical noise).
// ---------------------------------------------------------------------
const isApproximatelyDiagonal = () => {
const nDiagTolerance = tolerance * 10;
for (let i = 0; i < matrix.shape[0]; i++) {
for (let j = 0; j < matrix.shape[0]; j++) {
if (i === j)
continue;
if (Math.abs(A[i][j]) > nDiagTolerance)
return false;
}
}
return true;
};
if (isApproximatelyDiagonal()) {
warn('Input matrix is (almost) diagonal – skipped iterative Jacobi rotations for efficiency.');
const diag = A.map((row, i) => row[i]);
const V = A.map((_, i) => Array.from({ length: matrix.shape[0] }, (_, j) => (i === j ? 1 : 0)));
return { eigenvalues: diag, eigenvectors: V };
}
// Deep copy to avoid mutating the original matrix during rotations.
const D = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => A[i][j]));
// Eigenvector accumulator (initially identity)
const V = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
const offDiag = (M) => {
let sum = 0;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const val = M[i][j];
sum += val * val;
}
}
return Math.sqrt(sum);
};
let iter = 0;
while (iter < maxIterations && offDiag(D) > tolerance) {
// Find largest off-diagonal element (by absolute value)
let p = 0;
let q = 1;
let maxVal = Math.abs(D[p][q]);
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const val = Math.abs(D[i][j]);
if (val > maxVal) {
maxVal = val;
p = i;
q = j;
}
}
}
if (maxVal < tolerance)
break;
const a_pp = D[p][p];
const a_qq = D[q][q];
const a_pq = D[p][q];
// Compute rotation angle
// When a_pq is extremely small we risk dividing by ~0 which would blow
// up `tau`. In that situation the Jacobi rotation angle can be set to
// 0 because the off-diagonal entry is already tiny compared with the
// tolerance threshold (and will therefore be eliminated in the next
// iteration criterion). This guards against `Infinity` / `NaN`
// propagation that would otherwise terminate the algorithm.
if (Math.abs(a_pq) < tolerance) {
warn(`Jacobi pivot below tolerance (|a_pq|≈${Math.abs(a_pq)}). Skipping rotation.`);
D[p][q] = D[q][p] = 0;
iter += 1;
continue;
}
const tau = (a_qq - a_pp) / (2 * a_pq);
let t;
if (tau === 0) {
t = 1;
}
else {
t = Math.sign(tau) / (Math.abs(tau) + Math.sqrt(1 + tau * tau));
}
const c = 1 / Math.sqrt(1 + t * t);
const s = t * c;
// Rotate rows & columns p and q in D
for (let k = 0; k < n; k++) {
const d_pk = D[p][k];
const d_qk = D[q][k];
D[p][k] = d_pk * c - d_qk * s;
D[q][k] = d_pk * s + d_qk * c;
}
for (let k = 0; k < n; k++) {
const d_kp = D[k][p];
const d_kq = D[k][q];
D[k][p] = d_kp * c - d_kq * s;
D[k][q] = d_kp * s + d_kq * c;
}
// Manually set symmetric entries we overwrote
D[p][p] = a_pp * c * c - 2 * a_pq * c * s + a_qq * s * s;
D[q][q] = a_pp * s * s + 2 * a_pq * c * s + a_qq * c * c;
D[p][q] = D[q][p] = 0; // by design
// Update eigenvectors matrix
for (let k = 0; k < n; k++) {
const v_kp = V[k][p];
const v_kq = V[k][q];
V[k][p] = v_kp * c - v_kq * s;
V[k][q] = v_kp * s + v_kq * c;
}
iter += 1;
}
if (iter === maxIterations) {
warn(`Jacobi solver did not converge after ${maxIterations} iterations. Final off-diagonal norm: ${offDiag(D)}`);
}
let eigenvalues = D.map((row, i) => row[i]);
// Clamp very small negative values arising from numerical noise to 0.
eigenvalues = eigenvalues.map((v) => {
if (v < 0 && v > -tolerance) {
warn(`Negative eigenvalue ${v} clamped to 0 (within numeric tolerance).`);
return 0;
}
return v;
});
// Sort eigen-pairs ascending (deterministic sign handling is applied later
// in `smallest_eigenvectors` for the subset actually used in the spectral
// pipeline – avoids unnecessary sign flips for callers that do not care).
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 };
/* istanbul ignore next */
function warn(msg) {
// Centralised helper – could be swapped for a proper logger later.
// Users may suppress by overriding console.warn if desired.
// We add a prefix to make it searchable in logs.
console.warn(`[spectral] ${msg}`);
}
}
/**
* Convenience helper that returns the `k` smallest eigenvectors of the
* provided symmetric matrix *as a TensorFlow.js tensor* (n × k).
*/
function smallest_eigenvectors(matrix, k) {
if (!Number.isInteger(k) || k < 1) {
throw new Error('k must be a positive integer.');
}
return tf.tidy(() => {
// Import improved solver for better accuracy
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { improved_jacobi_eigen } = require('./eigen_improved');
// 1) Full eigendecomposition with improved solver
// For normalized Laplacians, we know it's PSD
const { eigenvalues, eigenvectors } = improved_jacobi_eigen(matrix, {
isPSD: true,
maxIterations: 3000,
tolerance: 1e-14,
});
// 2) Deterministic ordering & sign fixing (task-12.3.1 helper)
const { eigenvectors: vecSorted } = (0, eigen_post_1.deterministic_eigenpair_processing)({
eigenvalues,
eigenvectors,
});
// 3) Determine number of numerically-zero eigenvalues `c` (≤ n). We must
// include *all* corresponding eigenvectors because each represents a
// connected component in the affinity graph. scikit-learn retains
// them initially and discards them later after constructing the full
// embedding. We mimic this contract so callers can remove the block
// in one go.
const n = vecSorted.length;
// For spectral clustering, we want exactly k eigenvectors
// INCLUDING any with zero eigenvalues (they encode component structure)
const sliceCols = Math.min(k, n);
const selected = Array.from({ length: n }, () => new Array(sliceCols));
for (let col = 0; col < sliceCols; col++) {
for (let row = 0; row < n; row++) {
selected[row][col] = vecSorted[row][col];
}
}
return tf.tensor2d(selected, [n, sliceCols], 'float32');
});
}