Clip continuous values based on percentile cutoffs (#672)

* Add numeric inputs for percentiles

* Define initial values for percentile cutoffs in world reducer

* add percentil to crossfilter dimensions

* worldEqUniverse now handles cloned worlds

* add Dataframe.mapColumns

* Wire up handlers for percentile inputs

* World reducer and stateManager know about continuousPercentileMin/Max

* Create world as universe clone (not pointer) to avoid clobbering vals

* Define basic actions for setting continuousPercentileMin/Max

* Under the hood, deal with percentiles between 0 and 1

* Move percentile inputs to visualization settings menu

* Fix padding for undo/redo buttons

* Trigger world rebuild from percentile actions

* BROKEN - pseudocode for clamping dataframe by percentiles upon world rebuild

* fix error handling on clip quantiles; start world clipping implementation

* more unclipped reorg

* rename crossfilter.percentile to quantile

* simplify schema access

* update continuous legend when scale changes

* update color cache when clip changes

* clip obs annotations and var data when clip quantile changes

* use own fromEntries

* fix tests

* stable non-finite float sort/search

* clarify comments

* fix syntax typo

* use new stand-alone clip

* clip expresssion data

* add select tests for non-finite scalars

* basic styles

* clip UI now requires explicit commit

* reset enable/disable accounts for clip percentiles

* better error messages

* fix bug in undo interaction with programatic min brush selection

* small refactoring

* support clipping of int data

* do not perform unnecessary summarizations

* improve caching of dataframe compiled columns

* add percentile precompute to Dataframe.summarize

* use Dataframe.summarize for clip percentiles

* remove obsolete quantile code from corssfilter

* histogram scale and label Y axis, add unclipped X range labels

* layout tweaks

* scatterplot now updates when clip changes

* improve comments

* remove debugging comment

* rework clip number entry validation for usability

* ui tweaks to histogram colors and layout

* enable undo/redo for clip user action

* refine UI on clip value entry

* api cleanup

* update confusing comment

* clarify purpose of isValidDigitKeyEvent

* fix misleading comment

* apply appropriate button-group classes; do not mix span and div

* variable name and comment changes suggested in PR review

* rename sort to sortArray; remove unused and dead code path

* naming changes suggested in PR review

* code review improvements for clarity

* more small changes from PR review

* lint fixes for PR review

* fix spelling error

* clarify that function performs in-place modification of world

* add comment to clarify intent of range operation

* fix bad indents in comments

* clean up __columnsAccessor comments and code

* improve comments around clipPredicate

* field name consistency

* improve comment on quantiles params
This commit is contained in:
Sidney Bell
2019-04-30 16:20:10 -07:00
committed by Bruce Martin
parent 9f10d8095a
commit a08e19bbd0
34 changed files with 1842 additions and 551 deletions
@@ -532,6 +532,42 @@ describe("dataframe factories", () => {
expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
});
});
describe("mapColumns", () => {
test("identity", () => {
const dfA = Dataframe.Dataframe.create(
[3, 3],
[
new Array(3).fill(0),
new Int16Array(3).fill(99),
new Float64Array(3).fill(1.1)
]
);
const dfB = dfA.mapColumns((col, idx) => {
expect(dfA.icol(idx).asArray()).toBe(col);
return col;
});
expect(dfA).not.toBe(dfB);
expect(dfA.dims).toEqual(dfB.dims);
for (let c = 0; c < dfA.dims[1]; c += 1) {
expect(dfA.icol(c).asArray()).toBe(dfB.icol(c).asArray());
}
});
test("transform", () => {
const dfA = Dataframe.Dataframe.create(
[3, 3],
[new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)]
);
const dfB = dfA.mapColumns(() => {
return new Array(3).fill(1);
});
expect(dfA).not.toBe(dfB);
expect(dfB.iat(0, 0)).toEqual(1);
expect(dfB.iat(0, 1)).toEqual(1);
expect(dfB.iat(0, 2)).toEqual(1);
});
});
});
describe("dataframe col", () => {
+29
View File
@@ -0,0 +1,29 @@
import quantile from "../../src/util/quantile";
describe("quantile", () => {
test("single q", () => {
const arr = new Float32Array([9, 3, 5, 6, 0]);
expect(quantile([1.0], arr)).toMatchObject([9]);
expect(quantile([0.9], arr)).toMatchObject([9]);
expect(quantile([0.8], arr)).toMatchObject([9]);
expect(quantile([0.7], arr)).toMatchObject([6]);
expect(quantile([0.6], arr)).toMatchObject([6]);
expect(quantile([0.5], arr)).toMatchObject([5]);
expect(quantile([0.4], arr)).toMatchObject([5]);
expect(quantile([0.3], arr)).toMatchObject([3]);
expect(quantile([0.2], arr)).toMatchObject([3]);
expect(quantile([0.1], arr)).toMatchObject([0]);
expect(quantile([0], arr)).toMatchObject([0]);
});
test("multi q", () => {
const arr = new Float32Array([9, 3, 5, 6, 0]);
expect(quantile([0, 0.25, 0.5, 0.75, 1.0], arr)).toMatchObject([
0,
3,
5,
6,
9
]);
});
});
@@ -58,10 +58,15 @@ describe("createWorldFromEntireUniverse", () => {
nObs: universe.nObs,
nVar: universe.nVar,
schema: universe.schema,
obsAnnotations: universe.obsAnnotations,
varAnnotations: universe.varAnnotations,
obsLayout: universe.obsLayout,
varData: expect.any(Dataframe.Dataframe)
obsAnnotations: expect.any(Dataframe.Dataframe),
varAnnotations: expect.any(Dataframe.Dataframe),
obsLayout: expect.any(Dataframe.Dataframe),
varData: expect.any(Dataframe.Dataframe),
clipQuantiles: { min: 0, max: 1 },
unclipped: {
obsAnnotations: expect.any(Dataframe.Dataframe),
varData: expect.any(Dataframe.Dataframe)
}
})
);
});
@@ -76,7 +81,7 @@ describe("createWorldFromCurrentSelection", () => {
} = defaultBigBang();
/* mock a selection */
let crossfilter = originalCrossfilter
const crossfilter = originalCrossfilter
.select(obsAnnoDimensionName("field1"), { mode: "range", lo: 0, hi: 5 })
.select(obsAnnoDimensionName("field3"), {
mode: "exact",
@@ -84,7 +89,7 @@ describe("createWorldFromCurrentSelection", () => {
});
/* create the world from the selection */
const world = World.createWorldFromCurrentSelection(
const world = World.createWorldBySelection(
universe,
originalWorld,
crossfilter
@@ -112,10 +117,15 @@ describe("createWorldFromCurrentSelection", () => {
nObs: matchingIndices.length,
nVar: universe.nVar,
schema: universe.schema,
clipQuantiles: { min: 0, max: 1 },
obsAnnotations: expect.any(Dataframe.Dataframe),
varAnnotations: universe.varAnnotations,
varAnnotations: expect.any(Dataframe.Dataframe),
obsLayout: expect.any(Dataframe.Dataframe),
varData: expect.any(Dataframe.Dataframe)
varData: expect.any(Dataframe.Dataframe),
unclipped: {
obsAnnotations: expect.any(Dataframe.Dataframe),
varData: expect.any(Dataframe.Dataframe)
}
})
);
@@ -11,7 +11,8 @@ const someData = [
tip: 100,
type: "tab",
productIDs: ["001"],
coords: [0, 0]
coords: [0, 0],
nonFinite: 0.0
},
{
date: "2011-11-14T16:20:19Z",
@@ -20,7 +21,8 @@ const someData = [
tip: 100,
type: "tab",
productIDs: ["001", "005"],
coords: [0.4, 0.4]
coords: [0.4, 0.4],
nonFinite: Number.NaN
},
{
date: "2011-11-14T16:28:54Z",
@@ -29,7 +31,8 @@ const someData = [
tip: 200,
type: "visa",
productIDs: ["004", "005"],
coords: [0.3, 0.1]
coords: [0.3, 0.1],
nonFinite: Number.POSITIVE_INFINITY
},
{
date: "2011-11-14T16:30:43Z",
@@ -38,7 +41,8 @@ const someData = [
tip: 0,
type: "tab",
productIDs: ["001", "002"],
coords: [0.392, 0.1]
coords: [0.392, 0.1],
nonFinite: Number.NEGATIVE_INFINITY
},
{
date: "2011-11-14T16:48:46Z",
@@ -47,7 +51,8 @@ const someData = [
tip: 0,
type: "tab",
productIDs: ["005"],
coords: [0.7, 0.0482]
coords: [0.7, 0.0482],
nonFinite: 1.0
},
{
date: "2011-11-14T16:53:41Z",
@@ -56,7 +61,8 @@ const someData = [
tip: 0,
type: "tab",
productIDs: ["001", "004", "005"],
coords: [0.9999, 1.0]
coords: [0.9999, 1.0],
nonFinite: Number.NaN
},
{
date: "2011-11-14T16:54:06Z",
@@ -65,7 +71,8 @@ const someData = [
tip: 0,
type: "cash",
productIDs: ["001", "002", "003", "004", "005"],
coords: [0.384, 0.6938]
coords: [0.384, 0.6938],
nonFinite: 99.0
},
{
date: "2011-11-14T16:58:03Z",
@@ -74,7 +81,8 @@ const someData = [
tip: 0,
type: "tab",
productIDs: ["001"],
coords: [0.4822, 0.482]
coords: [0.4822, 0.482],
nonFinite: Number.NaN
},
{
date: "2011-11-14T17:07:21Z",
@@ -83,7 +91,8 @@ const someData = [
tip: 0,
type: "tab",
productIDs: ["004", "005"],
coords: [0.2234, 0]
coords: [0.2234, 0],
nonFinite: Number.NaN
},
{
date: "2011-11-14T17:22:59Z",
@@ -92,7 +101,8 @@ const someData = [
tip: 0,
type: "tab",
productIDs: ["001", "002", "004", "005"],
coords: [0.382, 0.38485]
coords: [0.382, 0.38485],
nonFinite: -1
},
{
date: "2011-11-14T17:25:45Z",
@@ -101,7 +111,8 @@ const someData = [
tip: 0,
type: "cash",
productIDs: ["002"],
coords: [0.998, 0.8472]
coords: [0.998, 0.8472],
nonFinite: 0.0
},
{
date: "2011-11-14T17:29:52Z",
@@ -110,7 +121,8 @@ const someData = [
tip: 100,
type: "visa",
productIDs: ["004"],
coords: [0.8273, 0.3384]
coords: [0.8273, 0.3384],
nonFinite: 0.0
}
];
@@ -327,4 +339,96 @@ describe("ImmutableTypedCrossfilter", () => {
).toEqual(_.filter(someData, d => polygonContains(polygon, d.coords)));
});
});
describe("non-finite scalars", () => {
let p;
beforeEach(() => {
p = payments
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
.addDimension(
"nonFinite",
"scalar",
(i, d) => d[i].nonFinite,
Float32Array
)
.select("quantity", { mode: "all" });
});
test("all or none", () => {
expect(p.select("nonFinite", { mode: "all" }).countSelected()).toEqual(
someData.length
);
expect(p.select("nonFinite", { mode: "none" }).countSelected()).toEqual(
0
);
});
test("exact", () => {
expect(
p.select("nonFinite", { mode: "exact", values: [0] }).countSelected()
).toEqual(3);
expect(
p.select("nonFinite", { mode: "exact", values: [1] }).countSelected()
).toEqual(1);
expect(
p
.select("nonFinite", {
mode: "exact",
values: [Number.POSITIVE_INFINITY]
})
.countSelected()
).toEqual(1);
expect(
p
.select("nonFinite", {
mode: "exact",
values: [Number.NEGATIVE_INFINITY]
})
.countSelected()
).toEqual(1);
expect(
p
.select("nonFinite", { mode: "exact", values: [Number.NaN] })
.countSelected()
).toEqual(4);
expect(
p
.select("nonFinite", {
mode: "exact",
values: [Number.POSITIVE_INFINITY, 0, 1, 99]
})
.countSelected()
).toEqual(6);
});
test("range", () => {
expect(
p
.select("nonFinite", {
mode: "range",
lo: 0,
hi: Number.POSITIVE_INFINITY
})
.countSelected()
).toEqual(5);
expect(
p
.select("nonFinite", {
mode: "range",
lo: 0,
hi: Number.NaN
})
.countSelected()
).toEqual(6);
expect(
p
.select("nonFinite", {
mode: "range",
lo: Number.NEGATIVE_INFINITY,
hi: Number.POSITIVE_INFINITY
})
.countSelected()
).toEqual(7);
});
});
});
@@ -1,4 +1,22 @@
import { sort, sortIndex } from "../../../src/util/typedCrossfilter/sort";
import {
sortArray,
sortIndex,
lowerBound,
upperBound,
lowerBoundIndirect,
upperBoundIndirect
} from "../../../src/util/typedCrossfilter/sort";
/*
Sort tests should keep in mind that there are separate code
paths for:
- small vs. large arrays (insertionsort only)
- float-only typed arrays vs. other array types (non-finite handling)
- indexed vs. direct sort
*/
const pInf = Number.POSITIVE_INFINITY;
const nInf = Number.NEGATIVE_INFINITY;
function fillRange(arr, start = 0) {
const larr = arr;
@@ -15,42 +33,223 @@ function fillRand(arr) {
return arr;
}
describe("sort", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
expect(sort(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
Type.from([0, 1, 2, 3, 4, 5, 6])
);
expect(sort(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
Type.from([1, 2, 3, 4, 5, 6])
);
describe("sortArray", () => {
describe("JS vals", () => {
[
[true, false],
["a", "b", "0", "1"],
[0, "a", true, null, undefined, 3.1415],
fillRand(new Array(1000)),
["a", NaN, null, pInf]
].map((val, idx) =>
test(`JS vals ${idx}`, () => {
expect(sortArray(val)).toMatchObject(val.sort());
})
);
});
const source = fillRand(new Type(1000));
expect(sort(Type.from(source))).toMatchObject(Type.from(source).sort());
})
);
describe("finite numbers", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
Type.from([0, 1, 2, 3, 4, 5, 6])
);
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
Type.from([1, 2, 3, 4, 5, 6])
);
const source = fillRand(new Type(1000));
expect(sortArray(Type.from(source))).toMatchObject(
Type.from(source).sort()
);
})
);
});
describe("non-finite numbers", () => {
test("inifinity", () => {
expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject(
new Float32Array([nInf, 0, 1, 2, pInf])
);
expect(
sortArray(new Float32Array([pInf, nInf, pInf, nInf]))
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf]));
expect(
sortArray(new Float32Array([pInf, nInf, pInf, nInf, pInf]))
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf, pInf]));
expect(
sortArray(
new Float32Array(100).fill(Infinity, 0, 50).fill(-Infinity, 50, 100)
)
).toMatchObject(
new Float32Array(100).fill(-Infinity, 0, 50).fill(Infinity, 50, 100)
);
});
test("NaN", () => {
expect(sortArray(new Float64Array([NaN, 2, 1, 0]))).toMatchObject(
new Float64Array([0, 1, 2, NaN])
);
expect(sortArray(new Float32Array([NaN, 2, 1, 0]))).toMatchObject(
new Float32Array([0, 1, 2, NaN])
);
expect(sortArray(new Float32Array([NaN, 2, NaN, 1, 0]))).toMatchObject(
new Float32Array([0, 1, 2, NaN, NaN])
);
expect(sortArray(new Float32Array([NaN, 2, 1, NaN, 0]))).toMatchObject(
new Float32Array([0, 1, 2, NaN, NaN])
);
expect(
sortArray(fillRange(new Float32Array(100)).fill(NaN, 0, 10))
).toMatchObject(fillRange(new Float32Array(100), 10).fill(NaN, 90, 100));
});
test("mixed numbers", () => {
expect(
sortArray(new Float32Array([NaN, pInf, nInf, NaN, NaN]))
).toMatchObject(new Float32Array([nInf, pInf, NaN, NaN, NaN]));
expect(
sortArray(new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]))
).toMatchObject(new Float32Array([nInf, 1, 2, pInf, NaN, NaN, NaN]));
expect(
sortArray(new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]))
).toMatchObject(new Float32Array([nInf, 0, 1, 2, pInf, NaN, NaN]));
expect(
sortArray(
fillRange(new Float32Array(100))
.fill(NaN, 0, 10)
.fill(Infinity, 10, 20)
)
).toMatchObject(
fillRange(new Float32Array(100), 20)
.fill(Infinity, 80, 90)
.fill(NaN, 90, 100)
);
});
});
});
describe("sortIndex", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
describe("finite numbers", () => {
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
test(Type.name, () => {
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
const index1 = fillRange(new Uint32Array(source1.length));
expect(sortIndex(index1, source1)).toMatchObject(
index1.sort((a, b) => source1[a] - source1[b])
);
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
const index2 = fillRange(new Uint32Array(source2.length));
expect(sortIndex(index2, source2)).toMatchObject(
index2.sort((a, b) => source1[a] - source1[b])
);
const source3 = fillRand(new Type(1000));
const index3 = fillRange(new Uint32Array(source3.length));
expect(sortIndex(index3, source3)).toMatchObject(
index3.sort((a, b) => source1[a] - source1[b])
);
})
);
});
describe("non-finite numbers", () => {
test("mixed numbers", () => {
const source1 = new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]);
const index1 = fillRange(new Uint32Array(source1.length));
expect(sortIndex(index1, source1)).toMatchObject(
index1.sort((a, b) => source1[a] - source1[b])
new Uint32Array([2, 4, 6, 1, 0, 3, 5])
);
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
const source2 = new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]);
const index2 = fillRange(new Uint32Array(source2.length));
expect(sortIndex(index2, source2)).toMatchObject(
index2.sort((a, b) => source1[a] - source1[b])
new Uint32Array([2, 3, 4, 6, 1, 0, 5])
);
});
});
});
const source3 = fillRand(new Type(1000));
const index3 = fillRange(new Uint32Array(source3.length));
expect(sortIndex(index3, source3)).toMatchObject(
index3.sort((a, b) => source1[a] - source1[b])
);
})
);
describe("lowerBound", () => {
test("non-float path", () => {
expect(lowerBound([], 0, 0, 0)).toEqual(0);
expect(lowerBound([0, 1, 2, 3], -1, 0, 4)).toEqual(0);
expect(lowerBound([0, 1, 2, 3], 0, 0, 4)).toEqual(0);
expect(lowerBound([0, 1, 2, 3], 1, 0, 4)).toEqual(1);
expect(lowerBound([0, 1, 2, 3], 3, 0, 4)).toEqual(3);
expect(lowerBound([0, 1, 2, 3], 4, 0, 4)).toEqual(4);
expect(lowerBound([0, 1, 2, 3, 4], -1, 0, 5)).toEqual(0);
expect(lowerBound([0, 1, 2, 3, 4], 0, 0, 5)).toEqual(0);
expect(lowerBound([0, 1, 2, 3, 4], 2, 0, 5)).toEqual(2);
expect(lowerBound([0, 1, 2, 3, 4], 4, 0, 5)).toEqual(4);
expect(lowerBound([0, 1, 2, 3, 4], 5, 0, 5)).toEqual(5);
expect(lowerBound([0, 2, 4, 6, 8], 5, 0, 5)).toEqual(3);
expect(lowerBound([0, 2, 2, 2, 8], 5, 0, 5)).toEqual(4);
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 3, 2, 4)).toEqual(3);
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 99, 2, 4)).toEqual(4);
});
test("float path, finites", () => {
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
expect(lowerBound(new Float32Array([]), 0, 0, 0)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), -1, 0, 4)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 0, 0, 4)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 3, 0, 4)).toEqual(3);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 4)).toEqual(4);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), -1, 0, 5)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 0, 0, 5)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 2, 0, 5)).toEqual(2);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 4, 0, 5)).toEqual(4);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 5, 0, 5)).toEqual(5);
expect(lowerBound(new Float32Array([0, 2, 4, 6, 8]), 5, 0, 5)).toEqual(3);
expect(lowerBound(new Float32Array([0, 2, 2, 2, 8]), 5, 0, 5)).toEqual(4);
expect(
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 3, 2, 4)
).toEqual(3);
expect(
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 99, 2, 4)
).toEqual(4);
});
test("float path, non-finite", () => {
expect(
lowerBound(
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
-Infinity,
0,
5
)
).toEqual(0);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 0, 0, 5)
).toEqual(1);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 1, 0, 5)
).toEqual(2);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 2, 0, 5)
).toEqual(3);
expect(
lowerBound(
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
Infinity,
0,
5
)
).toEqual(3);
expect(
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), NaN, 0, 5)
).toEqual(4);
});
});
@@ -1,10 +1,7 @@
import {
fillRange,
sliceByIndex,
makeSortIndex,
lowerBound,
lowerBoundIndirect,
upperBoundIndirect
makeSortIndex
} from "../../../src/util/typedCrossfilter/util";
describe("fillRange", () => {