From 41aaada4590c5d6fa340a8a927c7b747cb822a8e Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 11:26:54 -0700 Subject: [PATCH 01/16] linear regression with confidence bands --- src/index.js | 1 + src/marks/linearRegression.js | 146 +++++++++ src/stats.js | 143 ++++++++ src/style.js | 2 +- test/output/carsLinearRegression.svg | 471 +++++++++++++++++++++++++++ test/plots/cars-linear-regression.js | 12 + test/plots/index.js | 1 + 7 files changed, 775 insertions(+), 1 deletion(-) create mode 100644 src/marks/linearRegression.js create mode 100644 src/stats.js create mode 100644 test/output/carsLinearRegression.svg create mode 100644 test/plots/cars-linear-regression.js diff --git a/src/index.js b/src/index.js index 21315476bb..b998da0c71 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ export {Frame, frame} from "./marks/frame.js"; export {Hexgrid, hexgrid} from "./marks/hexgrid.js"; export {Image, image} from "./marks/image.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; +export {linearRegressionY} from "./marks/linearRegression.js"; export {Link, link} from "./marks/link.js"; export {Rect, rect, rectX, rectY} from "./marks/rect.js"; export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js new file mode 100644 index 0000000000..050d4e87e0 --- /dev/null +++ b/src/marks/linearRegression.js @@ -0,0 +1,146 @@ +import {create, sum, area as shapeArea, range} from "d3"; +import {identity, indexOf, maybeZ} from "../options.js"; +import {Mark} from "../plot.js"; +import {qt} from "../stats.js"; +import {applyDirectStyles, applyGroupedChannelStyles, applyIndirectStyles, applyTransform, groupZ, offset} from "../style.js"; +import {maybeDenseIntervalX} from "../transforms/bin.js"; + +const lineDefaults = { + ariaLabel: "linear-regression", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.5, + strokeLinecap: "round", + strokeLinejoin: "round", + strokeMiterlimit: 1 +}; + +const bandDefaults = { + ariaLabel: "linear-regression-band", + fillOpacity: 0.1, + strokeWidth: 1, + strokeLinecap: "round", + strokeLinejoin: "round", + strokeMiterlimit: 1 +}; + +export function linearRegressionY(data, {x = indexOf, y = identity, stroke, fill, ...options} = {}) { // eslint-disable-line no-unused-vars + return [ + new LinearRegressionBandY(data, maybeDenseIntervalX({...options, x, y, fill: stroke, sort: {channel: "x"}})), + new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, stroke, sort: {channel: "x"}})) + ]; +} + +class LinearRegressionY extends Mark { + constructor(data, options = {}) { + const {x, y, z} = options; + super( + data, + [ + {name: "x", value: x, scale: "x"}, + {name: "y", value: y, scale: "y"}, + {name: "z", value: maybeZ(options), optional: true} + ], + options, + lineDefaults + ); + this.z = z; + } + render(I, {x, y}, channels, dimensions) { + const {x: X, y: Y, z: Z} = channels; + const {dx, dy} = this; + const {width, marginLeft, marginRight} = dimensions; + const x1 = marginLeft; + const x2 = width - marginRight; + return create("svg:g") + .call(applyIndirectStyles, this, dimensions) + .call(applyTransform, x, y, offset + dx, offset + dy) + .call(g => g.selectAll() + .data(Z ? groupZ(I, Z, this.z) : [I]) + .enter() + .append("path") + .call(applyDirectStyles, this) + .call(applyGroupedChannelStyles, this, channels) + .attr("d", I => { + const f = linearRegressionF(I, X, Y); + return `M${x1},${f(x1)}L${x2},${f(x2)}`; + })) + .node(); + } +} + +class LinearRegressionBandY extends Mark { + constructor(data, options = {}) { + const {x, y, z, p = 0.05} = options; + super( + data, + [ + {name: "x", value: x, scale: "x"}, + {name: "y", value: y, scale: "y"}, + {name: "z", value: maybeZ(options), optional: true} + ], + options, + bandDefaults + ); + this.z = z; + this.p = +p; + } + render(I, {x, y}, channels, dimensions) { + const {x: X, y: Y, z: Z} = channels; + const {dx, dy, p} = this; + const {width, marginLeft, marginRight} = dimensions; + const x1 = marginLeft; + const x2 = width - marginRight; + return create("svg:g") + .call(applyIndirectStyles, this, dimensions) + .call(applyTransform, x, y, offset + dx, offset + dy) + .call(g => g.selectAll() + .data(Z ? groupZ(I, Z, this.z) : [I]) + .enter() + .append("path") + .call(applyDirectStyles, this) + .call(applyGroupedChannelStyles, this, channels) + .attr("d", I => { + const f = linearRegressionF(I, X, Y); + const g = confidenceIntervalF(I, X, Y, p, f); + return shapeArea() + .x(x => x) + .y0(x => g(x)[0]) + .y1(x => g(x)[1]) + (range(x1, x2 - 1, 4).concat(x2)); + })) + .node(); + } +} + +function linearRegressionF(I, X, Y) { + let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + for (const i of I) { + const xi = X[i]; + const yi = Y[i]; + sumX += xi; + sumY += yi; + sumXY += xi * yi; + sumX2 += xi * xi; + } + const n = I.length; + const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); + const intercept = (sumY - slope * sumX) / n; + return x => slope * x + intercept; +} + +function confidenceIntervalF(I, X, Y, p, f) { + const mean = sum(I, i => X[i]) / I.length; + let a = 0, b = 0; + for (const i of I) { + a += (X[i] - mean) ** 2; + b += (Y[i] - f(X[i])) ** 2; + } + const sy = Math.sqrt(b / (I.length - 2)); + const t = qt(p, I.length - 2); + return x => { + const Y = f(x); + const se = sy * Math.sqrt(1 / I.length + (x - mean) ** 2 / a); + return [Y - t * se, Y + t * se]; + }; +} diff --git a/src/stats.js b/src/stats.js new file mode 100644 index 0000000000..f99d97d3cb --- /dev/null +++ b/src/stats.js @@ -0,0 +1,143 @@ +// https://github.com/jstat/jstat +// +// Copyright (c) 2013 jStat +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +export function ibetainv(p, a, b) { + var EPS = 1e-8; + var a1 = a - 1; + var b1 = b - 1; + var j = 0; + var lna, lnb, pp, t, u, err, x, al, h, w, afac; + if (p <= 0) return 0; + if (p >= 1) return 1; + if (a >= 1 && b >= 1) { + pp = p < 0.5 ? p : 1 - p; + t = Math.sqrt(-2 * Math.log(pp)); + x = (2.30753 + t * 0.27061) / (1 + t * (0.99229 + t * 0.04481)) - t; + if (p < 0.5) x = -x; + al = (x * x - 3) / 6; + h = 2 / (1 / (2 * a - 1) + 1 / (2 * b - 1)); + w = + (x * Math.sqrt(al + h)) / h - + (1 / (2 * b - 1) - 1 / (2 * a - 1)) * (al + 5 / 6 - 2 / (3 * h)); + x = a / (a + b * Math.exp(2 * w)); + } else { + lna = Math.log(a / (a + b)); + lnb = Math.log(b / (a + b)); + t = Math.exp(a * lna) / a; + u = Math.exp(b * lnb) / b; + w = t + u; + if (p < t / w) x = Math.pow(a * w * p, 1 / a); + else x = 1 - Math.pow(b * w * (1 - p), 1 / b); + } + afac = -gammaln(a) - gammaln(b) + gammaln(a + b); + for (; j < 10; j++) { + if (x === 0 || x === 1) return x; + err = ibeta(x, a, b) - p; + t = Math.exp(a1 * Math.log(x) + b1 * Math.log(1 - x) + afac); + u = err / t; + x -= t = u / (1 - 0.5 * Math.min(1, u * (a1 / x - b1 / (1 - x)))); + if (x <= 0) x = 0.5 * (x + t); + if (x >= 1) x = 0.5 * (x + t + 1); + if (Math.abs(t) < EPS * x && j > 0) break; + } + return x; +} + +export function ibeta(x, a, b) { + // Factors in front of the continued fraction. + var bt = + x === 0 || x === 1 + ? 0 + : Math.exp( + gammaln(a + b) - + gammaln(a) - + gammaln(b) + + a * Math.log(x) + + b * Math.log(1 - x) + ); + if (x < 0 || x > 1) return false; + if (x < (a + 1) / (a + b + 2)) + // Use continued fraction directly. + return (bt * betacf(x, a, b)) / a; + // else use continued fraction after making the symmetry transformation. + return 1 - (bt * betacf(1 - x, b, a)) / b; +} + +export function betacf(x, a, b) { + var fpmin = 1e-30; + var m = 1; + var qab = a + b; + var qap = a + 1; + var qam = a - 1; + var c = 1; + var d = 1 - (qab * x) / qap; + var m2, aa, del, h; + + // These q's will be used in factors that occur in the coefficients + if (Math.abs(d) < fpmin) d = fpmin; + d = 1 / d; + h = d; + + for (; m <= 100; m++) { + m2 = 2 * m; + aa = (m * (b - m) * x) / ((qam + m2) * (a + m2)); + // One step (the even one) of the recurrence + d = 1 + aa * d; + if (Math.abs(d) < fpmin) d = fpmin; + c = 1 + aa / c; + if (Math.abs(c) < fpmin) c = fpmin; + d = 1 / d; + h *= d * c; + aa = (-(a + m) * (qab + m) * x) / ((a + m2) * (qap + m2)); + // Next step of the recurrence (the odd one) + d = 1 + aa * d; + if (Math.abs(d) < fpmin) d = fpmin; + c = 1 + aa / c; + if (Math.abs(c) < fpmin) c = fpmin; + d = 1 / d; + del = d * c; + h *= del; + if (Math.abs(del - 1.0) < 3e-7) break; + } + + return h; +} + +export function gammaln(x) { + var j = 0; + var cof = [ + 76.18009172947146, -86.5053203294167, 24.01409824083091, -1.231739572450155, + 0.1208650973866179e-2, -0.5395239384953e-5 + ]; + var ser = 1.000000000190015; + var xx, y, tmp; + tmp = (y = xx = x) + 5.5; + tmp -= (xx + 0.5) * Math.log(tmp); + for (; j < 6; j++) ser += cof[j] / ++y; + return Math.log((2.506628274631 * ser) / xx) - tmp; +} + +export function qt(p, dof) { + var x = ibetainv(2 * Math.min(p, 1 - p), 0.5 * dof, 0.5); + x = Math.sqrt((dof * (1 - x)) / x); + return p > 0.5 ? x : -x; +} diff --git a/src/style.js b/src/style.js index 4fc0804dff..0d95244b4d 100644 --- a/src/style.js +++ b/src/style.js @@ -184,7 +184,7 @@ function groupAesthetics({ariaLabel: AL, title: T, fill: F, fillOpacity: FO, str return [AL, T, F, FO, S, SO, SW, O, H].filter(c => c !== undefined); } -function groupZ(I, Z, z) { +export function groupZ(I, Z, z) { const G = group(I, i => Z[i]); if (z === undefined && G.size > I.length >> 1) { warn(`Warning: the implicit z channel has high cardinality. This may occur when the fill or stroke channel is associated with quantitative data rather than ordinal or categorical data. You can suppress this warning by setting the z option explicitly; if this data represents a single series, set z to null.`); diff --git a/test/output/carsLinearRegression.svg b/test/output/carsLinearRegression.svg new file mode 100644 index 0000000000..324ecf6f36 --- /dev/null +++ b/test/output/carsLinearRegression.svg @@ -0,0 +1,471 @@ + + + + + 10 + + + 15 + + + 20 + + + 25 + + + 30 + + + 35 + + + 40 + + + 45 + ↑ economy (mpg) + + + + 2,000 + + + 2,500 + + + 3,000 + + + 3,500 + + + 4,000 + + + 4,500 + + + 5,000 + weight (lb) → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/cars-linear-regression.js b/test/plots/cars-linear-regression.js new file mode 100644 index 0000000000..4a33d0fbf2 --- /dev/null +++ b/test/plots/cars-linear-regression.js @@ -0,0 +1,12 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function () { + const cars = await d3.csv("data/cars.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.dot(cars, {x: "weight (lb)", y: "economy (mpg)", r: 2}), + Plot.linearRegressionY(cars, {x: "weight (lb)", y: "economy (mpg)", p: 0.01}) + ] + }); +} diff --git a/test/plots/index.js b/test/plots/index.js index 0caa616fdf..33faff365d 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -32,6 +32,7 @@ export {default as caltrainDirection} from "./caltrain-direction.js"; export {default as carsDodge} from "./cars-dodge.js"; export {default as carsHexbin} from "./cars-hexbin.js"; export {default as carsJitter} from "./cars-jitter.js"; +export {default as carsLinearRegression} from "./cars-linear-regression.js"; export {default as carsMpg} from "./cars-mpg.js"; export {default as carsParcoords} from "./cars-parcoords.js"; export {default as clamp} from "./clamp.js"; From c11ceb6af299a2d52814ecbb5f273392d5dc597c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 23:01:01 -0700 Subject: [PATCH 02/16] handle invalid p; adopt marks --- src/marks/linearRegression.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index 050d4e87e0..3a85bbd238 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -1,6 +1,6 @@ import {create, sum, area as shapeArea, range} from "d3"; import {identity, indexOf, maybeZ} from "../options.js"; -import {Mark} from "../plot.js"; +import {Mark, marks} from "../plot.js"; import {qt} from "../stats.js"; import {applyDirectStyles, applyGroupedChannelStyles, applyIndirectStyles, applyTransform, groupZ, offset} from "../style.js"; import {maybeDenseIntervalX} from "../transforms/bin.js"; @@ -24,11 +24,11 @@ const bandDefaults = { strokeMiterlimit: 1 }; -export function linearRegressionY(data, {x = indexOf, y = identity, stroke, fill, ...options} = {}) { // eslint-disable-line no-unused-vars - return [ - new LinearRegressionBandY(data, maybeDenseIntervalX({...options, x, y, fill: stroke, sort: {channel: "x"}})), - new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, stroke, sort: {channel: "x"}})) - ]; +export function linearRegressionY(data, {x = indexOf, y = identity, stroke, fill, p, ...options} = {}) { // eslint-disable-line no-unused-vars + const line = new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, stroke, sort: {channel: "x"}})); + if (p === null || p === 0) return line; + const band = new LinearRegressionBandY(data, maybeDenseIntervalX({...options, x, y, fill: stroke, sort: {channel: "x"}})); + return marks(band, line); } class LinearRegressionY extends Mark { @@ -84,6 +84,7 @@ class LinearRegressionBandY extends Mark { ); this.z = z; this.p = +p; + if (!(0 < this.p && this.p < 0.5)) throw new Error(`p not in (0, 0.5): ${p}`); } render(I, {x, y}, channels, dimensions) { const {x: X, y: Y, z: Z} = channels; From c88914b1a796468031732d0ce1c5204bd3645c0b Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 23:06:23 -0700 Subject: [PATCH 03/16] fix fill, stroke, z --- src/marks/linearRegression.js | 11 ++++++----- test/plots/cars-linear-regression.js | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index 3a85bbd238..6966023d4d 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -24,10 +24,11 @@ const bandDefaults = { strokeMiterlimit: 1 }; -export function linearRegressionY(data, {x = indexOf, y = identity, stroke, fill, p, ...options} = {}) { // eslint-disable-line no-unused-vars - const line = new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, stroke, sort: {channel: "x"}})); +export function linearRegressionY(data, {x = indexOf, y = identity, z, stroke, fill = stroke, p, ...options} = {}) { + z = maybeZ({z, fill, stroke}); // enforce consistent z + const line = new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, z, stroke, sort: {channel: "x"}})); if (p === null || p === 0) return line; - const band = new LinearRegressionBandY(data, maybeDenseIntervalX({...options, x, y, fill: stroke, sort: {channel: "x"}})); + const band = new LinearRegressionBandY(data, maybeDenseIntervalX({...options, x, y, z, p, fill, sort: {channel: "x"}})); return marks(band, line); } @@ -39,7 +40,7 @@ class LinearRegressionY extends Mark { [ {name: "x", value: x, scale: "x"}, {name: "y", value: y, scale: "y"}, - {name: "z", value: maybeZ(options), optional: true} + {name: "z", value: z, optional: true} ], options, lineDefaults @@ -77,7 +78,7 @@ class LinearRegressionBandY extends Mark { [ {name: "x", value: x, scale: "x"}, {name: "y", value: y, scale: "y"}, - {name: "z", value: maybeZ(options), optional: true} + {name: "z", value: z, optional: true} ], options, bandDefaults diff --git a/test/plots/cars-linear-regression.js b/test/plots/cars-linear-regression.js index 4a33d0fbf2..be99ddb800 100644 --- a/test/plots/cars-linear-regression.js +++ b/test/plots/cars-linear-regression.js @@ -6,7 +6,7 @@ export default async function () { return Plot.plot({ marks: [ Plot.dot(cars, {x: "weight (lb)", y: "economy (mpg)", r: 2}), - Plot.linearRegressionY(cars, {x: "weight (lb)", y: "economy (mpg)", p: 0.01}) + Plot.linearRegressionY(cars, {x: "weight (lb)", y: "economy (mpg)", p: 0.4, stroke: "cylinders"}) ] }); } From 1d98ec258734c212af758a196147c6b0dfd1d40c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 23:06:57 -0700 Subject: [PATCH 04/16] fix tests --- test/plots/cars-linear-regression.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plots/cars-linear-regression.js b/test/plots/cars-linear-regression.js index be99ddb800..4a33d0fbf2 100644 --- a/test/plots/cars-linear-regression.js +++ b/test/plots/cars-linear-regression.js @@ -6,7 +6,7 @@ export default async function () { return Plot.plot({ marks: [ Plot.dot(cars, {x: "weight (lb)", y: "economy (mpg)", r: 2}), - Plot.linearRegressionY(cars, {x: "weight (lb)", y: "economy (mpg)", p: 0.4, stroke: "cylinders"}) + Plot.linearRegressionY(cars, {x: "weight (lb)", y: "economy (mpg)", p: 0.01}) ] }); } From 0c7db246cdb2fd2ad167be9a83ff8f96eea28da0 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 23:09:01 -0700 Subject: [PATCH 05/16] fit regression extent to data --- src/marks/linearRegression.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index 6966023d4d..28c3712aa0 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -1,4 +1,4 @@ -import {create, sum, area as shapeArea, range} from "d3"; +import {create, sum, area as shapeArea, range, min, max} from "d3"; import {identity, indexOf, maybeZ} from "../options.js"; import {Mark, marks} from "../plot.js"; import {qt} from "../stats.js"; @@ -50,9 +50,6 @@ class LinearRegressionY extends Mark { render(I, {x, y}, channels, dimensions) { const {x: X, y: Y, z: Z} = channels; const {dx, dy} = this; - const {width, marginLeft, marginRight} = dimensions; - const x1 = marginLeft; - const x2 = width - marginRight; return create("svg:g") .call(applyIndirectStyles, this, dimensions) .call(applyTransform, x, y, offset + dx, offset + dy) @@ -63,6 +60,8 @@ class LinearRegressionY extends Mark { .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, channels) .attr("d", I => { + const x1 = min(I, i => X[i]); + const x2 = max(I, i => X[i]); const f = linearRegressionF(I, X, Y); return `M${x1},${f(x1)}L${x2},${f(x2)}`; })) @@ -90,9 +89,6 @@ class LinearRegressionBandY extends Mark { render(I, {x, y}, channels, dimensions) { const {x: X, y: Y, z: Z} = channels; const {dx, dy, p} = this; - const {width, marginLeft, marginRight} = dimensions; - const x1 = marginLeft; - const x2 = width - marginRight; return create("svg:g") .call(applyIndirectStyles, this, dimensions) .call(applyTransform, x, y, offset + dx, offset + dy) @@ -103,6 +99,8 @@ class LinearRegressionBandY extends Mark { .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, channels) .attr("d", I => { + const x1 = min(I, i => X[i]); + const x2 = max(I, i => X[i]); const f = linearRegressionF(I, X, Y); const g = confidenceIntervalF(I, X, Y, p, f); return shapeArea() From 3353f24cef0bdf69f14f0ccfae4768c7e39c9908 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 23:15:43 -0700 Subject: [PATCH 06/16] configurable precision --- src/marks/linearRegression.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index 28c3712aa0..67ccfb1de5 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -71,7 +71,7 @@ class LinearRegressionY extends Mark { class LinearRegressionBandY extends Mark { constructor(data, options = {}) { - const {x, y, z, p = 0.05} = options; + const {x, y, z, p = 0.05, precision = 4} = options; super( data, [ @@ -84,11 +84,12 @@ class LinearRegressionBandY extends Mark { ); this.z = z; this.p = +p; + this.precision = +precision; if (!(0 < this.p && this.p < 0.5)) throw new Error(`p not in (0, 0.5): ${p}`); } render(I, {x, y}, channels, dimensions) { const {x: X, y: Y, z: Z} = channels; - const {dx, dy, p} = this; + const {dx, dy, p, precision} = this; return create("svg:g") .call(applyIndirectStyles, this, dimensions) .call(applyTransform, x, y, offset + dx, offset + dy) @@ -105,9 +106,9 @@ class LinearRegressionBandY extends Mark { const g = confidenceIntervalF(I, X, Y, p, f); return shapeArea() .x(x => x) - .y0(x => g(x)[0]) - .y1(x => g(x)[1]) - (range(x1, x2 - 1, 4).concat(x2)); + .y0(x => g(x, -1)) + .y1(x => g(x, +1)) + (range(x1, x2 - precision / 2, precision).concat(x2)); })) .node(); } @@ -138,9 +139,9 @@ function confidenceIntervalF(I, X, Y, p, f) { } const sy = Math.sqrt(b / (I.length - 2)); const t = qt(p, I.length - 2); - return x => { + return (x, k) => { const Y = f(x); const se = sy * Math.sqrt(1 / I.length + (x - mean) ** 2 / a); - return [Y - t * se, Y + t * se]; + return Y + k * t * se; }; } From c110d6d0d421dbc889bd560b6079233b6baf010d Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 23:38:03 -0700 Subject: [PATCH 07/16] combine regression marks --- src/marks/linearRegression.js | 85 ++++++++-------------------- src/style.js | 3 +- test/output/carsLinearRegression.svg | 6 +- 3 files changed, 28 insertions(+), 66 deletions(-) diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index 67ccfb1de5..271913ae07 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -1,13 +1,14 @@ import {create, sum, area as shapeArea, range, min, max} from "d3"; import {identity, indexOf, maybeZ} from "../options.js"; -import {Mark, marks} from "../plot.js"; +import {Mark} from "../plot.js"; import {qt} from "../stats.js"; import {applyDirectStyles, applyGroupedChannelStyles, applyIndirectStyles, applyTransform, groupZ, offset} from "../style.js"; import {maybeDenseIntervalX} from "../transforms/bin.js"; -const lineDefaults = { +const defaults = { ariaLabel: "linear-regression", - fill: "none", + fill: "currentColor", + fillOpacity: 0.1, stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", @@ -15,90 +16,48 @@ const lineDefaults = { strokeMiterlimit: 1 }; -const bandDefaults = { - ariaLabel: "linear-regression-band", - fillOpacity: 0.1, - strokeWidth: 1, - strokeLinecap: "round", - strokeLinejoin: "round", - strokeMiterlimit: 1 -}; - -export function linearRegressionY(data, {x = indexOf, y = identity, z, stroke, fill = stroke, p, ...options} = {}) { - z = maybeZ({z, fill, stroke}); // enforce consistent z - const line = new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, z, stroke, sort: {channel: "x"}})); - if (p === null || p === 0) return line; - const band = new LinearRegressionBandY(data, maybeDenseIntervalX({...options, x, y, z, p, fill, sort: {channel: "x"}})); - return marks(band, line); -} - class LinearRegressionY extends Mark { constructor(data, options = {}) { - const {x, y, z} = options; + const {x, y, z, p = 0.05, precision = 4} = options; super( data, [ {name: "x", value: x, scale: "x"}, {name: "y", value: y, scale: "y"}, - {name: "z", value: z, optional: true} + {name: "z", value: maybeZ(options), optional: true} ], options, - lineDefaults + defaults ); this.z = z; + this.p = +p; + this.precision = +precision; + if (!(0 < this.p && this.p < 0.5)) throw new Error(`invalid p; not in (0, 0.5): ${p}`); + if (!(this.precision > 0)) throw new Error(`invalid precision: ${precision}`); } render(I, {x, y}, channels, dimensions) { const {x: X, y: Y, z: Z} = channels; - const {dx, dy} = this; + const {dx, dy, p, precision} = this; return create("svg:g") - .call(applyIndirectStyles, this, dimensions) + .call(applyIndirectStyles, {...this, stroke: null, fill: null}, dimensions) .call(applyTransform, x, y, offset + dx, offset + dy) .call(g => g.selectAll() .data(Z ? groupZ(I, Z, this.z) : [I]) .enter() - .append("path") + .call(enter => enter.append("path") + .call(applyIndirectStyles, {stroke: this.stroke}) .call(applyDirectStyles, this) - .call(applyGroupedChannelStyles, this, channels) + .call(applyGroupedChannelStyles, this, {...channels, fill: null, fillOpacity: null}) .attr("d", I => { const x1 = min(I, i => X[i]); const x2 = max(I, i => X[i]); const f = linearRegressionF(I, X, Y); return `M${x1},${f(x1)}L${x2},${f(x2)}`; })) - .node(); - } -} - -class LinearRegressionBandY extends Mark { - constructor(data, options = {}) { - const {x, y, z, p = 0.05, precision = 4} = options; - super( - data, - [ - {name: "x", value: x, scale: "x"}, - {name: "y", value: y, scale: "y"}, - {name: "z", value: z, optional: true} - ], - options, - bandDefaults - ); - this.z = z; - this.p = +p; - this.precision = +precision; - if (!(0 < this.p && this.p < 0.5)) throw new Error(`p not in (0, 0.5): ${p}`); - } - render(I, {x, y}, channels, dimensions) { - const {x: X, y: Y, z: Z} = channels; - const {dx, dy, p, precision} = this; - return create("svg:g") - .call(applyIndirectStyles, this, dimensions) - .call(applyTransform, x, y, offset + dx, offset + dy) - .call(g => g.selectAll() - .data(Z ? groupZ(I, Z, this.z) : [I]) - .enter() - .append("path") + .call(enter => enter.append("path") + .call(applyIndirectStyles, {fill: this.fill}) .call(applyDirectStyles, this) - .call(applyGroupedChannelStyles, this, channels) + .call(applyGroupedChannelStyles, this, {...channels, stroke: null, strokeOpacity: null}) .attr("d", I => { const x1 = min(I, i => X[i]); const x2 = max(I, i => X[i]); @@ -109,11 +68,15 @@ class LinearRegressionBandY extends Mark { .y0(x => g(x, -1)) .y1(x => g(x, +1)) (range(x1, x2 - precision / 2, precision).concat(x2)); - })) + }))) .node(); } } +export function linearRegressionY(data, {x = indexOf, y = identity, stroke, fill = stroke, ...options} = {}) { + return new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, fill, stroke, sort: {channel: "x"}})); +} + function linearRegressionF(I, X, Y) { let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; for (const i of I) { diff --git a/src/style.js b/src/style.js index 0d95244b4d..6bfff2ce2d 100644 --- a/src/style.js +++ b/src/style.js @@ -247,7 +247,7 @@ export function maybeClip(clip) { throw new Error(`invalid clip method: ${clip}`); } -export function applyIndirectStyles(selection, mark, {width, height, marginLeft, marginRight, marginTop, marginBottom}) { +export function applyIndirectStyles(selection, mark, dimensions) { applyAttr(selection, "aria-label", mark.ariaLabel); applyAttr(selection, "aria-description", mark.ariaDescription); applyAttr(selection, "aria-hidden", mark.ariaHidden); @@ -265,6 +265,7 @@ export function applyIndirectStyles(selection, mark, {width, height, marginLeft, applyAttr(selection, "paint-order", mark.paintOrder); applyAttr(selection, "pointer-events", mark.pointerEvents); if (mark.clip === "frame") { + const {width, height, marginLeft, marginRight, marginTop, marginBottom} = dimensions; const id = `plot-clip-${++nextClipId}`; selection .attr("clip-path", `url(#${id})`) diff --git a/test/output/carsLinearRegression.svg b/test/output/carsLinearRegression.svg index 324ecf6f36..45ac41a4df 100644 --- a/test/output/carsLinearRegression.svg +++ b/test/output/carsLinearRegression.svg @@ -462,10 +462,8 @@ - + + - - - \ No newline at end of file From 0cc5f1331e354c91ecec1d4f5af89b2041ec2739 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 23:44:21 -0700 Subject: [PATCH 08/16] fix combined mark order --- src/marks/linearRegression.js | 24 ++++++++++++------------ test/output/carsLinearRegression.svg | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index 271913ae07..f2c88c7016 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -39,35 +39,35 @@ class LinearRegressionY extends Mark { const {x: X, y: Y, z: Z} = channels; const {dx, dy, p, precision} = this; return create("svg:g") - .call(applyIndirectStyles, {...this, stroke: null, fill: null}, dimensions) + .call(applyIndirectStyles, this, dimensions) .call(applyTransform, x, y, offset + dx, offset + dy) .call(g => g.selectAll() .data(Z ? groupZ(I, Z, this.z) : [I]) .enter() .call(enter => enter.append("path") - .call(applyIndirectStyles, {stroke: this.stroke}) + .attr("stroke", "none") .call(applyDirectStyles, this) - .call(applyGroupedChannelStyles, this, {...channels, fill: null, fillOpacity: null}) + .call(applyGroupedChannelStyles, this, {...channels, stroke: null, strokeOpacity: null, strokeWidth: null}) .attr("d", I => { const x1 = min(I, i => X[i]); const x2 = max(I, i => X[i]); const f = linearRegressionF(I, X, Y); - return `M${x1},${f(x1)}L${x2},${f(x2)}`; + const g = confidenceIntervalF(I, X, Y, p, f); + return shapeArea() + .x(x => x) + .y0(x => g(x, -1)) + .y1(x => g(x, +1)) + (range(x1, x2 - precision / 2, precision).concat(x2)); })) .call(enter => enter.append("path") - .call(applyIndirectStyles, {fill: this.fill}) + .attr("fill", "none") .call(applyDirectStyles, this) - .call(applyGroupedChannelStyles, this, {...channels, stroke: null, strokeOpacity: null}) + .call(applyGroupedChannelStyles, this, {...channels, fill: null, fillOpacity: null}) .attr("d", I => { const x1 = min(I, i => X[i]); const x2 = max(I, i => X[i]); const f = linearRegressionF(I, X, Y); - const g = confidenceIntervalF(I, X, Y, p, f); - return shapeArea() - .x(x => x) - .y0(x => g(x, -1)) - .y1(x => g(x, +1)) - (range(x1, x2 - precision / 2, precision).concat(x2)); + return `M${x1},${f(x1)}L${x2},${f(x2)}`; }))) .node(); } diff --git a/test/output/carsLinearRegression.svg b/test/output/carsLinearRegression.svg index 45ac41a4df..fbeaa1ff8e 100644 --- a/test/output/carsLinearRegression.svg +++ b/test/output/carsLinearRegression.svg @@ -462,8 +462,8 @@ - - - + + + \ No newline at end of file From 43cec361c36536fdb780f72e4397a15815b09048 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 23:49:43 -0700 Subject: [PATCH 09/16] add penguins test --- test/output/penguinLinearRegression.svg | 428 ++++++++++++++++++++++++ test/plots/index.js | 1 + test/plots/penguin-linear-regression.js | 14 + 3 files changed, 443 insertions(+) create mode 100644 test/output/penguinLinearRegression.svg create mode 100644 test/plots/penguin-linear-regression.js diff --git a/test/output/penguinLinearRegression.svg b/test/output/penguinLinearRegression.svg new file mode 100644 index 0000000000..0fad995088 --- /dev/null +++ b/test/output/penguinLinearRegression.svg @@ -0,0 +1,428 @@ + + + + + + 14 + + + + 15 + + + + 16 + + + + 17 + + + + 18 + + + + 19 + + + + 20 + + + + 21 + ↑ culmen_depth_mm + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index 33faff365d..3e1cd17d3d 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -141,6 +141,7 @@ export {default as penguinFacetDodgeIdentity} from "./penguin-facet-dodge-identi export {default as penguinFacetDodgeIsland} from "./penguin-facet-dodge-island.js"; export {default as penguinFacetDodgeSymbol} from "./penguin-facet-dodge-symbol.js"; export {default as penguinIslandUnknown} from "./penguin-island-unknown.js"; +export {default as penguinLinearRegression} from "./penguin-linear-regression.js"; export {default as penguinMass} from "./penguin-mass.js"; export {default as penguinMassSex} from "./penguin-mass-sex.js"; export {default as penguinMassSexSpecies} from "./penguin-mass-sex-species.js"; diff --git a/test/plots/penguin-linear-regression.js b/test/plots/penguin-linear-regression.js new file mode 100644 index 0000000000..a1ef1647ec --- /dev/null +++ b/test/plots/penguin-linear-regression.js @@ -0,0 +1,14 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function () { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + grid: true, + marks: [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fill: "species"}), + Plot.linearRegressionY(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "species"}), + Plot.linearRegressionY(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}) + ] + }); +} From 95b66006802b02decd7ca800316d2470be9bbc32 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 17 Jun 2022 23:51:15 -0700 Subject: [PATCH 10/16] rename tests --- .../{carsLinearRegression.svg => linearRegressionCars.svg} | 0 ...nguinLinearRegression.svg => linearRegressionPenguins.svg} | 0 test/plots/index.js | 4 ++-- .../{cars-linear-regression.js => linear-regression-cars.js} | 0 ...uin-linear-regression.js => linear-regression-penguins.js} | 0 5 files changed, 2 insertions(+), 2 deletions(-) rename test/output/{carsLinearRegression.svg => linearRegressionCars.svg} (100%) rename test/output/{penguinLinearRegression.svg => linearRegressionPenguins.svg} (100%) rename test/plots/{cars-linear-regression.js => linear-regression-cars.js} (100%) rename test/plots/{penguin-linear-regression.js => linear-regression-penguins.js} (100%) diff --git a/test/output/carsLinearRegression.svg b/test/output/linearRegressionCars.svg similarity index 100% rename from test/output/carsLinearRegression.svg rename to test/output/linearRegressionCars.svg diff --git a/test/output/penguinLinearRegression.svg b/test/output/linearRegressionPenguins.svg similarity index 100% rename from test/output/penguinLinearRegression.svg rename to test/output/linearRegressionPenguins.svg diff --git a/test/plots/index.js b/test/plots/index.js index 3e1cd17d3d..d5294411cc 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -32,7 +32,6 @@ export {default as caltrainDirection} from "./caltrain-direction.js"; export {default as carsDodge} from "./cars-dodge.js"; export {default as carsHexbin} from "./cars-hexbin.js"; export {default as carsJitter} from "./cars-jitter.js"; -export {default as carsLinearRegression} from "./cars-linear-regression.js"; export {default as carsMpg} from "./cars-mpg.js"; export {default as carsParcoords} from "./cars-parcoords.js"; export {default as clamp} from "./clamp.js"; @@ -103,6 +102,8 @@ export {default as letterFrequencyColumn} from "./letter-frequency-column.js"; export {default as letterFrequencyDot} from "./letter-frequency-dot.js"; export {default as letterFrequencyLollipop} from "./letter-frequency-lollipop.js"; export {default as letterFrequencyWheel} from "./letter-frequency-wheel.js"; +export {default as linearRegressionCars} from "./linear-regression-cars.js"; +export {default as linearRegressionPenguins} from "./linear-regression-penguins.js"; export {default as likertSurvey} from "./likert-survey.js"; export {default as logDegenerate} from "./log-degenerate.js"; export {default as markovChain} from "./markov-chain.js"; @@ -141,7 +142,6 @@ export {default as penguinFacetDodgeIdentity} from "./penguin-facet-dodge-identi export {default as penguinFacetDodgeIsland} from "./penguin-facet-dodge-island.js"; export {default as penguinFacetDodgeSymbol} from "./penguin-facet-dodge-symbol.js"; export {default as penguinIslandUnknown} from "./penguin-island-unknown.js"; -export {default as penguinLinearRegression} from "./penguin-linear-regression.js"; export {default as penguinMass} from "./penguin-mass.js"; export {default as penguinMassSex} from "./penguin-mass-sex.js"; export {default as penguinMassSexSpecies} from "./penguin-mass-sex-species.js"; diff --git a/test/plots/cars-linear-regression.js b/test/plots/linear-regression-cars.js similarity index 100% rename from test/plots/cars-linear-regression.js rename to test/plots/linear-regression-cars.js diff --git a/test/plots/penguin-linear-regression.js b/test/plots/linear-regression-penguins.js similarity index 100% rename from test/plots/penguin-linear-regression.js rename to test/plots/linear-regression-penguins.js From f63e9c594402d782725ec63e010ddad1d924c615 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 18 Jun 2022 09:26:01 -0400 Subject: [PATCH 11/16] remove unnecessary sort --- src/marks/linearRegression.js | 10 ++++------ test/output/linearRegressionCars.svg | 4 ++-- test/output/linearRegressionPenguins.svg | 16 ++++++++-------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index f2c88c7016..662658aa5f 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -1,4 +1,4 @@ -import {create, sum, area as shapeArea, range, min, max} from "d3"; +import {create, extent, range, sum, area as shapeArea} from "d3"; import {identity, indexOf, maybeZ} from "../options.js"; import {Mark} from "../plot.js"; import {qt} from "../stats.js"; @@ -49,8 +49,7 @@ class LinearRegressionY extends Mark { .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, {...channels, stroke: null, strokeOpacity: null, strokeWidth: null}) .attr("d", I => { - const x1 = min(I, i => X[i]); - const x2 = max(I, i => X[i]); + const [x1, x2] = extent(I, i => X[i]); const f = linearRegressionF(I, X, Y); const g = confidenceIntervalF(I, X, Y, p, f); return shapeArea() @@ -64,8 +63,7 @@ class LinearRegressionY extends Mark { .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, {...channels, fill: null, fillOpacity: null}) .attr("d", I => { - const x1 = min(I, i => X[i]); - const x2 = max(I, i => X[i]); + const [x1, x2] = extent(I, i => X[i]); const f = linearRegressionF(I, X, Y); return `M${x1},${f(x1)}L${x2},${f(x2)}`; }))) @@ -74,7 +72,7 @@ class LinearRegressionY extends Mark { } export function linearRegressionY(data, {x = indexOf, y = identity, stroke, fill = stroke, ...options} = {}) { - return new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, fill, stroke, sort: {channel: "x"}})); + return new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, fill, stroke})); } function linearRegressionF(I, X, Y) { diff --git a/test/output/linearRegressionCars.svg b/test/output/linearRegressionCars.svg index fbeaa1ff8e..a591f6ff4e 100644 --- a/test/output/linearRegressionCars.svg +++ b/test/output/linearRegressionCars.svg @@ -463,7 +463,7 @@ - - + + \ No newline at end of file diff --git a/test/output/linearRegressionPenguins.svg b/test/output/linearRegressionPenguins.svg index 0fad995088..630cafb314 100644 --- a/test/output/linearRegressionPenguins.svg +++ b/test/output/linearRegressionPenguins.svg @@ -414,15 +414,15 @@ - - - - - - + + + + + + - - + + \ No newline at end of file From 8d6cee7ef63b911d86e1a7af4c5b657d5a84d686 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 18 Jun 2022 09:36:43 -0400 Subject: [PATCH 12/16] linearRegressionX --- src/index.js | 2 +- src/marks/linearRegression.js | 71 ++++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/index.js b/src/index.js index b998da0c71..0e391414fe 100644 --- a/src/index.js +++ b/src/index.js @@ -10,7 +10,7 @@ export {Frame, frame} from "./marks/frame.js"; export {Hexgrid, hexgrid} from "./marks/hexgrid.js"; export {Image, image} from "./marks/image.js"; export {Line, line, lineX, lineY} from "./marks/line.js"; -export {linearRegressionY} from "./marks/linearRegression.js"; +export {linearRegressionX, linearRegressionY} from "./marks/linearRegression.js"; export {Link, link} from "./marks/link.js"; export {Rect, rect, rectX, rectY} from "./marks/rect.js"; export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js"; diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index 662658aa5f..f804eace34 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -3,7 +3,7 @@ import {identity, indexOf, maybeZ} from "../options.js"; import {Mark} from "../plot.js"; import {qt} from "../stats.js"; import {applyDirectStyles, applyGroupedChannelStyles, applyIndirectStyles, applyTransform, groupZ, offset} from "../style.js"; -import {maybeDenseIntervalX} from "../transforms/bin.js"; +import {maybeDenseIntervalX, maybeDenseIntervalY} from "../transforms/bin.js"; const defaults = { ariaLabel: "linear-regression", @@ -16,7 +16,7 @@ const defaults = { strokeMiterlimit: 1 }; -class LinearRegressionY extends Mark { +class LinearRegression extends Mark { constructor(data, options = {}) { const {x, y, z, p = 0.05, precision = 4} = options; super( @@ -37,7 +37,7 @@ class LinearRegressionY extends Mark { } render(I, {x, y}, channels, dimensions) { const {x: X, y: Y, z: Z} = channels; - const {dx, dy, p, precision} = this; + const {dx, dy} = this; return create("svg:g") .call(applyIndirectStyles, this, dimensions) .call(applyTransform, x, y, offset + dx, offset + dy) @@ -48,29 +48,64 @@ class LinearRegressionY extends Mark { .attr("stroke", "none") .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, {...channels, stroke: null, strokeOpacity: null, strokeWidth: null}) - .attr("d", I => { - const [x1, x2] = extent(I, i => X[i]); - const f = linearRegressionF(I, X, Y); - const g = confidenceIntervalF(I, X, Y, p, f); - return shapeArea() - .x(x => x) - .y0(x => g(x, -1)) - .y1(x => g(x, +1)) - (range(x1, x2 - precision / 2, precision).concat(x2)); - })) + .attr("d", I => this._renderBand(I, X, Y))) .call(enter => enter.append("path") .attr("fill", "none") .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, {...channels, fill: null, fillOpacity: null}) - .attr("d", I => { - const [x1, x2] = extent(I, i => X[i]); - const f = linearRegressionF(I, X, Y); - return `M${x1},${f(x1)}L${x2},${f(x2)}`; - }))) + .attr("d", I => this._renderLine(I, X, Y)))) .node(); } } +class LinearRegressionX extends LinearRegression { + constructor(data, options) { + super(data, options); + } + _renderBand(I, X, Y) { + const {p, precision} = this; + const [y1, y2] = extent(I, i => Y[i]); + const f = linearRegressionF(I, Y, X); + const g = confidenceIntervalF(I, Y, X, p, f); + return shapeArea() + .y(y => y) + .x0(y => g(y, -1)) + .x1(y => g(y, +1)) + (range(y1, y2 - precision / 2, precision).concat(y2)); + } + _renderLine(I, X, Y) { + const [y1, y2] = extent(I, i => Y[i]); + const f = linearRegressionF(I, Y, X); + return `M${f(y1)},${y1}L${f(y2)},${y2}`; + } +} + +class LinearRegressionY extends LinearRegression { + constructor(data, options) { + super(data, options); + } + _renderBand(I, X, Y) { + const {p, precision} = this; + const [x1, x2] = extent(I, i => X[i]); + const f = linearRegressionF(I, X, Y); + const g = confidenceIntervalF(I, X, Y, p, f); + return shapeArea() + .x(x => x) + .y0(x => g(x, -1)) + .y1(x => g(x, +1)) + (range(x1, x2 - precision / 2, precision).concat(x2)); + } + _renderLine(I, X, Y) { + const [x1, x2] = extent(I, i => X[i]); + const f = linearRegressionF(I, X, Y); + return `M${x1},${f(x1)}L${x2},${f(x2)}`; + } +} + +export function linearRegressionX(data, {y = indexOf, x = identity, stroke, fill = stroke, ...options} = {}) { + return new LinearRegressionX(data, maybeDenseIntervalY({...options, x, y, fill, stroke})); +} + export function linearRegressionY(data, {x = indexOf, y = identity, stroke, fill = stroke, ...options} = {}) { return new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, fill, stroke})); } From ca8cbf8c23a1016ae60fcee1c395c0e3da1a6a79 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 18 Jun 2022 09:43:50 -0400 Subject: [PATCH 13/16] make band optional --- src/marks/linearRegression.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index f804eace34..eed5cbbcef 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -1,5 +1,5 @@ import {create, extent, range, sum, area as shapeArea} from "d3"; -import {identity, indexOf, maybeZ} from "../options.js"; +import {identity, indexOf, isNone, maybeZ, number} from "../options.js"; import {Mark} from "../plot.js"; import {qt} from "../stats.js"; import {applyDirectStyles, applyGroupedChannelStyles, applyIndirectStyles, applyTransform, groupZ, offset} from "../style.js"; @@ -30,9 +30,9 @@ class LinearRegression extends Mark { defaults ); this.z = z; - this.p = +p; + this.p = number(p); this.precision = +precision; - if (!(0 < this.p && this.p < 0.5)) throw new Error(`invalid p; not in (0, 0.5): ${p}`); + if (this.p !== null && !(0 < this.p && this.p < 0.5)) throw new Error(`invalid p; not in [0, 0.5): ${p}`); if (!(this.precision > 0)) throw new Error(`invalid precision: ${precision}`); } render(I, {x, y}, channels, dimensions) { @@ -44,11 +44,11 @@ class LinearRegression extends Mark { .call(g => g.selectAll() .data(Z ? groupZ(I, Z, this.z) : [I]) .enter() - .call(enter => enter.append("path") + .call(this.p && !isNone(this.fill) ? enter => enter.append("path") .attr("stroke", "none") .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, {...channels, stroke: null, strokeOpacity: null, strokeWidth: null}) - .attr("d", I => this._renderBand(I, X, Y))) + .attr("d", I => this._renderBand(I, X, Y)) : () => {}) .call(enter => enter.append("path") .attr("fill", "none") .call(applyDirectStyles, this) From 0f620709b050b1914f54735345e7447652f4390e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 18 Jun 2022 19:04:12 +0200 Subject: [PATCH 14/16] tentative documentation for Plot.linearRegression --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ img/linear-regression.png | Bin 0 -> 38030 bytes 2 files changed, 38 insertions(+) create mode 100644 img/linear-regression.png diff --git a/README.md b/README.md index 3fd5f50386..75dab80380 100644 --- a/README.md +++ b/README.md @@ -1132,6 +1132,44 @@ Plot.image(presidents, {x: "inauguration", y: "favorability", src: "portrait"}) Returns a new image with the given *data* and *options*. If neither the **x** nor **y** nor **frameAnchor** options are specified, *data* is assumed to be an array of pairs [[*x₀*, *y₀*], [*x₁*, *y₁*], [*x₂*, *y₂*], …] such that **x** = [*x₀*, *x₁*, *x₂*, …] and **y** = [*y₀*, *y₁*, *y₂*, …]. + +### Linear regression + +[a scatterplot of penguin culmens, showing the length and depth of several species, with linear regression models by species and for the whole population, illustrating Simpson’s paradox](https://observablehq.com/@observablehq/plot-linear-regression) + +[Source](./src/marks/linearRegression.js) · [Examples](https://observablehq.com/@observablehq/plot-linear-regression) · Draws linear regression plots with confidence bands. + +The linear regression mark is a composite mark consisting of two marks: + +* a [line](#line) representing the estimated relation between the dependent variable and the independent variable +* an [area](#area) representing the band where the line lay with the given level of confidence. + +Multiple series can be defined by specifying the *z*, *fill*, or *stroke* channel. + +The given *options* are passed through to these underlying marks, with the exception of the following options: + +* **stroke** - the stroke color of the regression line; defaults to *currentColor* +* **fill** - the fill color of the confidence band; defaults to the line’s *stroke* +* **fillOpacity** - the fill opacity of the confidence band, defaults to 0.1 +* **p** - the probability that the band ……… ; set p=null to ignore the band +* **precision** - the distance (in pixels) between samples of the confidence interval, defaults to 4 + +#### Plot.linearRegressionX(*data*, *options*) + +```js +Plot.linearRegressionX(simpsons.map(d => d.imdb_rating)) +``` + +Returns a linear regression mark where *x* is the dependent variable, and *y* the independent variable. + +#### Plot.linearRegressionY(*data*, *options*) + +```js +Plot.linearRegressionY(simpsons.map(d => d.imdb_rating)) +``` + +Returns a linear regression mark where *y* is the dependent variable, and *x* the independent variable. + ### Line [a line chart](https://observablehq.com/@observablehq/plot-line) diff --git a/img/linear-regression.png b/img/linear-regression.png new file mode 100644 index 0000000000000000000000000000000000000000..521f6844f1d119f6f2c163138836be1ea78d6717 GIT binary patch literal 38030 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 literal 0 HcmV?d00001 From 273a5339672bd2d0a7b941319bd206e2ad75b6ae Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 20 Jun 2022 08:47:43 -0400 Subject: [PATCH 15/16] fix band-line order --- src/marks/linearRegression.js | 18 +++++++++++------- test/output/linearRegressionPenguins.svg | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index eed5cbbcef..af6362f837 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -1,4 +1,4 @@ -import {create, extent, range, sum, area as shapeArea} from "d3"; +import {create, extent, range, sum, area as shapeArea, namespaces} from "d3"; import {identity, indexOf, isNone, maybeZ, number} from "../options.js"; import {Mark} from "../plot.js"; import {qt} from "../stats.js"; @@ -44,20 +44,24 @@ class LinearRegression extends Mark { .call(g => g.selectAll() .data(Z ? groupZ(I, Z, this.z) : [I]) .enter() - .call(this.p && !isNone(this.fill) ? enter => enter.append("path") - .attr("stroke", "none") - .call(applyDirectStyles, this) - .call(applyGroupedChannelStyles, this, {...channels, stroke: null, strokeOpacity: null, strokeWidth: null}) - .attr("d", I => this._renderBand(I, X, Y)) : () => {}) .call(enter => enter.append("path") .attr("fill", "none") .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, {...channels, fill: null, fillOpacity: null}) - .attr("d", I => this._renderLine(I, X, Y)))) + .attr("d", I => this._renderLine(I, X, Y)) + .call(this.p && !isNone(this.fill) ? path => path.select(pathBefore) + .attr("stroke", "none") + .call(applyDirectStyles, this) + .call(applyGroupedChannelStyles, this, {...channels, stroke: null, strokeOpacity: null, strokeWidth: null}) + .attr("d", I => this._renderBand(I, X, Y)) : () => {}))) .node(); } } +function pathBefore() { + return this.parentNode.insertBefore(this.ownerDocument.createElementNS(namespaces.svg, "path"), this); +} + class LinearRegressionX extends LinearRegression { constructor(data, options) { super(data, options); diff --git a/test/output/linearRegressionPenguins.svg b/test/output/linearRegressionPenguins.svg index 630cafb314..53959fc33c 100644 --- a/test/output/linearRegressionPenguins.svg +++ b/test/output/linearRegressionPenguins.svg @@ -415,10 +415,10 @@ - - + + From 9e51399274e4f899ee0497b41a58992e7a422335 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Mon, 20 Jun 2022 09:26:18 -0400 Subject: [PATCH 16/16] ci, not p --- README.md | 25 ++--- src/marks/linearRegression.js | 24 ++--- test/data/README.md | 4 + test/data/mtcars.csv | 33 ++++++ test/output/linearRegressionCars.svg | 2 +- test/output/linearRegressionMtcars.svg | 125 +++++++++++++++++++++++ test/output/linearRegressionPenguins.svg | 8 +- test/plots/index.js | 1 + test/plots/linear-regression-cars.js | 2 +- test/plots/linear-regression-mtcars.js | 13 +++ 10 files changed, 204 insertions(+), 33 deletions(-) create mode 100644 test/data/mtcars.csv create mode 100644 test/output/linearRegressionMtcars.svg create mode 100644 test/plots/linear-regression-mtcars.js diff --git a/README.md b/README.md index 75dab80380..9dc70aa690 100644 --- a/README.md +++ b/README.md @@ -1137,38 +1137,33 @@ Returns a new image with the given *data* and *options*. If neither the **x** no [a scatterplot of penguin culmens, showing the length and depth of several species, with linear regression models by species and for the whole population, illustrating Simpson’s paradox](https://observablehq.com/@observablehq/plot-linear-regression) -[Source](./src/marks/linearRegression.js) · [Examples](https://observablehq.com/@observablehq/plot-linear-regression) · Draws linear regression plots with confidence bands. - -The linear regression mark is a composite mark consisting of two marks: - -* a [line](#line) representing the estimated relation between the dependent variable and the independent variable -* an [area](#area) representing the band where the line lay with the given level of confidence. - -Multiple series can be defined by specifying the *z*, *fill*, or *stroke* channel. +[Source](./src/marks/linearRegression.js) · [Examples](https://observablehq.com/@observablehq/plot-linear-regression) · Draws [linear regression](https://en.wikipedia.org/wiki/Linear_regression) lines with confidence bands, representing the estimated relation of a dependent variable (typically *y*) on an independent variable (typically *x*). The linear regression line is fit using the [least squares](https://en.wikipedia.org/wiki/Least_squares) approach. See Torben Jansen’s [“Linear regression with confidence bands”](https://observablehq.com/@toja/linear-regression-with-confidence-bands) and [this StatExchange question](https://stats.stackexchange.com/questions/101318/understanding-shape-and-calculation-of-confidence-bands-in-linear-regression) for details on the confidence interval calculation. The given *options* are passed through to these underlying marks, with the exception of the following options: * **stroke** - the stroke color of the regression line; defaults to *currentColor* * **fill** - the fill color of the confidence band; defaults to the line’s *stroke* -* **fillOpacity** - the fill opacity of the confidence band, defaults to 0.1 -* **p** - the probability that the band ……… ; set p=null to ignore the band -* **precision** - the distance (in pixels) between samples of the confidence interval, defaults to 4 +* **fillOpacity** - the fill opacity of the confidence band; defaults to 0.1 +* **ci** - the confidence interval in [0, 1), or 0 to hide bands; defaults to 0.95 +* **precision** - the distance (in pixels) between samples of the confidence band; defaults to 4 + +Multiple regressions can be defined by specifying the *z*, *fill*, or *stroke* channel. #### Plot.linearRegressionX(*data*, *options*) ```js -Plot.linearRegressionX(simpsons.map(d => d.imdb_rating)) +Plot.linearRegressionX(mtcars, {y: "wt", x: "hp"}) ``` -Returns a linear regression mark where *x* is the dependent variable, and *y* the independent variable. +Returns a linear regression mark where *x* is the dependent variable and *y* is the independent variable. #### Plot.linearRegressionY(*data*, *options*) ```js -Plot.linearRegressionY(simpsons.map(d => d.imdb_rating)) +Plot.linearRegressionY(mtcars, {x: "wt", y: "hp"}) ``` -Returns a linear regression mark where *y* is the dependent variable, and *x* the independent variable. +Returns a linear regression mark where *y* is the dependent variable and *x* is the independent variable. ### Line diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index af6362f837..21931fbf9c 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -1,5 +1,5 @@ import {create, extent, range, sum, area as shapeArea, namespaces} from "d3"; -import {identity, indexOf, isNone, maybeZ, number} from "../options.js"; +import {identity, indexOf, isNone, isNoneish, maybeZ} from "../options.js"; import {Mark} from "../plot.js"; import {qt} from "../stats.js"; import {applyDirectStyles, applyGroupedChannelStyles, applyIndirectStyles, applyTransform, groupZ, offset} from "../style.js"; @@ -18,7 +18,7 @@ const defaults = { class LinearRegression extends Mark { constructor(data, options = {}) { - const {x, y, z, p = 0.05, precision = 4} = options; + const {x, y, z, ci = 0.95, precision = 4} = options; super( data, [ @@ -30,14 +30,14 @@ class LinearRegression extends Mark { defaults ); this.z = z; - this.p = number(p); + this.ci = +ci; this.precision = +precision; - if (this.p !== null && !(0 < this.p && this.p < 0.5)) throw new Error(`invalid p; not in [0, 0.5): ${p}`); + if (!(0 <= this.ci && this.ci < 1)) throw new Error(`invalid ci; not in [0, 1): ${ci}`); if (!(this.precision > 0)) throw new Error(`invalid precision: ${precision}`); } render(I, {x, y}, channels, dimensions) { const {x: X, y: Y, z: Z} = channels; - const {dx, dy} = this; + const {dx, dy, ci} = this; return create("svg:g") .call(applyIndirectStyles, this, dimensions) .call(applyTransform, x, y, offset + dx, offset + dy) @@ -49,7 +49,7 @@ class LinearRegression extends Mark { .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, {...channels, fill: null, fillOpacity: null}) .attr("d", I => this._renderLine(I, X, Y)) - .call(this.p && !isNone(this.fill) ? path => path.select(pathBefore) + .call(ci && !isNone(this.fill) ? path => path.select(pathBefore) .attr("stroke", "none") .call(applyDirectStyles, this) .call(applyGroupedChannelStyles, this, {...channels, stroke: null, strokeOpacity: null, strokeWidth: null}) @@ -67,10 +67,10 @@ class LinearRegressionX extends LinearRegression { super(data, options); } _renderBand(I, X, Y) { - const {p, precision} = this; + const {ci, precision} = this; const [y1, y2] = extent(I, i => Y[i]); const f = linearRegressionF(I, Y, X); - const g = confidenceIntervalF(I, Y, X, p, f); + const g = confidenceIntervalF(I, Y, X, (1 - ci) / 2, f); return shapeArea() .y(y => y) .x0(y => g(y, -1)) @@ -89,10 +89,10 @@ class LinearRegressionY extends LinearRegression { super(data, options); } _renderBand(I, X, Y) { - const {p, precision} = this; + const {ci, precision} = this; const [x1, x2] = extent(I, i => X[i]); const f = linearRegressionF(I, X, Y); - const g = confidenceIntervalF(I, X, Y, p, f); + const g = confidenceIntervalF(I, X, Y, (1 - ci) / 2, f); return shapeArea() .x(x => x) .y0(x => g(x, -1)) @@ -106,11 +106,11 @@ class LinearRegressionY extends LinearRegression { } } -export function linearRegressionX(data, {y = indexOf, x = identity, stroke, fill = stroke, ...options} = {}) { +export function linearRegressionX(data, {y = indexOf, x = identity, stroke, fill = isNoneish(stroke) ? "currentColor" : stroke, ...options} = {}) { return new LinearRegressionX(data, maybeDenseIntervalY({...options, x, y, fill, stroke})); } -export function linearRegressionY(data, {x = indexOf, y = identity, stroke, fill = stroke, ...options} = {}) { +export function linearRegressionY(data, {x = indexOf, y = identity, stroke, fill = isNoneish(stroke) ? "currentColor" : stroke, ...options} = {}) { return new LinearRegressionY(data, maybeDenseIntervalX({...options, x, y, fill, stroke})); } diff --git a/test/data/README.md b/test/data/README.md index c615d9b825..4fd08ec4a8 100644 --- a/test/data/README.md +++ b/test/data/README.md @@ -71,6 +71,10 @@ https://www.metoffice.gov.uk/hadobs/hadcrut4/data/current/series_format.html The New York Times https://www.nytimes.com/2019/12/02/upshot/wealth-poverty-divide-american-cities.html +## mtcars.csv +1974 *Motor Trend* US magazine +https://www.rdocumentation.org/packages/datasets/versions/3.6.2/topics/mtcars + ## moby-dick-chapter-1.txt *Moby Dick; or The Whale* by Herman Melville https://www.gutenberg.org/files/2701/2701-h/2701-h.htm diff --git a/test/data/mtcars.csv b/test/data/mtcars.csv new file mode 100644 index 0000000000..b600096334 --- /dev/null +++ b/test/data/mtcars.csv @@ -0,0 +1,33 @@ +name,mpg,cyl,disp,hp,drat,wt,qsec,vs,am,gear,carb +Mazda RX4,21,6,160,110,3.9,2.62,16.46,0,1,4,4 +Mazda RX4 Wag,21,6,160,110,3.9,2.875,17.02,0,1,4,4 +Datsun 710,22.8,4,108,93,3.85,2.32,18.61,1,1,4,1 +Hornet 4 Drive,21.4,6,258,110,3.08,3.215,19.44,1,0,3,1 +Hornet Sportabout,18.7,8,360,175,3.15,3.44,17.02,0,0,3,2 +Valiant,18.1,6,225,105,2.76,3.46,20.22,1,0,3,1 +Duster 360,14.3,8,360,245,3.21,3.57,15.84,0,0,3,4 +Merc 240D,24.4,4,146.7,62,3.69,3.19,20,1,0,4,2 +Merc 230,22.8,4,140.8,95,3.92,3.15,22.9,1,0,4,2 +Merc 280,19.2,6,167.6,123,3.92,3.44,18.3,1,0,4,4 +Merc 280C,17.8,6,167.6,123,3.92,3.44,18.9,1,0,4,4 +Merc 450SE,16.4,8,275.8,180,3.07,4.07,17.4,0,0,3,3 +Merc 450SL,17.3,8,275.8,180,3.07,3.73,17.6,0,0,3,3 +Merc 450SLC,15.2,8,275.8,180,3.07,3.78,18,0,0,3,3 +Cadillac Fleetwood,10.4,8,472,205,2.93,5.25,17.98,0,0,3,4 +Lincoln Continental,10.4,8,460,215,3,5.424,17.82,0,0,3,4 +Chrysler Imperial,14.7,8,440,230,3.23,5.345,17.42,0,0,3,4 +Fiat 128,32.4,4,78.7,66,4.08,2.2,19.47,1,1,4,1 +Honda Civic,30.4,4,75.7,52,4.93,1.615,18.52,1,1,4,2 +Toyota Corolla,33.9,4,71.1,65,4.22,1.835,19.9,1,1,4,1 +Toyota Corona,21.5,4,120.1,97,3.7,2.465,20.01,1,0,3,1 +Dodge Challenger,15.5,8,318,150,2.76,3.52,16.87,0,0,3,2 +AMC Javelin,15.2,8,304,150,3.15,3.435,17.3,0,0,3,2 +Camaro Z28,13.3,8,350,245,3.73,3.84,15.41,0,0,3,4 +Pontiac Firebird,19.2,8,400,175,3.08,3.845,17.05,0,0,3,2 +Fiat X1-9,27.3,4,79,66,4.08,1.935,18.9,1,1,4,1 +Porsche 914-2,26,4,120.3,91,4.43,2.14,16.7,0,1,5,2 +Lotus Europa,30.4,4,95.1,113,3.77,1.513,16.9,1,1,5,2 +Ford Pantera L,15.8,8,351,264,4.22,3.17,14.5,0,1,5,4 +Ferrari Dino,19.7,6,145,175,3.62,2.77,15.5,0,1,5,6 +Maserati Bora,15,8,301,335,3.54,3.57,14.6,0,1,5,8 +Volvo 142E,21.4,4,121,109,4.11,2.78,18.6,1,1,4,2 \ No newline at end of file diff --git a/test/output/linearRegressionCars.svg b/test/output/linearRegressionCars.svg index a591f6ff4e..87623e8661 100644 --- a/test/output/linearRegressionCars.svg +++ b/test/output/linearRegressionCars.svg @@ -463,7 +463,7 @@ - + \ No newline at end of file diff --git a/test/output/linearRegressionMtcars.svg b/test/output/linearRegressionMtcars.svg new file mode 100644 index 0000000000..0a6d91be2a --- /dev/null +++ b/test/output/linearRegressionMtcars.svg @@ -0,0 +1,125 @@ + + + + + 60 + + + 80 + + + 100 + + + 120 + + + 140 + + + 160 + + + 180 + + + 200 + + + 220 + + + 240 + + + 260 + + + 280 + + + 300 + + + 320 + ↑ hp + + + + 2.0 + + + 2.5 + + + 3.0 + + + 3.5 + + + 4.0 + + + 4.5 + + + 5.0 + wt → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/linearRegressionPenguins.svg b/test/output/linearRegressionPenguins.svg index 53959fc33c..0c8cbb1795 100644 --- a/test/output/linearRegressionPenguins.svg +++ b/test/output/linearRegressionPenguins.svg @@ -414,15 +414,15 @@ - + - + - + - + \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index d5294411cc..f755502c44 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -103,6 +103,7 @@ export {default as letterFrequencyDot} from "./letter-frequency-dot.js"; export {default as letterFrequencyLollipop} from "./letter-frequency-lollipop.js"; export {default as letterFrequencyWheel} from "./letter-frequency-wheel.js"; export {default as linearRegressionCars} from "./linear-regression-cars.js"; +export {default as linearRegressionMtcars} from "./linear-regression-mtcars.js"; export {default as linearRegressionPenguins} from "./linear-regression-penguins.js"; export {default as likertSurvey} from "./likert-survey.js"; export {default as logDegenerate} from "./log-degenerate.js"; diff --git a/test/plots/linear-regression-cars.js b/test/plots/linear-regression-cars.js index 4a33d0fbf2..0f60cc04b6 100644 --- a/test/plots/linear-regression-cars.js +++ b/test/plots/linear-regression-cars.js @@ -6,7 +6,7 @@ export default async function () { return Plot.plot({ marks: [ Plot.dot(cars, {x: "weight (lb)", y: "economy (mpg)", r: 2}), - Plot.linearRegressionY(cars, {x: "weight (lb)", y: "economy (mpg)", p: 0.01}) + Plot.linearRegressionY(cars, {x: "weight (lb)", y: "economy (mpg)", ci: 0.99}) ] }); } diff --git a/test/plots/linear-regression-mtcars.js b/test/plots/linear-regression-mtcars.js new file mode 100644 index 0000000000..dc56731a88 --- /dev/null +++ b/test/plots/linear-regression-mtcars.js @@ -0,0 +1,13 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export default async function () { + const mtcars = await d3.csv("data/mtcars.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.dot(mtcars, {x: "wt", y: "hp", r: 2}), + Plot.linearRegressionY(mtcars, {x: "wt", y: "hp", stroke: null, ci: 0.8}), + Plot.linearRegressionY(mtcars, {x: "wt", y: "hp"}) + ] + }); +}