UNPKG

mdsjs

Version:

MDS and PCA projections in JavaScript

902 lines (835 loc) 23.1 kB
/** * Created by krause on 2014-10-25. */ class Continuation extends Error { constructor(f) { super(); this.__continuation = f; } } export class Matrix { constructor( /** @type {MDSJs} */ mdsjs, /** @type {Float32Array | Float64Array} */ mat, /** @type {number} */ rows, /** @type {number} */ cols, ) { /** @type {MDSJs} */ this._mdsjs = mdsjs; /** @type {Float32Array | Float64Array} */ this._mat = mat; /** @type {number} */ this._rows = rows; /** @type {number} */ this._cols = cols; } mdsjs() { return this._mdsjs; } rows() { return this._rows; } cols() { return this._cols; } isQuadratic() { return this._rows === this._cols; } noNaNs() { this._mdsjs.noNaNs(this._mat); } someRows( /** @type {(row: Float32Array | Float64Array, ix: number) => boolean} */ cb, ) { let pos = 0; for (let r = 0; r < this._rows; r += 1) { if (cb(this._mat.subarray(pos, pos + this._cols), r)) { return true; } pos += this._cols; } return false; } everyRows( /** @type {(row: Float32Array | Float64Array, ix: number) => boolean} */ cb, ) { return !this.someRows((row, ix) => { return !cb(row, ix); }); } rowsIter( /** @type {(row: Float32Array | Float64Array, ix: number) => void} */ cb, ) { let pos = 0; for (let r = 0; r < this._rows; r += 1) { cb(this._mat.subarray(pos, pos + this._cols), r); pos += this._cols; } } rowIter( /** @type {number} */ row, /** @type {(val: number, row: number, col: number) => void} */ cb, ) { let pos = row * this._cols; for (let ix = 0; ix < this._cols; ix += 1) { cb(this._mat[pos], row, ix); pos += 1; } } colIter( /** @type {number} */ col, /** @type {(val: number, row: number, col: number) => void} */ cb, ) { let pos = col; for (let ix = 0; ix < this._rows; ix += 1) { cb(this._mat[pos], ix, col); pos += this._cols; } } getUnsafe(/** @type {number} */ pos) { return this._mat[pos]; } createArray(/** @type {number} */ rows, /** @type {number} */ cols) { const size = rows * cols; return this._mat.byteLength > 24 ? new Float64Array(size) : new Float32Array(size); } toString() { let res = ''; for (let r = 0; r < this.rows(); r += 1) { this.rowIter(r, (e, c) => { res += ` ${e}`; }); res += '\n'; } return res; } iter( /** @type {Matrix} */ matB, /** @type {number} */ row, /** @type {number} */ col, /** @type {(left: number, right: number, row: number, pos: number, col: number) => void} */ cb, ) { Matrix.iter(this, matB, row, col, cb); } mul(/** @type {Matrix} */ matB) { return Matrix.mul(this, matB); } add(/** @type {Matrix} */ matB) { return Matrix.add(this, matB); } neg() { const mat = this.createArray(this.rows(), this.cols()); for (let pos = 0; pos < mat.length; pos += 1) { mat[pos] = -this.getUnsafe(pos); } return new Matrix(this._mdsjs, mat, this.rows(), this.cols()); } scale(/** @type {number} */ scale) { const mat = this.createArray(this.rows(), this.cols()); for (let pos = 0; pos < mat.length; pos += 1) { mat[pos] = scale * this.getUnsafe(pos); } return new Matrix(this._mdsjs, mat, this.rows(), this.cols()); } squareElements() { const mat = this.createArray(this.rows(), this.cols()); for (let pos = 0; pos < mat.length; pos += 1) { mat[pos] = this.getUnsafe(pos) * this.getUnsafe(pos); } return new Matrix(this._mdsjs, mat, this.rows(), this.cols()); } colCenter() { const rows = this.rows(); const cols = this.cols(); const mat = this.createArray(rows, cols); for (let c = 0; c < cols; c += 1) { let avg = 0; this.colIter(c, (v) => { avg += v; }); avg /= rows; let pos = c; this.colIter(c, (v) => { mat[pos] = v - avg; pos += cols; }); } return new Matrix(this._mdsjs, mat, rows, cols); } rowCenter() { const rows = this.rows(); const cols = this.cols(); const mat = this.createArray(rows, cols); let pos = 0; for (let r = 0; r < rows; r += 1) { let avg = 0; this.rowIter(r, (v) => { avg += v; }); avg /= cols; this.rowIter(r, (v) => { mat[pos] = v - avg; pos += 1; }); } return new Matrix(this._mdsjs, mat, rows, cols); } doubleCenter() { const rows = this.rows(); const cols = this.cols(); const mat = this.createArray(rows, cols); for (let r = 0; r < rows; r += 1) { let avg = 0; this.rowIter(r, (v) => { avg += v; }); avg /= cols; let pos = r * cols; this.rowIter(r, (v) => { mat[pos] = v - avg; pos += 1; }); } for (let c = 0; c < cols; c += 1) { let avg = 0; let pos = c; for (let r = 0; r < rows; r += 1) { avg += mat[pos]; pos += cols; } avg /= rows; pos = c; for (let r = 0; r < rows; r += 1) { mat[pos] -= avg; pos += cols; } } return new Matrix(this._mdsjs, mat, rows, cols); } distance(/** @type {number} */ colA, /** @type {number} */ colB) { let res = 0; let posA = colA; let posB = colB; for (let r = 0; r < this.rows(); r += 1) { const v = this.getUnsafe(posA) - this.getUnsafe(posB); res += v * v; posA += this.cols(); posB += this.cols(); } return Math.sqrt(res); } eigen(/** @type {Float32Array | Float64Array} */ eigenVals) { let /** @type {Matrix | undefined} */ res; this.eigenAsync( eigenVals, (mat) => { res = mat; }, this._mdsjs.getCallDirect(), ); return res; } eigenAsync( /** @type {Float32Array | Float64Array} */ eigenVals, /** @type {(mat: Matrix) => void} */ cb, /** @type {(cb: () => void) => void} */ argCall, ) { const call = argCall ?? this._mdsjs.CALL_ASYNC; const d = eigenVals.length; const rows = this.rows(); const cols = this.cols(); const content = this.createArray(rows, cols); let pos = 0; for (let r = 0; r < rows; r += 1) { this.rowIter(r, (v) => { content[pos] = v; pos += 1; }); } const eigenVecs = this.createArray(d, rows); let ePos = -rows; let m = 0; let r = 0; let iter = 0; const innerLoop = () => { for (let ix = 0; ix < this._mdsjs.EIGEN_ITER_ASYNC; ix += 1) { if ( !( Math.abs(1 - r) > this._mdsjs.EIGEN_EPS && iter < this._mdsjs.EIGEN_ITER ) ) { m += 1; iterate(); return; } const q = this.createArray(1, rows); pos = 0; for (let rix = 0; rix < rows; rix += 1) { for (let cix = 0; cix < cols; cix += 1) { q[rix] += content[pos] * eigenVecs[ePos + cix]; pos += 1; } } eigenVals[m] = this._mdsjs.prod(eigenVecs, ePos, q, 0, rows); this._mdsjs.normalizeVec(q); r = Math.abs(this._mdsjs.prod(eigenVecs, ePos, q, 0, rows)); this._mdsjs.xcopy(q, 0, eigenVecs, ePos, rows); iter += 1; } call(innerLoop); }; // innerLoop const iterate = () => { if (!(m < d)) { cb(new Matrix(this._mdsjs, eigenVecs, d, rows)); return; } if (m > 0) { pos = 0; for (let rix = 0; rix < rows; rix += 1) { for (let cix = 0; cix < cols; cix += 1) { content[pos] -= eigenVals[m - 1] * eigenVecs[ePos + rix] * eigenVecs[ePos + cix]; pos += 1; } } } ePos += rows; pos = ePos; for (let ix = 0; ix < rows; ix += 1) { eigenVecs[pos] = Math.random(); pos += 1; } this._mdsjs.normalizeVec(eigenVecs, ePos, ePos + rows); r = 0; iter = 0; call(innerLoop); }; // iterate iterate(); } powerIter() { let /** @type {Float32Array | Float64Array | undefined} */ res; this.powerIterAsync((r) => { res = r; }, this._mdsjs.getCallDirect()); return res; } powerIterAsync( /** @type {(r: Float32Array | Float64Array) => void} */ cb, /** @type {(cb: () => void) => void} */ argCall, ) { const call = argCall ?? this._mdsjs.CALL_ASYNC; const rows = this.rows(); const cols = this.cols(); let r = this.createArray(1, cols); for (let ix = 0; ix < cols; ix += 1) { r[ix] = Math.random(); } let len = Number.POSITIVE_INFINITY; let stop = false; let iter = 0; const iterate = () => { for (let ix = 0; ix < this._mdsjs.EIGEN_ITER_ASYNC; ix += 1) { if (iter >= this._mdsjs.EIGEN_ITER || stop) { cb(r); return; } const s = this.createArray(1, cols); for (let row = 0; row < rows; row += 1) { let prod = 0; this.rowIter(row, (v, row, col) => { prod += v * r[col]; }); this.rowIter(row, (v, row, col) => { s[col] += prod * v; }); } const nl = this._mdsjs.lengthSq(s); if (Math.abs(len - nl) < this._mdsjs.EIGEN_EPS) { stop = true; } len = nl; this._mdsjs.normalizeVec(s); r = s; iter += 1; } call(iterate); }; iterate(); } static iter( /** @type {Matrix} */ matA, /** @type {Matrix} */ matB, /** @type {number} */ row, /** @type {number} */ col, /** @type {(left: number, right: number, row: number, pos: number, col: number) => void} */ cb, ) { if (matA.cols() !== matB.rows()) { console.warn( 'incompatible dimensions', matA.rows() + 'x' + matA.cols(), matB.rows() + 'x' + matB.cols(), ); return; } let posA = row * matA.cols(); let posB = col; for (let ix = 0; ix < matA.cols(); ix += 1) { cb(matA.getUnsafe(posA), matB.getUnsafe(posB), row, ix, col); posA += 1; posB += matB.cols(); } } static mul(/** @type {Matrix} */ matA, /** @type {Matrix} */ matB) { if (matA.cols() !== matB.rows()) { console.warn( 'incompatible dimensions', `${matA.rows()}x${matA.cols()}`, `${matB.rows()}x${matB.cols()}`, ); return null; } // cache friendly iteration (a rows -> a cols/b rows -> b cols) // TODO experiment with (a cols -> a rows -> b cols) const mat = matA.createArray(matA.rows(), matB.cols()); for (let r = 0; r < matA.rows(); r += 1) { matA.rowIter(r, (a, _, k) => { let pos = r * matB.cols(); matB.rowIter(k, (b, _, __) => { mat[pos] += a * b; pos += 1; }); }); } return new Matrix(matA.mdsjs(), mat, matA.rows(), matB.cols()); } static add(/** @type {Matrix} */ matA, /** @type {Matrix} */ matB) { if (matA.rows() !== matB.rows() || matA.cols() !== matB.cols()) { console.warn( 'incompatible dimensions', `${matA.rows()}x${matA.cols()}`, `${matB.rows()}x${matB.cols()}`, ); return null; } const mat = matA.createArray(matA.rows(), matA.cols()); for (let pos = 0; pos < mat.length; pos += 1) { mat[pos] = matA.getUnsafe(pos) + matB.getUnsafe(pos); } return new Matrix(matA.mdsjs(), mat, matA.rows(), matA.cols()); } } // Matrix export class MDSJs { /** @type {boolean} */ DEBUG = false; /** @type {number} */ GRAM_SCHMIDT_EPS = 1e-12; /** @type {number} */ EIGEN_EPS = 1e-7; /** @type {number} */ EIGEN_ITER = 10000; /** @type {number} */ EIGEN_ITER_ASYNC = 200; constructor() {} noNaNs(/** @type {Float32Array | Float64Array | number[]} */ arr) { for (let ix = 0; ix < arr.length; ix += 1) { if (Number.isNaN(arr[ix])) { throw new Error('NaN in array'); } } } noZeros(/** @type {Float32Array | Float64Array | number[]} */ arr) { for (let ix = 0; ix < arr.length; ix += 1) { if (Number.isNaN(arr[ix]) || arr[ix] === 0) { throw new Error(arr[ix] === 0 ? '0 in array' : 'NaN in array'); } } } onlyPositive(/** @type {Float32Array | Float64Array | number[]} */ arr) { for (let ix = 0; ix < arr.length; ix += 1) { if (Number.isNaN(arr[ix]) || !(arr[ix] > 0)) { throw new Error( !(arr[ix] > 0) ? `${arr[ix]} in array` : 'NaN in array', ); } } } CALL_ASYNC(/** @type {() => void} */ cb) { setTimeout(cb, 0); } getCallDirect() { let depth = 0; return (cb) => { if (depth > 20) { // prevent stack-overflows throw new Continuation(cb); } if (!depth) { let cc = cb; while (cc) { try { depth += 1; cc(); cc = null; } catch (e) { if (!e.__continuation) { throw e; } cc = e.__continuation; } depth = 0; } } else { depth += 1; cb(); } }; } pca(/** @type {Matrix} */ positions) { let res; this.pcaAsync( positions, (mat) => { res = mat; }, this.getCallDirect(), ); return res; } pcaAsync( /** @type {Matrix} */ positions, /** @type {(mat: Matrix) => void} */ cb, /** @type {(cb: () => void) => void} */ argCall, ) { const call = arguments.length > 2 ? argCall : this.CALL_ASYNC; const centered = positions.colCenter(); const cols = centered.cols(); centered.powerIterAsync((pca0) => { const mat = this.removeComponent(centered, pca0); mat.powerIterAsync((pca1) => { const res = centered.createArray(cols, 2); for (let ix = 0; ix < cols; ix += 1) { res[2 * ix + 0] = pca0[ix]; res[2 * ix + 1] = pca1[ix]; } cb(new Matrix(this, res, cols, 2)); }, call); }, call); } removeComponent( /** @type {Matrix} */ mat, /** @type {Float32Array | Float64Array} */ comp, ) { // Gram–Schmidt process const rows = mat.rows(); const cols = mat.cols(); if (comp.length !== cols) { console.warn('incompatible size', comp.length, cols); } const proj = (vec, from, sub, fromSub, len) => { const res = mat.createArray(1, len); let uv = 0; let uu = 0; for (let ix = 0; ix < len; ix += 1) { uv += sub[fromSub + ix] * vec[from + ix]; uu += sub[fromSub + ix] * sub[fromSub + ix]; } if ( Math.abs(uv) < this.GRAM_SCHMIDT_EPS || Math.abs(uu) < this.GRAM_SCHMIDT_EPS || !Number.isFinite(uu) || !Number.isFinite(uv) ) { for (let ix = 0; ix < len; ix += 1) { res[ix] = 0; } } else { for (let ix = 0; ix < len; ix += 1) { res[ix] = (uv / uu) * sub[fromSub + ix]; } } return res; }; const nextMat = mat.createArray(rows, cols); let pos = 0; for (let r = 0; r < rows; r += 1) { mat.rowIter(r, (v) => { nextMat[pos] = v; pos += 1; }); } for (let r = 0; r < rows; r += 1) { const curPos = r * cols; this.normalizeVec(nextMat, curPos, curPos + cols); for (let ix = r + 1; ix < rows; ix += 1) { const mPos = ix * cols; const p = proj(nextMat, mPos, nextMat, curPos, cols); for (let c = 0; c < cols; c += 1) { nextMat[mPos + c] -= p[c]; } } } return new Matrix(this, nextMat, rows, cols); } pcaPositions(/** @type {Matrix} */ positions) { const pca = this.pca(positions); return positions.mul(pca); } landmarkMDS(/** @type {Matrix} */ dist, /** @type {number} */ dims) { let res; this.landmarkMDSAsync( dist, dims, (mat) => { res = mat; }, this.getCallDirect(), ); return res; } landmarkMDSAsync( /** @type {Matrix} */ dist, /** @type {number} */ dims, /** @type {(mat: Matrix) => void} */ cb, /** @type {(cb: () => void) => void} */ argCall, ) { const landmarkMatrix = (/** @type {Matrix} */ mat) => { if (this.DEBUG) { mat.noNaNs(); } const rows = mat.rows(); const cols = mat.cols(); const perm = new Uint32Array(rows); for (let r = 0; r < rows; r += 1) { mat.rowIter(r, (v, r, c) => { if (!v) { perm[r] = c; } }); } if (this.DEBUG) { this.noNaNs(perm); } const lm = mat.createArray(rows, rows); let pos = 0; for (let r = 0; r < rows; r += 1) { const mPos = r * cols; for (let c = 0; c < cols; c += 1) { lm[pos] = mat.getUnsafe(mPos + perm[c]); pos += 1; } } if (this.DEBUG) { this.noNaNs(lm); } return new Matrix(this, lm, rows, rows); }; const landmarkResult = ( /** @type {Matrix} */ dist, /** @type {number} */ dims, /** @type {Matrix} */ eigenVecs, /** @type {Float32Array | Float64Array} */ eigenVals, ) => { const rows = dist.rows(); const cols = dist.cols(); const distSq = dist.squareElements(); if (this.DEBUG) { distSq.noNaNs(); } const mean = dist.createArray(1, cols); for (let c = 0; c < cols; c += 1) { distSq.colIter(c, (v) => { mean[c] += v; }); mean[c] /= rows; } if (this.DEBUG) { this.noNaNs(mean); eigenVecs.noNaNs(); this.noZeros(eigenVals); } const tmp = eigenVecs.createArray(eigenVecs.rows(), eigenVecs.cols()); let pos = 0; for (let r = 0; r < eigenVecs.rows(); r += 1) { const div = Math.sqrt(Math.abs(eigenVals[r])); // TODO not sure how to handle negative values eigenVecs.rowIter(r, (v) => { tmp[pos] = v / div; pos += 1; }); } if (this.DEBUG) { this.noNaNs(tmp); } const positions = dist.createArray(cols, dims); pos = 0; for (let e = 0; e < cols; e += 1) { const m = mean[e]; let tPos = 0; for (let d = 0; d < dims; d += 1) { let cur = 0; distSq.colIter(e, (v) => { cur -= 0.5 * (v - m) * tmp[tPos]; tPos += 1; }); positions[pos] = cur; pos += 1; } } if (this.DEBUG) { this.noNaNs(positions); } return new Matrix(this, positions, cols, dims); }; const call = argCall ?? this.CALL_ASYNC; const lm = landmarkMatrix(dist); const eigenVals = dist.createArray(1, dims); lm.squareElements() .doubleCenter() .scale(-0.5) .eigenAsync( eigenVals, (eigenVecs) => { cb(landmarkResult(dist, dims, eigenVecs, eigenVals)); }, call, ); } normalizeVec( /** @type {Float32Array | Float64Array | number[]} */ vec, /** @type {number} */ f, /** @type {number} */ t, ) { const from = f ?? 0; const to = t ?? vec.length; let sum = 0; for (let ix = from; ix < to; ix += 1) { sum += vec[ix] * vec[ix]; } sum = Math.sqrt(sum); if (sum < 1e-30 || !Number.isFinite(sum)) { // don't scale when really small return; } for (let ix = from; ix < to; ix += 1) { vec[ix] /= sum; } } lengthSq( /** @type {Float32Array | Float64Array | number[]} */ vec, /** @type {number} */ f, /** @type {number} */ t, ) { const from = f ?? 0; const to = t ?? vec.length; let sum = 0; for (let ix = from; ix < to; ix += 1) { sum += vec[ix] * vec[ix]; } return sum; } prod( /** @type {Float32Array | Float64Array | number[]} */ vecA, /** @type {number} */ fromA, /** @type {Float32Array | Float64Array | number[]} */ vecB, /** @type {number} */ fromB, /** @type {number} */ len, ) { let sum = 0; let posA = fromA; let posB = fromB; for (let ix = 0; ix < len; ix += 1) { sum += vecA[posA] * vecB[posB]; posA += 1; posB += 1; } return sum; } xcopy( /** @type {Float32Array | Float64Array | number[]} */ fromVec, /** @type {number} */ fromStart, /** @type {Float32Array | Float64Array | number[]} */ toVec, /** @type {number} */ toStart, /** @type {number} */ len, ) { let fromPos = fromStart; let toPos = toStart; for (let ix = 0; ix < len; ix += 1) { toVec[toPos] = fromVec[fromPos]; fromPos += 1; toPos += 1; } } convertToMatrix( /** @type {number[][]} */ arrs, /** @type {boolean} */ useFloat32, ) { const rows = arrs.length; if (!rows) { console.warn('invalid dimension (rows)', rows); return null; } const cols = arrs[0].length; if (!cols) { console.warn('invalid dimension (cols)', cols); return null; } const size = rows * cols; const mat = useFloat32 ? new Float32Array(size) : new Float64Array(size); let pos = 0; for (let r = 0; r < rows; r += 1) { const row = arrs[r]; if (row.length !== cols) { console.warn('invalid dimension in row ' + r, row.length, cols); return null; } for (let c = 0; c < cols; c += 1) { mat[pos] = row[c]; pos += 1; } } return new Matrix(this, mat, rows, cols); } eye( /** @type {number} */ rows, /** @type {number} */ c, /** @type {boolean} */ useFloat32, ) { const cols = c ?? rows; const size = rows * cols; if (rows <= 0 || cols <= 0) { console.warn('invalid dimensions', rows, cols); return null; } const mat = useFloat32 ? new Float32Array(size) : new Float64Array(size); let pos = 0; for (let ix = 0; ix < Math.min(rows, cols); ix += 1) { mat[pos] = 1; pos += cols + 1; } return new Matrix(this, mat, rows, cols); } pivotRandom(/** @type {Matrix} */ m, /** @type {number} */ k) { if (!m.isQuadratic()) { console.warn('quadratic matrix needed', m.rows(), m.cols()); return null; } if (k < m.rows()) { console.warn( 'requested more pivots than elements', k, m.rows(), m.cols(), ); return null; } const mat = m.createArray(k, m.cols()); const pivots = {}; let pos = 0; for (let ix = 0; ix < k; ix += 1) { let pivot = 0; do { pivot = Math.random() * m.cols(); } while (pivots[pivot]); pivots[pivot] = true; for (let c = 0; c < m.cols(); c += 1) { mat[pos] = m.distance(pivot, c); pos += 1; } } return new Matrix(this, mat, k, m.cols()); } } // MDSJs export default new MDSJs(); // create instance