-
Notifications
You must be signed in to change notification settings - Fork 180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Added density reducer to bin/group/hexbin #2047
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking great!
Suggestions:
I think we can extend the concept to weighted density when the original channel is a value?
The documentation needs to clarify what the reducer does when you're not reducing y in binX or x in binY.
If we're binning in two dimensions, and using this reducer to inform r, then the "area" is defined by x and y and is not 1, and the results are comparable to what happens with hexbin.
The documentation should also clarify that this normalization is applied on each series (all values with the same z in the same facet).
src/transforms/group.js
Outdated
if ("y2" in extent) proportion /= extent.y2 - extent.y1; | ||
if ("x2" in extent) proportion /= extent.x2 - extent.x1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if ("y2" in extent) proportion /= extent.y2 - extent.y1; | |
if ("x2" in extent) proportion /= extent.x2 - extent.x1; | |
if ("y2" in extent && !("x2" in extent)) proportion /= extent.y2 - extent.y1; | |
else if ("x2" in extent && !("y2" in extent)) proportion /= extent.x2 - extent.x1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also need to check whether value is null (like in reduceProportion), and if that is not the case, use a weighted formula.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think when binning on both x, y
it can also make sense to normalise to have total area 1, see example notebook where the fill
is the density which is compared to a Gaussian distribution. Without ensuring this normalisation, the density goes to zero with the bin size. For this reason I think it makes more sense to do
if ("y2" in extent) proportion /= extent.y2 - extent.y1;
if ("x2" in extent) proportion /= extent.x2 - extent.x1;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you share the example?
If we want this to be consistent, then we should also normalize by hexagon area (binWidth**2) when doing hexbin?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, it should be public now.
Regarding hexbin: Probably that would be consistent, although the right thing to divide by would be the area of the bin in the x-y-variable scale. However, binWidth
is in pixel scale, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doing it consistently for hexbin would require to somehow expose the x,y-radii of the hexagonal cells. Basically, this would amount to changing hbin
in src/transforms/hexbin.js to (addition highlighted with //NEW)
function hbin(data, I, X, Y, dx, sx, sy) {
// ...
if (bin === undefined) {
const x = (pi + (pj & 1) / 2) * dx + ox,
y = pj * dy + oy;
bin = {
index: [],
extent: {
data,
x,
y,
rx: sx.invert(x + dx / 2) - sx.invert(x), //NEW
ry: sy.invert(y - dy / 2) - sy.invert(y) //NEW
}
};
bins.set(key, bin);
}
bin.index.push(i);
}
return bins.values();
}
and calling it with hbin(data, I, X, Y, binWidth, scales.x, scales.y)
.
Then the correct normalisation in reduceDensity
would be
if ("rx" in extent) proportion /= 3 * extent.rx * extent.ry;
according to the area of a (hexagon)[https://en.wikipedia.org/wiki/Hexagon]
However, this adds some computational overhead by computing this for each hexagonal cell even when the values rx,ry
are not needed. For linear scales rx, ry is the same for every cell but I am not sure whether the scale functions somehow expose the fact that they are linear if this is the case
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for researching this. Note that in fact we can't guarantee anything about the x and y scales (they might not be invertible, and they might even not exist, for example when we use a projection).
This makes me think that the normalization by x2-x1 that we do here does in fact not guarantee that the scaling is correct—it will only work if x is linear (or non-existent, because identity is linear). That's the main use case we wanted to solve, but it doesn't generalize: if your histogram's base is against a log scale, the results will be inconsistent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm I think the issue is that this density reduction makes sense before the scales are applied, but probably not afterwards. As far as I can see this is different between bin
and hexbin
.
What I mean is that for bin
the density reducer as a concept also makes sense e.g. for logarithmic scales, see an example here https://observablehq.com/d/0ef7cf5eae234601 (sorry for the dots, I don't know how to produce bars in log scale). Here the reducer is applied before the scaling. The scale just influences the final appearance of the plot. Hence the reducer does not guarantee that the area under the curve is 1 after the scaling, but before the scaling. But I would argue that this is the desired behaviour because the goal is to compare the binned density with some real normalised density, irrespective of the plot scale.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would argue the opposite :) If my data warrants using a log scale, it's because the thing that I am interested in analyzing is the log of the value. Scaling is not meant to make things pretty, but to connect to a deeper “truth” about the data.
This is what we do with the linear regression mark: if you use log scales, you get a linear model of the log of the values, which is equivalent to a log regression (or log-log regression if both scales are logarithmic).
To a certain extent, this is also what we do with hexbins: these hexagons are on the scaled values, and do not represent anything meaningful in data space (unless you use similar scales, for example with an aspectRatio of 1).
Linear binning is very different, as it operates in data space: the x/y bins are "rectangles", not squares. And if you want homogenous rectangles you need to manually adapt the bins so that they are equally-spaced (e.g. 1,2,4,8,16,32,64…), this is not provided by the defaults.
And, once you do that, the division by the extent of each bin should end up with the correct result? Here's what it would look like:
Plot.plot({
marks: [
Plot.rectY(
pts,
Plot.binX(
{ y: "density" },
{ x: "value", fill: "lambda", thresholds: d3.ticks(-3, 10, 20).map(d => 2**d), opacity: 0.5 }
)
),
Plot.line(density, { x: "x", y: "rho", stroke: "lambda" })
],
x: { type: "log" },
title: "Log Scale",
grid: true,
color: { legend: true, type: "categorical" }
})
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that the log scale is used to reveals some truth about the data like exponential scaling.
What I meant to argue is that the reducer I am suggesting, i.e. "density of values normalised to have total area 1" should be normalised such that the area measured before the scaling and not after the scaling is equal to 1.
In my notebook I now have the same chart in three coordinate systems. Here all three charts use the "density" reducer as implemented in by pull request, i.e. with proportion /= extent.x2 - extent.x1
which is before applying the log-scale. Note that the areas look differently big, in the first plot yellow has a bigger area than blue, while in the second plot it is the other way round. However, in the space of real coordinates all areas have exactly the same size of 1, and this is what I would like the ensure.
In the applications I have in mind I plot some histogram of values (in various scalings), and compare it to the PDF of some probability measure (like exponential, Gaussian, Weibull). For this to make sense it is important that the "area under the curve" measured in the "real" coordinate system (not e.g. the log-coordinate system) is equal to 1. This is for instance also how it is done in matplotlib with plt.hist(Y, bins=100, density=True, log=True)
.
I understand that this becomes a bit strange for hexbin
because hexbin
does the binning with the scaled values other than bin
which (by default) does the binning on the raw values (allowing for setting custom thresholds which can be chosen to match the scale). In order for a density reducer to make sense for hexbin there would have to be a canonical way of determining the extent of each bin in the real coordinate space.
Co-authored-by: Philippe Rivière <fil@rezo.net>
@Fil thanks for the review, will look into it! One question regarding
What do you mean by this, basically like proportion-facet but per series? What is the expected output of something like
in this case? Is it the sum of the values in the bin for a given series, divided by the sum over the whole series, i.e.
or something else? I am not sure normalising by bin area makes sense in this case? |
Normalizing with weights is for example, when a data point is a city, and represents anywhere from 1,000 to 1 million inhabitants. You want the same chart as you would have if you had one point per inhabitant. |
This is a pull request addressing #1940, see also the discussion in https://talk.observablehq.com/t/normalised-histogram-in-observable-plot/8576
Basically I added a new
reduceDensity
reducer which computes the density per series/facet. This is slightly different from thereduceProportion
(with or without thefacet
scope) reducer (when not supplied with a value)reduceDensity
does normalise by the bin sizereduceDensity
normalises by group rather than globally or by facet.I forked the notebook https://observablehq.com/@fil/plot-normalized-histograms here https://observablehq.com/d/fb0d876105777d59 to illustrate the functionality. I also added a test plot in
test/plots/density-reducer.ts
The main change to existing code is that in
src/transforms/group.js
,src/transforms/bin.js
andsrc/transforms/hexbin.js
need to call the reducer scope on a per group level as inI noticed that this new
density
reducer can also be used as a replacement forproportion-facet
intest/plots/athletes-sport-weight.ts
test/plots/hexbin-r.ts
where (at least to me) it also makes sense semantically.
Among the tests this would leave
test/plots/penguin-species-island-relative.ts
as the only place where
proportion-facet
is used. Heredensity
does not makes sense on a group level due to the grouping byfill
.One could also consider a more customisable reducer allowing to specify the normalisation scope, but I could not come up with a satisfying syntax. Something like
feels a bit too verbose given that most reducers do not have/need any scope.