├── .babelrc ├── .gitignore ├── .npmignore ├── .vscode └── settings.json ├── img ├── screenshot1.png └── screenshot2.png ├── data ├── gas.json └── ethanol.json ├── LICENSE ├── package.json ├── src ├── helpers.js ├── index.js └── inputsValidation.js ├── dist ├── helpers.js ├── inputsValidation.js └── index.js ├── test ├── helpers_spec.js └── validation_spec.js └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | reference/ 3 | *.log 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | test/ 3 | data/ 4 | reference/ 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false 3 | } 4 | -------------------------------------------------------------------------------- /img/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yongjun21/loess/HEAD/img/screenshot1.png -------------------------------------------------------------------------------- /img/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yongjun21/loess/HEAD/img/screenshot2.png -------------------------------------------------------------------------------- /data/gas.json: -------------------------------------------------------------------------------- 1 | { 2 | "NOx": [ 3 | 1.561, 1.990, 2.118, 3.834, 4.602, 4 | 5.199, 4.255, 4.818, 5.064, 5.283, 5 | 5.344, 4.691, 5.055, 4.937, 3.752, 6 | 3.965, 3.275, 2.849, 2.286, 1.640, 7 | 0.970, 0.537 8 | ], 9 | "E": [ 10 | 0.665, 0.701, 0.710, 0.767, 0.801, 11 | 0.807, 0.825, 0.831, 0.891, 0.902, 12 | 0.928, 0.970, 0.973, 0.980, 0.997, 13 | 1.000, 1.021, 1.045, 1.074, 1.089, 14 | 1.148, 1.224 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Yong Jun 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loess", 3 | "version": "1.3.5", 4 | "description": "JavaScript implementation of the Locally-Weighted Regression package originally written in C by Cleveland, Grosse and Shyu (1992)", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "rm -rf dist && mkdir dist && babel src --out-dir dist", 8 | "prepublish": "npm run build && npm run test", 9 | "test": "standard && mocha --compilers js:babel-core/register" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/yongjun21/loess.git" 14 | }, 15 | "author": "Yong Jun", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/yongjun21/loess/issues" 19 | }, 20 | "homepage": "https://github.com/yongjun21/loess#readme", 21 | "keywords": [ 22 | "statistic", 23 | "regression", 24 | "smoothing", 25 | "fitting", 26 | "graph", 27 | "plot", 28 | "utility" 29 | ], 30 | "dependencies": { 31 | "gaussian": "^1.0.0", 32 | "lodash.sortby": "^4.0.2", 33 | "lodash.zip": "^4.0.0", 34 | "mathjs": "^7.5.1" 35 | }, 36 | "devDependencies": { 37 | "babel-cli": "^6.4.5", 38 | "babel-preset-es2015": "^6.3.13", 39 | "chai": "^3.5.0", 40 | "mocha": "^2.4.5", 41 | "standard": "^5.4.1" 42 | } 43 | } -------------------------------------------------------------------------------- /data/ethanol.json: -------------------------------------------------------------------------------- 1 | { 2 | "NOx": [ 3 | 3.741, 2.295, 1.498, 2.881, 0.76, 3.12, 0.638, 1.17, 2.358, 4 | 0.606, 3.669, 1, 0.981, 1.192, 0.926, 1.59, 1.806, 1.962, 5 | 4.028, 3.148, 1.836, 2.845, 1.013, 0.414, 0.812, 0.374, 3.623, 6 | 1.869, 2.836, 3.567, 0.866, 1.369, 0.542, 2.739, 1.2, 1.719, 7 | 3.423, 1.634, 1.021, 2.157, 3.361, 1.39, 1.947, 0.962, 0.571, 8 | 2.219, 1.419, 3.519, 1.732, 3.206, 2.471, 1.777, 2.571, 3.952, 9 | 3.931, 1.587, 1.397, 3.536, 2.202, 0.756, 1.62, 3.656, 2.964, 10 | 3.76, 0.672, 3.677, 3.517, 3.29, 1.139, 0.727, 2.581, 0.923, 11 | 1.527, 3.388, 2.085, 0.966, 3.488, 0.754, 0.797, 2.064, 3.732, 12 | 0.586, 0.561, 0.563, 0.678, 0.37, 0.53, 1.9 13 | ], 14 | "C": [ 15 | 12, 12, 12, 12, 12, 9, 9, 9, 12, 12, 12, 12, 15, 18, 7.5, 12, 16 | 12, 15, 15, 9, 9, 7.5, 7.5, 18, 18, 15, 15, 7.5, 7.5, 9, 15, 15, 17 | 15, 15, 15, 9, 9, 7.5, 7.5, 7.5, 18, 18, 18, 18, 9, 9, 9, 9, 18 | 7.5, 7.5, 7.5, 15, 18, 18, 15, 15, 7.5, 7.5, 7.5, 7.5, 7.5, 7.5, 19 | 7.5, 18, 18, 18, 12, 12, 9, 9, 9, 15, 15, 15, 15, 15, 7.5, 7.5, 20 | 9, 7.5, 18, 18, 7.5, 9, 12, 15, 18, 18 21 | ], 22 | "E": [ 23 | 0.907, 0.761, 1.108, 1.016, 1.189, 1.001, 1.231, 1.123, 1.042, 24 | 1.215, 0.93, 1.152, 1.138, 0.601, 0.696, 0.686, 1.072, 1.074, 25 | 0.934, 0.808, 1.071, 1.009, 1.142, 1.229, 1.175, 0.568, 0.977, 26 | 0.767, 1.006, 0.893, 1.152, 0.693, 1.232, 1.036, 1.125, 1.081, 27 | 0.868, 0.762, 1.144, 1.045, 0.797, 1.115, 1.07, 1.219, 0.637, 28 | 0.733, 0.715, 0.872, 0.765, 0.878, 0.811, 0.676, 1.045, 0.968, 29 | 0.846, 0.684, 0.729, 0.911, 0.808, 1.168, 0.749, 0.892, 1.002, 30 | 0.812, 1.23, 0.804, 0.813, 1.002, 0.696, 1.199, 1.03, 0.602, 31 | 0.694, 0.816, 1.037, 1.181, 0.899, 1.227, 1.18, 0.795, 0.99, 32 | 1.201, 0.629, 0.608, 0.584, 0.562, 0.535, 0.655 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import {std, sum, subtract, dotMultiply, matrix, transpose as mathTranspose, inv, multiply, squeeze} from 'mathjs' 2 | import sort from 'lodash.sortby' 3 | import zip from 'lodash.zip' 4 | 5 | export function weightFunc (d, dmax, degree) { 6 | return d < dmax ? Math.pow(1 - Math.pow(d / dmax, degree), degree) : 0 7 | } 8 | 9 | export function normalize (referenceArr) { 10 | const cutoff = Math.ceil(0.1 * referenceArr.length) 11 | const trimmed_arr = sort(referenceArr).slice(cutoff, referenceArr.length - cutoff) 12 | const sd = std(trimmed_arr) 13 | return function (outputArr) { 14 | return outputArr.map(val => val / sd) 15 | } 16 | } 17 | 18 | export function transpose (X) { 19 | const transposed = [] 20 | for (let i = 0; i < X[0].length; i++) { 21 | transposed.push(X.map(x => x[i])) 22 | } 23 | return transposed 24 | } 25 | 26 | export function euclideanDist (orig, dest) { 27 | if (orig.length < 2) { 28 | return Math.abs(orig[0] - dest[0]) 29 | } else { 30 | return Math.sqrt(orig.reduce((acc, val, idx) => acc + Math.pow(val - dest[idx], 2), 0)) 31 | } 32 | } 33 | 34 | export function distMatrix (origSet, destSet) { 35 | return origSet.map(orig => destSet.map(dest => euclideanDist(orig, dest))) 36 | } 37 | 38 | export function weightMatrix (distMat, inputWeights, bandwidth) { 39 | return distMat.map(distVect => { 40 | const sorted = sort(zip(distVect, inputWeights), (v) => v[0]) 41 | const cutoff = sum(inputWeights) * bandwidth 42 | let sumOfWeights = 0 43 | let cutoffIndex = sorted.findIndex(v => { 44 | sumOfWeights += v[1] 45 | return sumOfWeights >= cutoff 46 | }) 47 | let dmax = bandwidth > 1 48 | ? (sorted[sorted.length - 1][0] * bandwidth) 49 | : sorted[cutoffIndex][0] 50 | return dotMultiply(distVect.map(d => weightFunc(d, dmax, 3)), inputWeights) 51 | }) 52 | } 53 | 54 | export function polynomialExpansion (factors, degree) { 55 | const expandedSet = [] 56 | let constTerm = 1 57 | if (Array.isArray(factors[0])) constTerm = Array(factors[0].length).fill(1) 58 | function crossMultiply (accumulator, pointer, n) { 59 | if (n > 1) { 60 | for (let i = pointer; i < factors.length; i++) { 61 | crossMultiply(dotMultiply(accumulator, factors[i]), i, n - 1) 62 | } 63 | } else { 64 | expandedSet.push(accumulator) 65 | } 66 | } 67 | for (let d = 0; d <= degree; d++) crossMultiply(constTerm, 0, d + 1) 68 | return expandedSet 69 | } 70 | 71 | export function weightedLeastSquare (predictors, response, weights) { 72 | try { 73 | const weightedY = matrix(dotMultiply(weights, response)) 74 | const weightedX = mathTranspose(matrix(predictors.map(x => { 75 | return dotMultiply(weights, x) 76 | }))) 77 | const LHS = multiply(predictors, weightedX) 78 | const RHS = multiply(predictors, weightedY) 79 | const beta = multiply(inv(LHS), RHS) 80 | const yhat = squeeze(multiply(beta, predictors)) 81 | const residual = subtract(response, yhat) 82 | return {beta, yhat, residual} 83 | } catch (err) { 84 | return {error: err} 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as math from 'mathjs' 2 | import sort from 'lodash.sortby' 3 | import gaussian from 'gaussian' 4 | import {validateModel, validatePredict, validateGrid} from './inputsValidation' 5 | import {weightFunc, normalize, transpose, distMatrix, weightMatrix, 6 | polynomialExpansion, weightedLeastSquare} from './helpers' 7 | // import data from '../data/gas.json' 8 | 9 | export default class Loess { 10 | constructor (data, options = {}) { 11 | Object.assign(this, validateModel(data, options)) 12 | 13 | if (this.options.normalize) this.normalization = this.x.map(normalize) 14 | 15 | this.expandedX = polynomialExpansion(this.x, this.options.degree) 16 | const normalized = this.normalization 17 | ? this.x.map((x, idx) => this.normalization[idx](x)) : this.x 18 | this.transposedX = transpose(normalized) 19 | } 20 | 21 | predict (data) { 22 | const {x_new, n} = validatePredict.bind(this)(data) 23 | 24 | const expandedX = polynomialExpansion(x_new, this.options.degree) 25 | const normalized = this.normalization ? x_new.map((x, idx) => this.normalization[idx](x)) : x_new 26 | const distM = distMatrix(transpose(normalized), this.transposedX) 27 | const weightM = weightMatrix(distM, this.w, this.bandwidth) 28 | 29 | let fitted, residuals, weights, betas 30 | function iterate (wt) { 31 | fitted = [] 32 | residuals = [] 33 | betas = [] 34 | weights = math.dotMultiply(wt, weightM) 35 | transpose(expandedX).forEach((point, idx) => { 36 | const fit = weightedLeastSquare(this.expandedX, this.y, weights[idx]) 37 | if (fit.error) { 38 | const sumWeights = math.sum(weights[idx]) 39 | const mle = sumWeights === 0 ? 0 : math.multiply(this.y, weights[idx]) / sumWeights 40 | fit.beta = math.zeros(this.expandedX.length).set([0], mle) 41 | fit.residual = math.subtract(this.y, mle) 42 | } 43 | fitted.push(math.squeeze(math.multiply(point, fit.beta))) 44 | residuals.push(fit.residual) 45 | betas.push(fit.beta.toArray()) 46 | const median = math.median(math.abs(fit.residual)) 47 | wt[idx] = fit.residual.map(r => weightFunc(r, 6 * median, 2)) 48 | }) 49 | } 50 | 51 | const robustWeights = Array(n).fill(math.ones(this.n)) 52 | for (let iter = 0; iter < this.options.iterations; iter++) iterate.bind(this)(robustWeights) 53 | 54 | const output = {fitted, betas, weights} 55 | 56 | if (this.options.band) { 57 | const z = gaussian(0, 1).ppf(1 - (1 - this.options.band) / 2) 58 | const halfwidth = weights.map((weight, idx) => { 59 | const V1 = math.sum(weight) 60 | const V2 = math.multiply(weight, weight) 61 | const intervalEstimate = Math.sqrt(math.multiply(math.square(residuals[idx]), weight) / (V1 - V2 / V1)) 62 | return intervalEstimate * z 63 | }) 64 | Object.assign(output, {halfwidth}) 65 | } 66 | 67 | return output 68 | } 69 | 70 | grid (cuts) { 71 | validateGrid.bind(this)(cuts) 72 | 73 | const x_new = [] 74 | const x_cuts = [] 75 | this.x.forEach((x, idx) => { 76 | const x_sorted = sort(x) 77 | const x_min = x_sorted[0] 78 | const x_max = x_sorted[this.n - 1] 79 | const width = (x_max - x_min) / (cuts[idx] - 1) 80 | x_cuts.push([]) 81 | for (let i = 0; i < cuts[idx]; i++) x_cuts[idx].push(x_min + i * width) 82 | 83 | let repeats = 1 84 | let copies = 1 85 | for (let i = idx - 1; i >= 0; i--) repeats *= cuts[i] 86 | for (let i = idx + 1; i < this.d; i++) copies *= cuts[i] 87 | 88 | x_new.push([]) 89 | for (let i = 0; i < repeats; i++) { 90 | x_new[idx] = x_new[idx].concat(x_cuts[idx].reduce((acc, cut) => acc.concat(Array(copies).fill(cut)), [])) 91 | } 92 | }) 93 | 94 | const data = {x: x_new[0], x_cut: x_cuts[0]} 95 | if (this.d > 1) Object.assign(data, {x2: x_new[1], x_cut2: x_cuts[1]}) 96 | return data 97 | } 98 | } 99 | 100 | // const w = data.NOx.map(() => Math.random() * 10) 101 | // const fit = new Loess({y: data.NOx, x: data.E, w}, {span: 0.8, band: 0.8, degree: 'quadratic'}) 102 | // console.log(JSON.stringify(fit.predict(fit.grid([30])))) 103 | -------------------------------------------------------------------------------- /dist/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.weightFunc = weightFunc; 7 | exports.normalize = normalize; 8 | exports.transpose = transpose; 9 | exports.euclideanDist = euclideanDist; 10 | exports.distMatrix = distMatrix; 11 | exports.weightMatrix = weightMatrix; 12 | exports.polynomialExpansion = polynomialExpansion; 13 | exports.weightedLeastSquare = weightedLeastSquare; 14 | 15 | var _mathjs = require('mathjs'); 16 | 17 | var _lodash = require('lodash.sortby'); 18 | 19 | var _lodash2 = _interopRequireDefault(_lodash); 20 | 21 | var _lodash3 = require('lodash.zip'); 22 | 23 | var _lodash4 = _interopRequireDefault(_lodash3); 24 | 25 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 26 | 27 | function weightFunc(d, dmax, degree) { 28 | return d < dmax ? Math.pow(1 - Math.pow(d / dmax, degree), degree) : 0; 29 | } 30 | 31 | function normalize(referenceArr) { 32 | var cutoff = Math.ceil(0.1 * referenceArr.length); 33 | var trimmed_arr = (0, _lodash2.default)(referenceArr).slice(cutoff, referenceArr.length - cutoff); 34 | var sd = (0, _mathjs.std)(trimmed_arr); 35 | return function (outputArr) { 36 | return outputArr.map(function (val) { 37 | return val / sd; 38 | }); 39 | }; 40 | } 41 | 42 | function transpose(X) { 43 | var transposed = []; 44 | 45 | var _loop = function _loop(i) { 46 | transposed.push(X.map(function (x) { 47 | return x[i]; 48 | })); 49 | }; 50 | 51 | for (var i = 0; i < X[0].length; i++) { 52 | _loop(i); 53 | } 54 | return transposed; 55 | } 56 | 57 | function euclideanDist(orig, dest) { 58 | if (orig.length < 2) { 59 | return Math.abs(orig[0] - dest[0]); 60 | } else { 61 | return Math.sqrt(orig.reduce(function (acc, val, idx) { 62 | return acc + Math.pow(val - dest[idx], 2); 63 | }, 0)); 64 | } 65 | } 66 | 67 | function distMatrix(origSet, destSet) { 68 | return origSet.map(function (orig) { 69 | return destSet.map(function (dest) { 70 | return euclideanDist(orig, dest); 71 | }); 72 | }); 73 | } 74 | 75 | function weightMatrix(distMat, inputWeights, bandwidth) { 76 | return distMat.map(function (distVect) { 77 | var sorted = (0, _lodash2.default)((0, _lodash4.default)(distVect, inputWeights), function (v) { 78 | return v[0]; 79 | }); 80 | var cutoff = (0, _mathjs.sum)(inputWeights) * bandwidth; 81 | var sumOfWeights = 0; 82 | var cutoffIndex = sorted.findIndex(function (v) { 83 | sumOfWeights += v[1]; 84 | return sumOfWeights >= cutoff; 85 | }); 86 | var dmax = bandwidth > 1 ? sorted[sorted.length - 1][0] * bandwidth : sorted[cutoffIndex][0]; 87 | return (0, _mathjs.dotMultiply)(distVect.map(function (d) { 88 | return weightFunc(d, dmax, 3); 89 | }), inputWeights); 90 | }); 91 | } 92 | 93 | function polynomialExpansion(factors, degree) { 94 | var expandedSet = []; 95 | var constTerm = 1; 96 | if (Array.isArray(factors[0])) constTerm = Array(factors[0].length).fill(1); 97 | function crossMultiply(accumulator, pointer, n) { 98 | if (n > 1) { 99 | for (var i = pointer; i < factors.length; i++) { 100 | crossMultiply((0, _mathjs.dotMultiply)(accumulator, factors[i]), i, n - 1); 101 | } 102 | } else { 103 | expandedSet.push(accumulator); 104 | } 105 | } 106 | for (var d = 0; d <= degree; d++) { 107 | crossMultiply(constTerm, 0, d + 1); 108 | }return expandedSet; 109 | } 110 | 111 | function weightedLeastSquare(predictors, response, weights) { 112 | try { 113 | var weightedY = (0, _mathjs.matrix)((0, _mathjs.dotMultiply)(weights, response)); 114 | var weightedX = (0, _mathjs.transpose)((0, _mathjs.matrix)(predictors.map(function (x) { 115 | return (0, _mathjs.dotMultiply)(weights, x); 116 | }))); 117 | var LHS = (0, _mathjs.multiply)(predictors, weightedX); 118 | var RHS = (0, _mathjs.multiply)(predictors, weightedY); 119 | var beta = (0, _mathjs.multiply)((0, _mathjs.inv)(LHS), RHS); 120 | var yhat = (0, _mathjs.squeeze)((0, _mathjs.multiply)(beta, predictors)); 121 | var residual = (0, _mathjs.subtract)(response, yhat); 122 | return { beta: beta, yhat: yhat, residual: residual }; 123 | } catch (err) { 124 | return { error: err }; 125 | } 126 | } -------------------------------------------------------------------------------- /src/inputsValidation.js: -------------------------------------------------------------------------------- 1 | function validateIsArray (target, msg) { 2 | if (!Array.isArray(target)) throw new Error(msg) 3 | } 4 | 5 | function validateIsNumber (target, msg) { 6 | if (typeof target !== 'number') throw new Error(msg) 7 | } 8 | 9 | function validateIsInteger (target, msg) { 10 | validateIsNumber(target, msg) 11 | if (target - Math.floor(target) > 0) throw new Error(msg) 12 | } 13 | 14 | export function validateModel (data, options) { 15 | if (!data) throw new Error('no data passed in to constructor') 16 | if (typeof data !== 'object') throw new Error('no data passed in to constructor') 17 | let {y, x, x2, w} = data 18 | 19 | validateIsArray(y, 'Invalid type: y should be an array') 20 | validateIsArray(x, 'Invalid type: x should be an array') 21 | 22 | y.forEach(v => validateIsNumber(v, 'Invalid type: y should include only numbers')) 23 | x.forEach(v => validateIsNumber(v, 'Invalid type: x should include only numbers')) 24 | 25 | const n = y.length 26 | if (x.length !== n) throw new Error('y and x have different length') 27 | x = [x] 28 | 29 | if (x2) { 30 | validateIsArray(x2, 'Invalid type: x2 should be an array') 31 | x2.forEach(v => validateIsNumber(v, 'Invalid type: x2 should include only numbers')) 32 | if (x2.length !== n) throw new Error('y and x2 have different length') 33 | x.push(x2) 34 | } 35 | 36 | if (w) { 37 | validateIsArray(w, 'Invalid type: w should be an array') 38 | w.forEach(v => validateIsNumber(v, 'Invalid type: w should include only numbers')) 39 | if (w.length !== n) throw new Error('y and w have different length') 40 | } else { 41 | w = Array(n).fill(1) 42 | } 43 | 44 | if (!options || typeof options !== 'object') throw new Error('Invalid type: options should be passed in as an object') 45 | options = Object.assign({ 46 | span: 0.75, 47 | band: 0, 48 | degree: 2, 49 | normalize: true, 50 | robust: false, 51 | iterations: 4 52 | }, options) 53 | 54 | if (typeof options.degree === 'string') { 55 | options.degree = ['constant', 'linear', 'quadratic'].indexOf(options.degree) 56 | } 57 | validateIsNumber(options.span, 'Invalid type: options.span should be a number') 58 | if (options.span <= 0) throw new Error('options.span should be greater than 0') 59 | 60 | validateIsNumber(options.band, 'Invalid type: options.band should be a number') 61 | if (options.band < 0 || options.band > 0.99) throw new Error('options.band should be between 0 and 1') 62 | 63 | validateIsInteger(options.degree, 'Invalid type: options.degree should be an integer') 64 | if (options.degree < 0 || options.degree > 2) throw new Error('options.degree should be between 0 and 2') 65 | 66 | if (typeof options.normalize !== 'boolean') throw new Error('Invalid type: options.normalize should be a boolean') 67 | if (typeof options.robust !== 'boolean') throw new Error('Invalid type: options.robust should be a boolean') 68 | 69 | validateIsInteger(options.iterations, 'Invalid type: options.iterations should be an integer') 70 | if (options.iterations < 1) throw new Error('options.iterations should be at least 1') 71 | if (!options.robust) options.iterations = 1 72 | 73 | const d = x2 ? 2 : 1 74 | const bandwidth = options.span > 1 ? Math.pow(options.span, 1 / d) : options.span 75 | 76 | return {y, x, w, n, d, options, bandwidth} 77 | } 78 | 79 | export function validatePredict (data) { 80 | if (!data) data = {x: this.x[0], x2: this.x[1]} 81 | if (typeof data !== 'object') throw new Error('Invalid type: data should be supplied as an object') 82 | 83 | let {x, x2 = null} = data 84 | 85 | validateIsArray(x, 'Invalid type: x should be an array') 86 | x.forEach(v => validateIsNumber(v, 'Invalid type: x should include only numbers')) 87 | 88 | const x_new = [x] 89 | const n = x.length 90 | 91 | if (this.d > 1) { 92 | validateIsArray(x2, 'Invalid type: x2 should be an array') 93 | x2.forEach(v => validateIsNumber(v, 'Invalid type: x2 should include only numbers')) 94 | if (x2.length !== n) throw new Error('x and x2 have different length') 95 | x_new.push(x2) 96 | } else if (x2) { 97 | throw new Error('extra variable x2') 98 | } 99 | return {x_new, n} 100 | } 101 | 102 | export function validateGrid (cuts) { 103 | validateIsArray(cuts, 'Invalid type: cuts should be an array') 104 | cuts.forEach(cut => { 105 | validateIsInteger(cut, 'Invalid type: cuts should include only integers') 106 | if (cuts < 3) throw new Error('cuts should include only integers > 2') 107 | }) 108 | if (cuts.length !== this.d) throw new Error('cuts.length should match dimension of predictors') 109 | } 110 | -------------------------------------------------------------------------------- /test/helpers_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import {expect} from 'chai' 3 | import { matrix, round } from 'mathjs' 4 | import { 5 | weightFunc, normalize, transpose, 6 | euclideanDist, distMatrix, weightMatrix, 7 | polynomialExpansion, weightedLeastSquare 8 | } from '../src/helpers' 9 | 10 | describe('function weightFunc', function () { 11 | it('should return (1-(d/dmax)^n)^n if d < dmax', function () { 12 | expect(weightFunc(0.5, 1, 3)).to.equal(0.669921875) 13 | expect(weightFunc(0.5, 1, 2)).to.equal(0.5625) 14 | }) 15 | it('should return 0 if d >= dmax', function () { 16 | expect(weightFunc(1, 1, 3)).to.equal(0) 17 | expect(weightFunc(1, 1, 2)).to.equal(0) 18 | }) 19 | }) 20 | 21 | describe('function transpose', function () { 22 | const caseOne = { 23 | test: [ 24 | [1, 2, 3, 4, 5], 25 | [6, 7, 8, 9, 10] 26 | ], 27 | expect: [ 28 | [1, 6], 29 | [2, 7], 30 | [3, 8], 31 | [4, 9], 32 | [5, 10] 33 | ] 34 | } 35 | 36 | it('should return transposed matrix', function () { 37 | expect(transpose(caseOne.test)).to.eql(caseOne.expect) 38 | }) 39 | }) 40 | 41 | describe('function normalize', function () { 42 | const caseOne = { 43 | test: [109, 8, 7, 6, 5, 4, 3, 2, 1, -100], 44 | expect: [44.499, 3.266, 2.858, 2.449, 2.041, 1.633, 1.225, 0.816, 0.408, -40.825] 45 | } 46 | 47 | it('should return array divided by 10% trimmed sample deviation', function () { 48 | const normalizedArr = normalize(caseOne.test)(caseOne.test) 49 | expect(round(normalizedArr, 3)).to.eql(caseOne.expect) 50 | }) 51 | }) 52 | 53 | describe('function euclideanDist', function () { 54 | it('should return Euclidean distance between two vectors', function () { 55 | expect(round(euclideanDist([1, 2, 3], [4, 5, 6]), 3)).to.equal(5.196) 56 | }) 57 | }) 58 | 59 | describe('function distMatrix', function () { 60 | const caseOne = { 61 | coordinates: [ 62 | [1, 1], 63 | [4, 1], 64 | [4, 5] 65 | ], 66 | expect: [ 67 | [0, 3, 5], 68 | [3, 0, 4], 69 | [5, 4, 0] 70 | ] 71 | } 72 | it('should return matrix of Euclidean distance between pairs of points', function () { 73 | expect(distMatrix(caseOne.coordinates, caseOne.coordinates)).to.eql(caseOne.expect) 74 | }) 75 | }) 76 | 77 | describe('function weightMatrix', function () { 78 | const distMat = [ 79 | [5, 4, 3, 2, 1] 80 | ] 81 | const caseOne = { 82 | inputWeights: [1, 1, 1, 1, 1], 83 | bandwidth: 0.6, 84 | expect: [ 85 | [0, 0, 0, 0.348, 0.893] 86 | ] 87 | } 88 | const caseTwo = { 89 | inputWeights: [1, 1, 1, 1, 1], 90 | bandwidth: 2, 91 | expect: [ 92 | [0.67, 0.82, 0.921, 0.976, 0.997] 93 | ] 94 | } 95 | 96 | it('span <= 1', function () { 97 | const actual = weightMatrix(distMat, caseOne.inputWeights, caseOne.bandwidth) 98 | actual[0] = round(actual[0], 3) 99 | expect(actual).to.eql(caseOne.expect) 100 | }) 101 | it('span > 1', function () { 102 | const actual = weightMatrix(distMat, caseTwo.inputWeights, caseTwo.bandwidth) 103 | actual[0] = round(actual[0], 3) 104 | expect(actual).to.eql(caseTwo.expect) 105 | }) 106 | }) 107 | 108 | describe('function polynomialExpansion', function () { 109 | it('(a + b + c)^0 >>> (1)', function () { 110 | expect(polynomialExpansion([1, 2, 3], 0)).to.eql([1]) 111 | }) 112 | it('(a + b + c)^1 >>> (1 + a + b + c)', function () { 113 | expect(polynomialExpansion([1, 2, 3], 1)).to.eql([1, 1, 2, 3]) 114 | }) 115 | it('(a + b + c)^2 >>> (1 + a + b + c + a2 + ab + ac + b2 + bc + c2)', function () { 116 | expect(polynomialExpansion([1, 2, 3], 2)).to.eql([1, 1, 2, 3, 1, 2, 3, 4, 6, 9]) 117 | }) 118 | it('should operates on arrays also', function () { 119 | expect(polynomialExpansion([[1, 2], [3, 4]], 2)).to.eql([[1, 1], [1, 2], [3, 4], [1, 4], [3, 8], [9, 16]]) 120 | }) 121 | }) 122 | 123 | describe('function weightedLeastSquare', function () { 124 | const caseOne = { 125 | x: [ 126 | [1, 1, 1, 1], 127 | [1, 3, 5, 7] 128 | ], 129 | y: [14, 17, 19, 20], 130 | w: [1, 1, 1, 1], 131 | expect: { 132 | beta: matrix([13.5, 1]), 133 | yhat: matrix([14.5, 16.5, 18.5, 20.5]), 134 | residual: matrix([-0.5, 0.5, 0.5, -0.5]) 135 | } 136 | } 137 | 138 | const caseTwo = { 139 | x: [ 140 | [1, 1, 1, 1], 141 | [1, 3, 5, 7] 142 | ], 143 | y: [14, 17, 19, 20], 144 | w: [1, 3, 3, 1], 145 | expect: { 146 | beta: matrix([13.75, 1]), 147 | yhat: matrix([14.75, 16.75, 18.75, 20.75]), 148 | residual: matrix([-0.75, 0.25, 0.25, -0.75]) 149 | } 150 | } 151 | 152 | const caseThree = { 153 | x: [ 154 | [1, 1, 1, 1], 155 | [1, 1, 1, 1] 156 | ], 157 | y: [14, 17, 19, 20], 158 | w: [1, 3, 3, 1] 159 | } 160 | 161 | it('should return vector of fitted parameters (w/o weights)', function () { 162 | expect(weightedLeastSquare(caseOne.x, caseOne.y, caseOne.w)).to.eql(caseOne.expect) 163 | }) 164 | 165 | it('should return vector of fitted parameters (with weights)', function () { 166 | expect(weightedLeastSquare(caseTwo.x, caseTwo.y, caseTwo.w)).to.eql(caseTwo.expect) 167 | }) 168 | 169 | it('should return error object if x is non-invertible', function () { 170 | expect(weightedLeastSquare(caseThree.x, caseThree.y, caseThree.w)).to.contain.keys('error') 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # loess 2 | 3 | JavaScript implementation of the Locally-Weighted Regression package originally written in C by Cleveland, Grosse and Shyu (1992) 4 | 5 | ## Getting started 6 | 7 | First install the package: 8 | 9 | ``` 10 | npm install loess --save 11 | ``` 12 | 13 | Load in your data: 14 | 15 | ```javascript 16 | var data = require("./myData.json"); 17 | ``` 18 | 19 | Instantiate a LOESS model with the data: 20 | 21 | ```javascript 22 | var Loess = require("loess"); 23 | var options = { span: 0.5, band: 0.8, degree: 1 }; 24 | var model = new Loess(data, options); 25 | ``` 26 | 27 | Fit model by calling the **.predict( )** method on the model object: 28 | 29 | ```javascript 30 | var fit = model.predict(); 31 | console.log(fit.fitted); 32 | // do something else with fit.fitted 33 | ``` 34 | 35 | To fit model on a new set of points, pass a data object into **.predict( )** 36 | 37 | ```javascript 38 | var newData = { 39 | x: [1, 2, 3, 4, 5], 40 | x2: [6, 7, 8, 9, 10], 41 | }; 42 | 43 | fit = model.predict(newData); 44 | 45 | var upperLimit = fit.fitted.map((yhat, idx) => yhat + fit.halfwidth[idx]); 46 | var lowerLimit = fit.fitted.map((yhat, idx) => yhat - fit.halfwidth[idx]); 47 | // plot upperLimit and lowerLimit 48 | ``` 49 | 50 | Alternatively, use **.grid( )** method to generate a grid of equally spaced points: 51 | 52 | ```javascript 53 | newData = model.grid([20, 20]); 54 | 55 | fit = model.predict(newData); 56 | ``` 57 | 58 | --- 59 | 60 | ## Usage 61 | 62 | ![screenshot1](./img/screenshot1.png) 63 | 64 | ![screenshot2](./img/screenshot2.png) 65 | 66 | #### Find out more by visiting my demo app:
67 | 68 | [https://loess.netlify.com/](https://loess.netlify.com/) 69 | 70 | --- 71 | 72 | ## Documentation 73 | 74 | ```javascript 75 | class Loess { 76 | constructor (data: object, options: object) { 77 | // arguments 78 | data /*required*/ = { 79 | y: [number], 80 | x: [number], 81 | x2: [number], // optional 82 | w: [number] // optional 83 | } 84 | 85 | options /*optional*/ = { 86 | span: number, // 0 to inf, default 0.75 87 | band: number, // 0 to 1, default 0 88 | degree: [0, 1, 2] || ['constant', 'linear', 'quadratic'] // default 2 89 | normalize: boolean, // default true if degree > 1, false otherwise 90 | robust: boolean, // default false 91 | iterations: integer //default 4 if robust = true, 1 otherwise 92 | } 93 | 94 | // return a LOESS model object with the following properties 95 | this.y = data.y 96 | this.x = [data.x, data.x2] // predictor matrix 97 | this.n = this.y.length // number of data points 98 | this.d = this.x.length // dimension of predictors 99 | this.bandwidth = options.span * this.n // number of data points used in local regression 100 | this.options = options 101 | } 102 | 103 | predict (data: object) { 104 | // arguments 105 | data /*optional*/ = { 106 | x: [number], 107 | x2: [number] 108 | } // default this.x 109 | 110 | return { 111 | fitted: [number], // fitted values for the specified data points 112 | halfwidth: [number] // fitted +- halfwidth is the uncertainty band 113 | } 114 | } 115 | 116 | grid (cuts: [integer]) { 117 | return { 118 | x_cut: [number], // equally-spaced data points 119 | x_cut2: [number], 120 | x: [number], // all combination of x_cut and x_cut2, forming a grid 121 | x2: [number] 122 | } 123 | } 124 | } 125 | ``` 126 | 127 | #### Note: 128 | 129 | - **data** should be passed into the constructor function as json with keys **y**, **x** and optionally **x2** and **w**. Values being the arrays of response, predictor variables, and observation weights. 130 | - If no data is supplied to **.predict( )** method, default is to perform fitting on the original dataset the model is constructed with. 131 | - **span** refers to the percentage number of neighboring points used in local regression. 132 | - **band** specifies how wide the uncertainty band should be. The higher the value, the greater number of points encompassed by the uncertainty band. Setting to 0 will return only **fitted** values. 133 | - By default LOESS model will perform local fitting using the quadratic function. Overwrite this by setting the **degree** option to "linear" or "constant". Lower degree fitting function computes faster. 134 | - For multivariate data, **normalize** option defaults to true. This means normalization is applied before performing proximity calculation. Data is transformed by dividing the factors by their 10% trimmed sample standard deviation. Turn off this option if dealing with geographical data. 135 | - Set **robust** option to true to turn on iterative robust fitting procedure. Applicable for estimates that have non-Gaussian errors. More **iterations** requires longer computation time. 136 | - When using **.grid( )**, cuts refers to the number of equally spaced points required along each axis. 137 | - `dist` is intentionally checked in to avoid the need to build when installing directly from GitHub. 138 | 139 | ## Credits 140 | 141 | William S. Cleveland, Susan J. Devlin
142 | [Locally Weighted Regression: An Approach to Regression Analysis by Local Fitting](http://www.stat.washington.edu/courses/stat527/s13/readings/Cleveland_Delvin_JASA_1988.pdf)
143 | Journal of the American Statistical Association, Vol. 83, No. 403. (Sep., 1988), pp. 596-610. 144 | 145 | William S. Cleveland, Eric Grosse, Ming-Jen Shyu
146 | [A Package of C and Fortran Routines for Fitting Local Regression Models ](www.netlib.org/a/cloess.ps) (20 August 1992)
147 | Source code available at [http://www.netlib.org/a/dloess](http://www.netlib.org/a/dloess) 148 | -------------------------------------------------------------------------------- /dist/inputsValidation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; 8 | 9 | exports.validateModel = validateModel; 10 | exports.validatePredict = validatePredict; 11 | exports.validateGrid = validateGrid; 12 | function validateIsArray(target, msg) { 13 | if (!Array.isArray(target)) throw new Error(msg); 14 | } 15 | 16 | function validateIsNumber(target, msg) { 17 | if (typeof target !== 'number') throw new Error(msg); 18 | } 19 | 20 | function validateIsInteger(target, msg) { 21 | validateIsNumber(target, msg); 22 | if (target - Math.floor(target) > 0) throw new Error(msg); 23 | } 24 | 25 | function validateModel(data, options) { 26 | if (!data) throw new Error('no data passed in to constructor'); 27 | if ((typeof data === 'undefined' ? 'undefined' : _typeof(data)) !== 'object') throw new Error('no data passed in to constructor'); 28 | var y = data.y, 29 | x = data.x, 30 | x2 = data.x2, 31 | w = data.w; 32 | 33 | 34 | validateIsArray(y, 'Invalid type: y should be an array'); 35 | validateIsArray(x, 'Invalid type: x should be an array'); 36 | 37 | y.forEach(function (v) { 38 | return validateIsNumber(v, 'Invalid type: y should include only numbers'); 39 | }); 40 | x.forEach(function (v) { 41 | return validateIsNumber(v, 'Invalid type: x should include only numbers'); 42 | }); 43 | 44 | var n = y.length; 45 | if (x.length !== n) throw new Error('y and x have different length'); 46 | x = [x]; 47 | 48 | if (x2) { 49 | validateIsArray(x2, 'Invalid type: x2 should be an array'); 50 | x2.forEach(function (v) { 51 | return validateIsNumber(v, 'Invalid type: x2 should include only numbers'); 52 | }); 53 | if (x2.length !== n) throw new Error('y and x2 have different length'); 54 | x.push(x2); 55 | } 56 | 57 | if (w) { 58 | validateIsArray(w, 'Invalid type: w should be an array'); 59 | w.forEach(function (v) { 60 | return validateIsNumber(v, 'Invalid type: w should include only numbers'); 61 | }); 62 | if (w.length !== n) throw new Error('y and w have different length'); 63 | } else { 64 | w = Array(n).fill(1); 65 | } 66 | 67 | if (!options || (typeof options === 'undefined' ? 'undefined' : _typeof(options)) !== 'object') throw new Error('Invalid type: options should be passed in as an object'); 68 | options = Object.assign({ 69 | span: 0.75, 70 | band: 0, 71 | degree: 2, 72 | normalize: true, 73 | robust: false, 74 | iterations: 4 75 | }, options); 76 | 77 | if (typeof options.degree === 'string') { 78 | options.degree = ['constant', 'linear', 'quadratic'].indexOf(options.degree); 79 | } 80 | validateIsNumber(options.span, 'Invalid type: options.span should be a number'); 81 | if (options.span <= 0) throw new Error('options.span should be greater than 0'); 82 | 83 | validateIsNumber(options.band, 'Invalid type: options.band should be a number'); 84 | if (options.band < 0 || options.band > 0.99) throw new Error('options.band should be between 0 and 1'); 85 | 86 | validateIsInteger(options.degree, 'Invalid type: options.degree should be an integer'); 87 | if (options.degree < 0 || options.degree > 2) throw new Error('options.degree should be between 0 and 2'); 88 | 89 | if (typeof options.normalize !== 'boolean') throw new Error('Invalid type: options.normalize should be a boolean'); 90 | if (typeof options.robust !== 'boolean') throw new Error('Invalid type: options.robust should be a boolean'); 91 | 92 | validateIsInteger(options.iterations, 'Invalid type: options.iterations should be an integer'); 93 | if (options.iterations < 1) throw new Error('options.iterations should be at least 1'); 94 | if (!options.robust) options.iterations = 1; 95 | 96 | var d = x2 ? 2 : 1; 97 | var bandwidth = options.span > 1 ? Math.pow(options.span, 1 / d) : options.span; 98 | 99 | return { y: y, x: x, w: w, n: n, d: d, options: options, bandwidth: bandwidth }; 100 | } 101 | 102 | function validatePredict(data) { 103 | if (!data) data = { x: this.x[0], x2: this.x[1] }; 104 | if ((typeof data === 'undefined' ? 'undefined' : _typeof(data)) !== 'object') throw new Error('Invalid type: data should be supplied as an object'); 105 | 106 | var _data = data, 107 | x = _data.x, 108 | _data$x = _data.x2, 109 | x2 = _data$x === undefined ? null : _data$x; 110 | 111 | 112 | validateIsArray(x, 'Invalid type: x should be an array'); 113 | x.forEach(function (v) { 114 | return validateIsNumber(v, 'Invalid type: x should include only numbers'); 115 | }); 116 | 117 | var x_new = [x]; 118 | var n = x.length; 119 | 120 | if (this.d > 1) { 121 | validateIsArray(x2, 'Invalid type: x2 should be an array'); 122 | x2.forEach(function (v) { 123 | return validateIsNumber(v, 'Invalid type: x2 should include only numbers'); 124 | }); 125 | if (x2.length !== n) throw new Error('x and x2 have different length'); 126 | x_new.push(x2); 127 | } else if (x2) { 128 | throw new Error('extra variable x2'); 129 | } 130 | return { x_new: x_new, n: n }; 131 | } 132 | 133 | function validateGrid(cuts) { 134 | validateIsArray(cuts, 'Invalid type: cuts should be an array'); 135 | cuts.forEach(function (cut) { 136 | validateIsInteger(cut, 'Invalid type: cuts should include only integers'); 137 | if (cuts < 3) throw new Error('cuts should include only integers > 2'); 138 | }); 139 | if (cuts.length !== this.d) throw new Error('cuts.length should match dimension of predictors'); 140 | } -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _mathjs = require('mathjs'); 10 | 11 | var math = _interopRequireWildcard(_mathjs); 12 | 13 | var _lodash = require('lodash.sortby'); 14 | 15 | var _lodash2 = _interopRequireDefault(_lodash); 16 | 17 | var _gaussian = require('gaussian'); 18 | 19 | var _gaussian2 = _interopRequireDefault(_gaussian); 20 | 21 | var _inputsValidation = require('./inputsValidation'); 22 | 23 | var _helpers = require('./helpers'); 24 | 25 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 26 | 27 | function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } newObj.default = obj; return newObj; } } 28 | 29 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 30 | 31 | // import data from '../data/gas.json' 32 | 33 | var Loess = function () { 34 | function Loess(data) { 35 | var _this = this; 36 | 37 | var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 38 | 39 | _classCallCheck(this, Loess); 40 | 41 | Object.assign(this, (0, _inputsValidation.validateModel)(data, options)); 42 | 43 | if (this.options.normalize) this.normalization = this.x.map(_helpers.normalize); 44 | 45 | this.expandedX = (0, _helpers.polynomialExpansion)(this.x, this.options.degree); 46 | var normalized = this.normalization ? this.x.map(function (x, idx) { 47 | return _this.normalization[idx](x); 48 | }) : this.x; 49 | this.transposedX = (0, _helpers.transpose)(normalized); 50 | } 51 | 52 | _createClass(Loess, [{ 53 | key: 'predict', 54 | value: function predict(data) { 55 | var _this2 = this; 56 | 57 | var _validatePredict$bind = _inputsValidation.validatePredict.bind(this)(data), 58 | x_new = _validatePredict$bind.x_new, 59 | n = _validatePredict$bind.n; 60 | 61 | var expandedX = (0, _helpers.polynomialExpansion)(x_new, this.options.degree); 62 | var normalized = this.normalization ? x_new.map(function (x, idx) { 63 | return _this2.normalization[idx](x); 64 | }) : x_new; 65 | var distM = (0, _helpers.distMatrix)((0, _helpers.transpose)(normalized), this.transposedX); 66 | var weightM = (0, _helpers.weightMatrix)(distM, this.w, this.bandwidth); 67 | 68 | var fitted = void 0, 69 | residuals = void 0, 70 | weights = void 0, 71 | betas = void 0; 72 | function iterate(wt) { 73 | var _this3 = this; 74 | 75 | fitted = []; 76 | residuals = []; 77 | betas = []; 78 | weights = math.dotMultiply(wt, weightM); 79 | (0, _helpers.transpose)(expandedX).forEach(function (point, idx) { 80 | var fit = (0, _helpers.weightedLeastSquare)(_this3.expandedX, _this3.y, weights[idx]); 81 | if (fit.error) { 82 | var sumWeights = math.sum(weights[idx]); 83 | var mle = sumWeights === 0 ? 0 : math.multiply(_this3.y, weights[idx]) / sumWeights; 84 | fit.beta = math.zeros(_this3.expandedX.length).set([0], mle); 85 | fit.residual = math.subtract(_this3.y, mle); 86 | } 87 | fitted.push(math.squeeze(math.multiply(point, fit.beta))); 88 | residuals.push(fit.residual); 89 | betas.push(fit.beta.toArray()); 90 | var median = math.median(math.abs(fit.residual)); 91 | wt[idx] = fit.residual.map(function (r) { 92 | return (0, _helpers.weightFunc)(r, 6 * median, 2); 93 | }); 94 | }); 95 | } 96 | 97 | var robustWeights = Array(n).fill(math.ones(this.n)); 98 | for (var iter = 0; iter < this.options.iterations; iter++) { 99 | iterate.bind(this)(robustWeights); 100 | }var output = { fitted: fitted, betas: betas, weights: weights }; 101 | 102 | if (this.options.band) { 103 | var z = (0, _gaussian2.default)(0, 1).ppf(1 - (1 - this.options.band) / 2); 104 | var halfwidth = weights.map(function (weight, idx) { 105 | var V1 = math.sum(weight); 106 | var V2 = math.multiply(weight, weight); 107 | var intervalEstimate = Math.sqrt(math.multiply(math.square(residuals[idx]), weight) / (V1 - V2 / V1)); 108 | return intervalEstimate * z; 109 | }); 110 | Object.assign(output, { halfwidth: halfwidth }); 111 | } 112 | 113 | return output; 114 | } 115 | }, { 116 | key: 'grid', 117 | value: function grid(cuts) { 118 | var _this4 = this; 119 | 120 | _inputsValidation.validateGrid.bind(this)(cuts); 121 | 122 | var x_new = []; 123 | var x_cuts = []; 124 | this.x.forEach(function (x, idx) { 125 | var x_sorted = (0, _lodash2.default)(x); 126 | var x_min = x_sorted[0]; 127 | var x_max = x_sorted[_this4.n - 1]; 128 | var width = (x_max - x_min) / (cuts[idx] - 1); 129 | x_cuts.push([]); 130 | for (var i = 0; i < cuts[idx]; i++) { 131 | x_cuts[idx].push(x_min + i * width); 132 | }var repeats = 1; 133 | var copies = 1; 134 | for (var _i = idx - 1; _i >= 0; _i--) { 135 | repeats *= cuts[_i]; 136 | }for (var _i2 = idx + 1; _i2 < _this4.d; _i2++) { 137 | copies *= cuts[_i2]; 138 | }x_new.push([]); 139 | for (var _i3 = 0; _i3 < repeats; _i3++) { 140 | x_new[idx] = x_new[idx].concat(x_cuts[idx].reduce(function (acc, cut) { 141 | return acc.concat(Array(copies).fill(cut)); 142 | }, [])); 143 | } 144 | }); 145 | 146 | var data = { x: x_new[0], x_cut: x_cuts[0] }; 147 | if (this.d > 1) Object.assign(data, { x2: x_new[1], x_cut2: x_cuts[1] }); 148 | return data; 149 | } 150 | }]); 151 | 152 | return Loess; 153 | }(); 154 | 155 | // const w = data.NOx.map(() => Math.random() * 10) 156 | // const fit = new Loess({y: data.NOx, x: data.E, w}, {span: 0.8, band: 0.8, degree: 'quadratic'}) 157 | // console.log(JSON.stringify(fit.predict(fit.grid([30])))) 158 | 159 | 160 | exports.default = Loess; -------------------------------------------------------------------------------- /test/validation_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import {expect} from 'chai' 3 | import {validateModel, validatePredict, validateGrid} from '../src/inputsValidation' 4 | 5 | describe('validation for model constructor', function () { 6 | it('throw error if data obj not provided', function () { 7 | expect(validateModel).to.throw('no data passed in to constructor') 8 | expect(validateModel.bind(null, true)).to.throw('no data passed in to constructor') 9 | }) 10 | 11 | it('throw error if y, x or x2 is not array', function () { 12 | expect(validateModel.bind(null, {y: null}, {})).to.throw('Invalid type: y should be an array') 13 | expect(validateModel.bind(null, {y: [], x: null})).to.throw('Invalid type: x should be an array') 14 | expect(validateModel.bind(null, {y: [], x: [], x2: true})).to.throw('Invalid type: x2 should be an array') 15 | }) 16 | 17 | it('throw error if y, x or x2 contains non-number', function () { 18 | expect(validateModel.bind(null, {y: ['dummy'], x: ['dummy']})).to.throw('Invalid type: y should include only numbers') 19 | expect(validateModel.bind(null, {y: [1], x: ['dummy']})).to.throw('Invalid type: x should include only numbers') 20 | expect(validateModel.bind(null, {y: [1], x: [1], x2: ['dummy']})).to.throw('Invalid type: x2 should include only numbers') 21 | }) 22 | 23 | it('throw error if y, x and x2 have different lengths', function () { 24 | expect(validateModel.bind(null, {y: [1], x: [1, 2]})).to.throw('y and x have different length') 25 | expect(validateModel.bind(null, {y: [1], x: [1], x2: [1, 2]})).to.throw('y and x2 have different length') 26 | }) 27 | 28 | it('throw error if options not passed in as an object', function () { 29 | expect(validateModel.bind(null, {y: [1], x: [1]}, true)).to.throw('Invalid type: options should be passed in as an object') 30 | }) 31 | 32 | it('if options not provided, should be populated with defaults', function () { 33 | const result = validateModel({y: [1], x: [1]}, {}) 34 | expect(result.options).to.have.property('span', 0.75) 35 | expect(result.options).to.have.property('band', 0) 36 | expect(result.options).to.have.property('degree', 2) 37 | expect(result.options).to.have.property('normalize', true) 38 | expect(result.options).to.have.property('robust', false) 39 | }) 40 | 41 | it('options.degree translated from str to int', function () { 42 | expect(validateModel({y: [1], x: [1]}, {degree: 'constant'}).options.degree).to.equal(0) 43 | expect(validateModel({y: [1], x: [1]}, {degree: 'linear'}).options.degree).to.equal(1) 44 | expect(validateModel({y: [1], x: [1]}, {degree: 'quadratic'}).options.degree).to.equal(2) 45 | expect(validateModel.bind(null, {y: [1], x: [1]}, {degree: 'dummy'})).to.throw('options.degree should be between 0 and 2') 46 | }) 47 | 48 | it('throw error if options.span is not a number > 0', function () { 49 | expect(validateModel.bind(null, {y: [1], x: [1]}, {span: null})).to.throw('Invalid type: options.span should be a number') 50 | expect(validateModel.bind(null, {y: [1], x: [1]}, {span: 0})).to.throw('options.span should be greater than 0') 51 | }) 52 | 53 | it('throw error if options.band is not a number betweem 0 and 1', function () { 54 | expect(validateModel.bind(null, {y: [1], x: [1]}, {band: null})).to.throw('Invalid type: options.band should be a number') 55 | expect(validateModel.bind(null, {y: [1], x: [1]}, {band: -0.001})).to.throw('options.band should be between 0 and 1') 56 | expect(validateModel.bind(null, {y: [1], x: [1]}, {band: 1})).to.throw('options.band should be between 0 and 1') 57 | }) 58 | 59 | it('throw error if options.degree is not an integer between 0 and 2', function () { 60 | expect(validateModel.bind(null, {y: [1], x: [1]}, {degree: null})).to.throw('Invalid type: options.degree should be an integer') 61 | expect(validateModel.bind(null, {y: [1], x: [1]}, {degree: 1.5})).to.throw('Invalid type: options.degree should be an integer') 62 | expect(validateModel.bind(null, {y: [1], x: [1]}, {degree: -1})).to.throw('options.degree should be between 0 and 2') 63 | expect(validateModel.bind(null, {y: [1], x: [1]}, {degree: 3})).to.throw('options.degree should be between 0 and 2') 64 | }) 65 | 66 | it('throw error if options.normalize or options.robust is not boolean', function () { 67 | expect(validateModel.bind(null, {y: [1], x: [1]}, {normalize: null})).to.throw('Invalid type: options.normalize should be a boolean') 68 | expect(validateModel.bind(null, {y: [1], x: [1]}, {robust: null})).to.throw('Invalid type: options.robust should be a boolean') 69 | }) 70 | 71 | it('throw error if options.interation is not an integer > 0', function () { 72 | expect(validateModel.bind(null, {y: [1], x: [1]}, {robust: true, iterations: null})).to.throw('Invalid type: options.iterations should be an integer') 73 | expect(validateModel.bind(null, {y: [1], x: [1]}, {robust: true, iterations: 1.5})).to.throw('Invalid type: options.iterations should be an integer') 74 | expect(validateModel.bind(null, {y: [1], x: [1]}, {robust: true, iterations: 0})).to.throw('options.iterations should be at least 1') 75 | }) 76 | 77 | it('options.iterations defaults to 4 if options.robust = true and 1 otherwise', function () { 78 | expect(validateModel({y: [1], x: [1]}, {robust: true}).options.iterations).to.equal(4) 79 | expect(validateModel({y: [1], x: [1]}, {robust: false, iterations: 10}).options.iterations).to.equal(1) 80 | }) 81 | 82 | it('return dimension of data correctly', function () { 83 | expect(validateModel({y: [1], x: [1]}, {}).d).to.equal(1) 84 | expect(validateModel({y: [1], x: [1], x2: [1]}, {}).d).to.equal(2) 85 | }) 86 | 87 | it('return correct bandwidth given span', function () { 88 | const data = { 89 | y: [1, 2, 3, 4, 5], 90 | x: [1, 2, 3, 4, 5], 91 | x2: [1, 2, 3, 4, 5] 92 | } 93 | expect(validateModel(data, {span: 0.6}).bandwidth).to.equal(0.6) 94 | expect(validateModel(data, {span: 4}).bandwidth).to.equal(2) 95 | }) 96 | }) 97 | 98 | describe('validation for .predict() method', function () { 99 | it('returns this.x as new data if no arg supplied', function () { 100 | const input = { 101 | x: [ 102 | [1, 2, 3, 4, 5], 103 | [6, 7, 8, 9, 10] 104 | ], 105 | d: 2 106 | } 107 | const output = { 108 | x_new: [ 109 | [1, 2, 3, 4, 5], 110 | [6, 7, 8, 9, 10] 111 | ], 112 | n: 5 113 | } 114 | expect(validatePredict.bind(input)()).to.eql(output) 115 | }) 116 | 117 | it('throw error if data obj not provided', function () { 118 | expect(validatePredict.bind(null, true)).to.throw('Invalid type: data should be supplied as an object') 119 | }) 120 | 121 | it('throw error if x or x2 is not array', function () { 122 | expect(validatePredict.bind({d: 1}, {x: null})).to.throw('Invalid type: x should be an array') 123 | expect(validatePredict.bind({d: 2}, {x: [], x2: true})).to.throw('Invalid type: x2 should be an array') 124 | }) 125 | 126 | it('throw error if x or x2 contains non-number', function () { 127 | expect(validatePredict.bind({d: 1}, {x: ['dummy']})).to.throw('Invalid type: x should include only numbers') 128 | expect(validatePredict.bind({d: 2}, {x: [1], x2: ['dummy']})).to.throw('Invalid type: x2 should include only numbers') 129 | }) 130 | 131 | it('throw error if x and x2 have different lengths', function () { 132 | expect(validatePredict.bind({d: 2}, {x: [1], x2: [1, 2]})).to.throw('x and x2 have different length') 133 | }) 134 | 135 | it('throw error if extra variable provided', function () { 136 | expect(validatePredict.bind({d: 1}, {x: [1, 2], x2: [1, 2]})).to.throw('extra variable x2') 137 | }) 138 | }) 139 | 140 | describe('validation for .grid() method', function () { 141 | it('throw error if cuts arg is not provided', function () { 142 | expect(validateGrid).to.throw('Invalid type: cuts should be an array') 143 | }) 144 | 145 | it('throw error if cuts does not contain only integers > 2', function () { 146 | expect(validateGrid.bind(null, [true])).to.throw('Invalid type: cuts should include only integers') 147 | expect(validateGrid.bind(null, [1.1])).to.throw('Invalid type: cuts should include only integers') 148 | expect(validateGrid.bind(null, [2])).to.throw('cuts should include only integers > 2') 149 | }) 150 | 151 | it('throw error if cuts not matching dimension of model', function () { 152 | expect(validateGrid.bind({d: 2}, [5, 5, 5])).to.throw('cuts.length should match dimension of predictors') 153 | }) 154 | }) 155 | --------------------------------------------------------------------------------