├── .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 | 
63 |
64 | 
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 |
--------------------------------------------------------------------------------