Skip to content

Commit 179a027

Browse files
committed
feat: Support an array of percentiles
Add support for calcualting and array of percentiles in 1 pass, example: ```js const percentile = require("percentile"); const result = percentile( [70, 80, 90], // calculates 70p, 80p and 90p in one pass [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] ); console.log(result); // [7, 8, 9] ``` It adds a little bit of overhead because, now percentile needs to convert a percentile argument to an array and apply validation and calculation to this array of percentiles, but overall performance is still in a ball park of original implementation: ``` Small Array – 10 items [old] x 3,024,685 ops/sec ±0.65% (94 runs sampled) Small Array – 10 items [new] x 2,941,058 ops/sec ±1.04% (95 runs sampled) Fastest is Small Array – 10 items [old] Big array 10k values [old] x 5,482 ops/sec ±0.58% (92 runs sampled) Big array 10k values [new] x 5,381 ops/sec ±0.69% (91 runs sampled) Fastest is Big array 10k values [old] Big array 100k values [old] x 395 ops/sec ±1.00% (91 runs sampled) Big array 100k values [new] x 406 ops/sec ±0.93% (88 runs sampled) Fastest is Big array 100k values [new] ``` Fixes #101
1 parent d7f3023 commit 179a027

File tree

6 files changed

+232
-37
lines changed

6 files changed

+232
-37
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ const result = percentile(
3535
);
3636
console.log(result); // 8
3737

38+
// With array of percentiles
39+
const percentile = require("percentile");
40+
const result = percentile(
41+
[70, 80, 90], // calculates 70p, 80p and 90p in one pass
42+
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
43+
);
44+
console.log(result); // [7, 8, 9]
45+
3846
```
3947
## Notes
4048

lib/index.js

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Error message for case when percentile is less than 0
2+
* Error message for a case when percentile is less than 0.
33
*
44
* @param {Number} p
55
*
@@ -10,7 +10,7 @@ function lessThanZeroError(p) {
1010
}
1111

1212
/**
13-
* Error message for case when percentile is greater than 100
13+
* Error message for a case when percentile is greater than 100.
1414
*
1515
* @param {Number} p
1616
*
@@ -21,7 +21,7 @@ function greaterThanHundredError(p) {
2121
}
2222

2323
/**
24-
* Error message for case when percentile is not a number (NaN)
24+
* Error message for a case when percentile is not a number (NaN).
2525
*
2626
* @param {Number} p
2727
*
@@ -32,27 +32,54 @@ function nanError(p) {
3232
}
3333

3434
/**
35-
* Calculate percentile for given array of values.
35+
* Checks that a list of percentiles are all numbers and they lie in range 0..100.
36+
*
37+
* @param {Array<Number>} ps - percentiles to calculate
38+
*
39+
* @return {Array} List of errors
40+
*/
41+
function validateInput(ps) {
42+
return ps.reduce(function (errors, p) {
43+
if (isNaN(Number(p))) {
44+
errors.push(nanError(p));
45+
} else if (p < 0) {
46+
errors.push(lessThanZeroError(p));
47+
} else if (p > 100) {
48+
errors.push(greaterThanHundredError(p));
49+
}
50+
return errors;
51+
}, []);
52+
}
53+
54+
/**
55+
* Get percentile value from an array.
3656
*
3757
* @param {Number} p - percentile
38-
* @param {Array} list - array of values
39-
* @param {Function} [fn] - optional function to extract value from array
58+
* @param {Array} list - list of values
4059
*
4160
* @return {*}
4261
*/
43-
function percentile(p, list, fn) {
44-
if (isNaN(Number(p))) {
45-
throw new Error(nanError(p));
46-
}
47-
48-
p = Number(p);
62+
function getPsValue(p, list) {
63+
if (p === 0) return list[0];
64+
var kIndex = Math.ceil(list.length * (p / 100)) - 1;
65+
return list[kIndex];
66+
}
4967

50-
if (p < 0) {
51-
throw new Error(lessThanZeroError(p));
52-
}
68+
/**
69+
* Calculate percentile for given array of values.
70+
*
71+
* @param {Number|Array<Number>} pOrPs - percentile or a list of percentiles
72+
* @param {Array} list - array of values
73+
* @param {Function} [fn] - optional function to extract a value from an array item
74+
*
75+
* @return {*}
76+
*/
77+
function percentile(pOrPs, list, fn) {
78+
var ps = Array.isArray(pOrPs) ? pOrPs : [pOrPs];
79+
var validationErrors = validateInput(ps);
5380

54-
if (p > 100) {
55-
throw new Error(greaterThanHundredError(p));
81+
if (validationErrors.length) {
82+
throw new Error(validationErrors.join(' '));
5683
}
5784

5885
list = list.slice().sort(function (a, b) {
@@ -70,11 +97,13 @@ function percentile(p, list, fn) {
7097
return 0;
7198
});
7299

73-
if (p === 0) return list[0];
74-
75-
var kIndex = Math.ceil(list.length * (p / 100)) - 1;
100+
if (ps.length === 1) {
101+
return getPsValue(ps[0], list);
102+
}
76103

77-
return list[kIndex];
104+
return ps.map(function (p) {
105+
return getPsValue(p, list);
106+
});
78107
}
79108

80109
module.exports = percentile;

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"coverage": "nyc --reporter=lcov --reporter=text --reporter=html npm test",
2222
"coveralls": "npm run coverage && cat ./coverage/lcov.info | coveralls && rm -rf ./coverage",
2323
"lint": "eslint lib test",
24+
"bench": "node ./scripts/bench.js",
2425
"test": "ava",
2526
"ci:github-release": "conventional-github-releaser -p angular",
2627
"pmm:prepare": "npm run lint && npm test",
@@ -39,6 +40,7 @@
3940
"dependencies": {},
4041
"devDependencies": {
4142
"ava": "^0.25.0",
43+
"benchmark": "^2.1.4",
4244
"conventional-github-releaser": "^3.1.3",
4345
"coveralls": "^3.0.2",
4446
"cz-conventional-changelog": "^2.1.0",

scripts/bench.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
let Benchmark = require('benchmark');
2+
let percentile = require('../lib');
3+
let oldpercentile = require('./percentile-baseline');
4+
5+
function generateArray(length, fn) {
6+
return Array.apply(null, Array(length)).map(fn);
7+
}
8+
9+
function generateArraySimple(length) {
10+
return generateArray(length, (v, i) => i + 1);
11+
}
12+
13+
let suite1 = new Benchmark.Suite();
14+
let suite2 = new Benchmark.Suite();
15+
let suite3 = new Benchmark.Suite();
16+
17+
let arr10 = generateArraySimple(10);
18+
let arr10k = generateArraySimple(10000);
19+
let arr100k = generateArraySimple(100000);
20+
21+
22+
suite1.add('Small Array – 10 items [old]', function () {
23+
oldpercentile(Math.floor(Math.random() * (100 - 1)), arr10);
24+
})
25+
.add('Small Array – 10 items [new]', function () {
26+
percentile(Math.floor(Math.random() * (100 - 1)), arr10);
27+
})
28+
.on('cycle', function (event) {
29+
console.log(String(event.target)); // eslint-disable-line
30+
})
31+
.on('complete', function () {
32+
console.log('Fastest is ' + this.filter('fastest').map('name')); // eslint-disable-line
33+
})
34+
.run();
35+
36+
suite2.add('Big array 10k values [old]', function () {
37+
oldpercentile(Math.floor(Math.random() * (100 - 1)), arr10k);
38+
})
39+
.add('Big array 10k values [new]', function () {
40+
percentile(Math.floor(Math.random() * (100 - 1)), arr10k);
41+
})
42+
.on('cycle', function (event) {
43+
console.log(String(event.target)); // eslint-disable-line
44+
})
45+
.on('complete', function () {
46+
console.log('Fastest is ' + this.filter('fastest').map('name')); // eslint-disable-line
47+
})
48+
.run();
49+
50+
suite3.add('Big array 100k values [old]', function () {
51+
oldpercentile(Math.floor(Math.random() * (100 - 1)), arr100k);
52+
})
53+
.add('Big array 100k values [new]', function () {
54+
percentile(Math.floor(Math.random() * (100 - 1)), arr100k);
55+
})
56+
.on('cycle', function (event) {
57+
console.log(String(event.target)); // eslint-disable-line
58+
})
59+
.on('complete', function () {
60+
console.log('Fastest is ' + this.filter('fastest').map('name')); // eslint-disable-line
61+
})
62+
.run();

scripts/percentile-baseline.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Error message for case when percentile is less than 0
3+
*
4+
* @param {Number} p
5+
*
6+
* @return {String}
7+
*/
8+
function lessThanZeroError(p) {
9+
return 'Expect percentile to be >= 0 but given "' + p + '" and its type is "' + (typeof p) + '".';
10+
}
11+
12+
/**
13+
* Error message for case when percentile is greater than 100
14+
*
15+
* @param {Number} p
16+
*
17+
* @return {String}
18+
*/
19+
function greaterThanHundredError(p) {
20+
return 'Expect percentile to be <= 100 but given "' + p + '" and its type is "' + (typeof p) + '".';
21+
}
22+
23+
/**
24+
* Error message for case when percentile is not a number (NaN)
25+
*
26+
* @param {Number} p
27+
*
28+
* @return {String}
29+
*/
30+
function nanError(p) {
31+
return 'Expect percentile to be a number but given "' + p + '" and its type is "' + (typeof p) + '".';
32+
}
33+
34+
/**
35+
* Calculate percentile for given array of values.
36+
*
37+
* @param {Number} p - percentile
38+
* @param {Array} list - array of values
39+
* @param {Function} [fn] - optional function to extract value from array
40+
*
41+
* @return {*}
42+
*/
43+
function percentile(p, list, fn) {
44+
if (isNaN(Number(p))) {
45+
throw new Error(nanError(p));
46+
}
47+
48+
p = Number(p);
49+
50+
if (p < 0) {
51+
throw new Error(lessThanZeroError(p));
52+
}
53+
54+
if (p > 100) {
55+
throw new Error(greaterThanHundredError(p));
56+
}
57+
58+
list = list.slice().sort(function (a, b) {
59+
if (fn) {
60+
a = fn(a);
61+
b = fn(b);
62+
}
63+
64+
a = Number.isNaN(a) ? Number.NEGATIVE_INFINITY : a;
65+
b = Number.isNaN(b) ? Number.NEGATIVE_INFINITY : b;
66+
67+
if (a > b) return 1;
68+
if (a < b) return -1;
69+
70+
return 0;
71+
});
72+
73+
if (p === 0) return list[0];
74+
75+
var kIndex = Math.ceil(list.length * (p / 100)) - 1;
76+
77+
return list[kIndex];
78+
}
79+
80+
module.exports = percentile;

test/index.js

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ const stubsSimple = [
2929
{ percentile: 75, list: shuffleArray([].concat(generateArraySimple(100), generateArraySimple(30))), result: 68 }
3030
];
3131

32+
test('percentile simple values', t => {
33+
stubsSimple.forEach(stub => {
34+
t.is(
35+
percentile(stub.percentile, stub.list),
36+
stub.result
37+
);
38+
});
39+
});
40+
3241
const stubsObject = [
3342
{ percentile: 0, list: shuffleArray(generateArrayOfObject(100)), result: 1 },
3443
{ percentile: 25, list: shuffleArray(generateArrayOfObject(100)), result: 25 },
@@ -39,15 +48,6 @@ const stubsObject = [
3948
{ percentile: 75, list: shuffleArray([].concat(generateArrayOfObject(100), generateArrayOfObject(30))), result: 68 }
4049
];
4150

42-
test('percentile simple values', t => {
43-
stubsSimple.forEach(stub => {
44-
t.is(
45-
percentile(stub.percentile, stub.list),
46-
stub.result
47-
);
48-
});
49-
});
50-
5151
test('percentile values in object', t => {
5252
stubsObject.forEach(stub => {
5353
t.is(
@@ -57,20 +57,34 @@ test('percentile values in object', t => {
5757
});
5858
});
5959

60+
test('array of percentiles', t => {
61+
t.deepEqual(
62+
percentile([0, 25, 50, 75, 100], shuffleArray(generateArraySimple(100))),
63+
[1, 25, 50, 75, 100]
64+
);
65+
});
66+
67+
test('array of percentiles when values are objects', t => {
68+
t.deepEqual(
69+
percentile([0, 25, 50, 75, 100], shuffleArray(generateArrayOfObject(100)), item => item.val).map(p => p.val),
70+
[1, 25, 50, 75, 100]
71+
);
72+
});
73+
6074
test('throw an error if NaN', t => {
6175
t.throws(() => {
62-
percentile(undefined) // eslint-disable-line
76+
percentile(undefined, []) // eslint-disable-line
6377
}, Error);
6478
});
6579

6680
test('throw an error if less than 0', t => {
67-
t.throws(() => {
68-
percentile(-1) // eslint-disable-line
69-
}, Error);
81+
t.throws(() => percentile(-1, []), Error);
7082
});
7183

7284
test('throw an error if grater than 100', t => {
73-
t.throws(() => {
74-
percentile(101) // eslint-disable-line
75-
}, Error);
85+
t.throws(() => percentile(101, []), Error);
86+
});
87+
88+
test('throws a list of errors', t => {
89+
t.throws(() => percentile([101, -1, 'a'], []), Error);
7690
});

0 commit comments

Comments
 (0)