diff --git a/lang/src/js/trove/adaptive.js b/lang/src/js/trove/adaptive.js new file mode 100644 index 000000000..9d2274421 --- /dev/null +++ b/lang/src/js/trove/adaptive.js @@ -0,0 +1,134 @@ +({ + requires: [], + nativeRequires: [], + provides: { + values: {}, + types: {} + }, + theModule: function(RUNTIME, NAMESPACE, uri) { + /** + * Euclidean distance between two points + * @param {[number, number]} xs - a pair of x-values + * @param {[number, number]} ys - a corresponding pair of y-values + * @returns {number} - the loss for the interval + */ + function defaultLoss(xs, ys) { + const dx = xs[1] - xs[0]; + const dy = ys[1] - ys[0]; + return Math.hypot(dx, dy); + } + + /** + * Distance between two x-values + * @param {[number, number]} xs - a pair of x-values + * @param {[number, number]} ys - unused (only passed in for consistency with other loss functions) + * @returns {number} - the loss for the interval + */ + function uniformLoss(xs, ys) { + return xs[1] - xs[0]; + } + + /** + * Penalizes y-values close to 0 + * Implementation of abs_min_log_loss from adaptive (min not required for scalars) + * @param {[number, number]} xs - a pair of x-values + * @param {[number, number]} ys - a corresponding pair of y-values + * @returns {number} - the loss for the interval + */ + function absLogLoss(xs, ys) { + // bound the transformed y-value in case y = 0 + const lowerBound = -1e12; + const ysLog = ys.map(y => Math.max(lowerBound, Math.log(Math.abs(y)))); + return defaultLoss(xs, ysLog); + } + + /** + * Flow: + * 1. Initialize data + * 2. Compute losses + * 3. Identify interval with max loss + * 4. Split interval in half + * 5. Repeat steps 2-4 until stopping condition + * @param {Function} func - function to plot + * @param {number} xMinValue - min x-value + * @param {number} xMaxValue - max x-value + * @param {Function} lossFunction - loss function + * @param {number} numSamples - max number of data points to sample + */ + function AdaptiveSampler(func, xMinValue, xMaxValue, lossFunction, numSamples) { + this.lossManager = []; + this.data = new Map(); + this.func = func; + this.xMinValue = xMinValue; + this.xMaxValue = xMaxValue; + this.lossFunction = lossFunction; + this.numSamples = numSamples; + this.pending = []; + + // initialize data by computing f(x) for endpoints + this.initData = function() { + this.data.set(xMinValue, this.func.app(xMinValue)); + this.data.set(xMaxValue, this.func.app(xMaxValue)); + this.pending.push([xMinValue, xMaxValue]); + }; + + // compute loss for each interval in pending + this.computeLosses = function() { + while (this.pending.length > 0) { + const xs = this.pending.pop(); + const ys = [this.data.get(xs[0]), this.data.get(xs[1])] + const loss = this.lossFunction(xs, ys) + this.lossManager.push([...xs, loss]) + } + }; + + // get the interval with the max loss + // TODO: handling multiple intervals with the same max loss (particularly important for uniform loss) + this.getMaxLoss = function() { + let maxLoss = -Infinity; + let maxInterval = null; + let maxIndex = null; + + for (let i = 0; i < this.lossManager.length; i++) { + const item = this.lossManager[i]; + if (item[2] > maxLoss) { + maxLoss = item[2]; + maxInterval = item.slice(0, 2); + maxIndex = i; + } + } + return { maxInterval, maxIndex }; + }; + + // split an interval in half, compute y-value of the midpoint, and add new intervals to pending + this.splitInterval = function(maxInterval, maxIndex) { + const [l, r] = maxInterval; + const m = (l + r) / 2; + this.data.set(m, this.func.app(m)); + this.lossManager.splice(maxIndex, 1); + this.pending.push([l, m], [m, r]); + }; + + // runs the adaptive sampler + // TODO: adding different stopping conditions + this.runner = function() { + this.initData(); + this.computeLosses(); + while (this.data.size < 10) { + const maxLoss = this.getMaxLoss() + this.splitInterval(maxLoss.maxInterval, maxLoss.maxIndex) + this.computeLosses() + } + }; + } + + var internal = { + defaultLoss: defaultLoss, + uniformLoss: uniformLoss, + absLogLoss: absLogLoss, + AdaptiveSampler: AdaptiveSampler + }; + + return RUNTIME.makeModuleReturn({}, {}, internal); + } +}) \ No newline at end of file diff --git a/lang/src/js/trove/charts-lib.js b/lang/src/js/trove/charts-lib.js index 18fdf9289..a02c4a8fa 100644 --- a/lang/src/js/trove/charts-lib.js +++ b/lang/src/js/trove/charts-lib.js @@ -2,6 +2,7 @@ requires: [ { 'import-type': 'builtin', 'name': 'image-lib' }, { "import-type": "builtin", 'name': "charts-util" }, + { "import-type": "builtin", 'name': "adaptive" } ], nativeRequires: [ 'pyret-base/js/js-numbers', @@ -20,7 +21,7 @@ 'plot': "tany", } }, - theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, CHARTSUTILLIB, jsnums, vega, canvasLib) { + theModule: function (RUNTIME, NAMESPACE, uri, IMAGELIB, CHARTSUTILLIB, ADAPTIVELIB, jsnums, vega, canvasLib) { 'use strict'; @@ -61,6 +62,7 @@ var IMAGE = get(IMAGELIB, "internal"); var CHARTSUTIL = get(CHARTSUTILLIB, "values"); + var ADAPTIVE = get(ADAPTIVELIB, "internal"); const ann = function(name, pred) { return RUNTIME.makePrimitiveAnn(name, pred); @@ -2767,20 +2769,18 @@ // NOTE: Must be run on Pyret stack - function recomputePoints(func, samplePoints, then) { + function recomputePoints(func, xMinValue, xMaxValue, numSamples, then) { + const loss = ADAPTIVE.defaultLoss; + const sampler = new ADAPTIVE.AdaptiveSampler(func, xMinValue, xMaxValue, loss, numSamples); return RUNTIME.safeCall(() => { - return RUNTIME.raw_array_map(RUNTIME.makeFunction((sample) => { - return RUNTIME.execThunk(RUNTIME.makeFunction(() => func.app(sample))); - }), samplePoints); - }, (funcVals) => { + sampler.runner(); + return sampler.data; + }, (dataMap) => { const dataValues = []; - funcVals.forEach((result, idx) => { - cases(RUNTIME.ffi.isEither, 'Either', result, { - left: (value) => dataValues.push({ - x: toFixnum(samplePoints[idx]), - y: toFixnum(value) - }), - right: () => {} + dataMap.forEach((yVal, xVal) => { + dataValues.push({ + x: toFixnum(xVal), + y: toFixnum(yVal) }) }); return then(dataValues); @@ -2801,8 +2801,6 @@ const xAxisType = globalOptions['x-axis-type']; const yAxisType = globalOptions['y-axis-type']; - const fraction = (xMaxValue - xMinValue) / (numSamples - 1); - const data = [ { name: `${prefix}table` } ]; const signals = [ @@ -2852,9 +2850,7 @@ addCrosshairs(prefix, ['Dots'], signals, marks, pointColor); - const samplePoints = [...Array(numSamples).keys().map((i) => (xMinValue + (fraction * i)))]; - - return recomputePoints(func, samplePoints, (dataValues) => { + return recomputePoints(func, xMinValue, xMaxValue, numSamples, (dataValues) => { data[0].values = dataValues; return { prefix, @@ -2869,14 +2865,13 @@ const numSamples = globalOptions.numSamples; const xMinValue = globalOptions.xMinValue; const xMaxValue = globalOptions.xMaxValue; - const fraction = (xMaxValue - xMinValue) / (numSamples - 1); - const samplePoints = [...Array(numSamples).keys().map((i) => (xMinValue + (fraction * i)))]; + RUNTIME.runThunk(() => { // NOTE(Ben): We can use view.data(`${prefix}rawTable`, ...newData...) // to replace the existing data points in the _current_ view, so that // we do not have to reconstruct a new vega.View or restart the rendering process. // See https://vega.github.io/vega/docs/api/view/#view_data - return recomputePoints(func, samplePoints, (dataValues) => { + return recomputePoints(func, xMinValue, xMaxValue, numSamples, (dataValues) => { view.data(data[0].name, dataValues); }); }, (res) => {