Repository navigation
ENH: stats.circmedian: function for calculating the circular median - #24166
Conversation
|
I concur in the choice of
|
| # | ||
| # The purpose of this test is not to show that circmean() is | ||
| # accurate in the last digit for certain input, because this is | ||
| # neither guaranteed not particularly useful. Rather, it is a |
There was a problem hiding this comment.
I guess this is just "migrated" code, but noticed "not -> nor," I think, while catching up on my GH notifications
There was a problem hiding this comment.
I've un-migrated the code.
fancidev
left a comment
There was a problem hiding this comment.
arc-distance looks like a natural choice to me.
Why geometric median seems not widely used might be because it's more costly to compute -- Reference [3] claims 80x longer computation time to compute the geometric median than arc distance median while giving qualitatively similar results.
Out of curiosity, it could be helpful to mention that the time complexity to compute the median is
| `circmedian` always finds all points *among the observations* (and their antipodes, | ||
| in the ``'bisecting'`` case) that satisfy the chosen definition of a circular median | ||
| and returns their circular mean. If the circular mean is poorly defined (i.e. the | ||
| circular variance is with a small tolerance of ``1.0``), `circmedian` returns NaN. |
There was a problem hiding this comment.
If I read correctly, four cases are discussed here. Take the 'arc-distance'. Let
- circular median = circular mean of
$A$ ; - circular median
$\in S$ if$|S|$ is odd; - circular median
$\in M$ if$|S|$ is even; - circular median = circular mean of
$S$ .
To me (1) seems the "nicest", but I don't know how to compute it because I don't know how
| if convention == 'arc-distance': | ||
| mad = _circmeandev(sample, sample, high=high, low=low) | ||
| min_mad = xp.min(mad, axis=-1, keepdims=True) | ||
| i = (mad - min_mad) < tol*min_mad |
There was a problem hiding this comment.
Does this work if the sample contains a single element?
There was a problem hiding this comment.
I should have used <=. I can make that change and add a test when there is something else to do.
There was a problem hiding this comment.
... the time complexity to compute the median is
$O(n^2)$ . I don't foresee this to be a practical concern at all, but I think improving the complexity could be an interesting theoretical question for the future.
I considered that but wanted to start with a simple correct implementation. I believe there is a vectorized, sort, cumsum, searchsorted, and take_along_axis come to mind) but that is left for a follow-up. Then, it will be useful to have the simple implementation to test against.
| if convention == 'arc-distance': | ||
| mad = _circmeandev(sample, sample, high=high, low=low) | ||
| min_mad = xp.min(mad, axis=-1, keepdims=True) | ||
| i = (mad - min_mad) < tol*min_mad |
There was a problem hiding this comment.
I should have used <=. I can make that change and add a test when there is something else to do.
|
I re-read two articles, 1 and 2, to look for results on the structure of angles that minimize the total arc length. It is shown in [1] (Lemma 5) that there exists at least one sample point that is a minimizer. In addition, if the sample size is odd, then a minimizer is necessarily a sample point ([1], Theorem 4). If the sample size is even, the results seem less clean. If the minimizers form a unique arc of length less than This PR returns the circular mean of sample point minimizers. I couldn't find a result stating that the circular mean of sample point minimizers remains a minimizer if it is well-defined. However, Footnotes
|
I think "variant" might be more accurate here, since "arc-distance" and "bisecting" correspond to quite different definitions of median, and there are yet more variants (such as the "separable" and the "geometric" medians). "Convention" might be more appropriate for things that differ only in (constant) offset and scale, such as kg vs pound, 0- vs 1-based index, Fisher vs Pearson kurtosis, left-hand vs right-hand, etc. Here the various definitions of median all try to convey the "preferred direction" but are otherwise loosely related apparently. Separately, the handling of non-unique "bisecting" median seems tricky. For example, by eye-balling the illustrations of Figure 2 in 1, it seems doubtful that the circular mean of bisecting sample points remain a bisecting point. And I didn't find any source that elaborate on the choice of a bisecting median when the candidates are non-unique. Since the "bisecting" median seems not widely supported in other statistical software, maybe would it be easier to only implement the "arc-distance" variant for now and keep the "variant" argument for future extension? A third point of thought is about how to document the return value of the function. I think it could make life easier for the user if they are told what to expect for the return value instead of how the return value is computed. If the user asks "what is returned if the minimizer is not unique?", the current documentation would tell them "the circular mean of sample minimizers or NAN if such mean is not well-defined", which is a faithful description of the implementation but might arguably fall short of answering the question. I wonder if it's correct to assert that the return value (of the current implementation) is the mid point of all minimizers if they form a (single) arc of length less than Footnotes
|
|
Using a semi-exhaustive approach, I found a curious example where the current implementation (as well as that of R's The sample is 1, 2, 5, 9, 9 with a period of 12. (Think of those as hours on a 12-hour clock.) Using a semi-exhaustive search, the arg min of total arc distance is found to be the set This example shows that taking the circular mean of sample point minimisers may yield an answer that neither minimises the total arc distance nor bisects the data points, and hence is not a median. If we want the returned value to have the median property, a possible workaround is to return an arbitrary sample point minimiser when the sample size is odd. Alternatively, we could keep the current implementation and put a note in the documentation that the return value might not be a minimiser or bisector. Script to discover curious examplesThe semi-exhaustive search works as follows. Suppose that import numpy as np
def arc_distance_median(x: np.array, period: float) -> list[tuple[float, float]]:
"""Given an array `x` of angular measurements with period `period`, return
a list of disjoint, closed intervals such that every direction within
these intervals minimize the total arc distance to the measurements."""
x = np.asarray(x)
if len(x) == 0:
raise [(0, period)]
y = np.unique(x % period)
if len(y) == 1:
return [(y[0], y[0])]
y = np.unique(np.concat([y, y + period / 2]) % period) # include antipodals
y = np.unique(
np.concat([
y,
(y[1:] + y[:-1]) / 2,
[(y[-1] + y[0] + period) / 2 % period],
])
) # include mid points
d = np.abs(y[:, np.newaxis] - x) % period
d = np.where(d > period / 2, period - d, d)
d = np.sum(d, axis=-1)
dmin = min(d)
if np.all(d == dmin):
return [(0, period)]
intervals = []
i0 = np.argmax(d > dmin)
interval = None
m = len(y)
for j in range(m):
i = (i0 + j + 1) % len(d)
if d[i] == dmin:
if interval is None:
interval = (float(y[i]), float(y[i]))
else:
interval = (interval[0], float(y[i]))
elif interval is not None:
intervals.append(interval)
interval = None
return intervals
def simulate(period: int, n: int):
x = np.random.randint(0, period, size=n)
med = arc_distance_median(x, period)
assert len(med) >= 1
if med == [(0, period)]: # every point is a median
print(f"{x=}, {med=}")
elif n % 2 == 1 and not (len(med) == 1 and med[0][0] == med[0][1]):
# odd sample; non-unique minimizer, or interval minimizer (should not happen)
print(f"{x=}, {med=}")
elif n % 2 == 0 and len(med) > 1:
# even sample; non-unique interval
print(f"{x=}, {med=}")
else:
pass # nothing special to print
def main():
np.random.seed(1234)
for _ in range(100):
simulate(period=12, n=5)
# simulate(period=13, n=6)
if __name__ == "__main__":
main()Output: R script that produces the same result as the PRlibrary(circular)
median(circular(c(1, 2, 5, 9, 9) * 30, units = "degrees"))Output: |
|
Let me recap the discussion above and then propose a solution. The goal is to compute a sample circular median, an angle that minimizes the mean circular deviation. If the sample is large or drawn from a continuous distribution, it is almost certain that the sample circular median is unique. The question is what to do for a small sample drawn from a discrete distribution, which could be relevant e.g. if the sample data are binned. When the minimizer for mean circular deviation is not unique, most (all?) statistical software and two academic papers choose to take the circular average of minimizers in the sample as the circular median. However, there are (non-contrived) examples where such an average do not minimize the mean circular deviation. Other academic literature note the possibility of multiple median, and one proposes to choose an arbitrary minimizer as the circular median. Based on the above, I'd propose the following for the circmedian function:
Basically, we offer weak but sure guarantee in the specification, and make a best effort in the implementation. The weak guarantee may be strengthened later if there turns out to be a need to (which I doubt). This also eliminates the need to deal with epsilon in the implementation. (After all, the return value is extremely sensitive to numerical error when the circular mean is near zero, and it is therefore near impossible to guarantee the accuracy of the returned angle anyway. On the other hand, it's relatively easy to guarantee the optimality of the returned mean circular deviation.) |
|
Are you happy with this PR @mdhaber ? Then I would take a detailed look. |
|
Yup, I'm happy with it. I didn't see anything in the other comments that I wanted to follow up on. Thanks! I've planned to take biteopt for a spin at some point. Can't review/merge, but I can add a +1. |
|
To simplify review (and merge conflict resolution), I moved the existing circular/directional stats functions back to |
[lint only]
|
GPU test failure is in main, so I pushed a lint-only commit. |
dschmitz89
left a comment
There was a problem hiding this comment.
Overall, this looks quite good. Test coverage is comprehensive and the tests are well researched examples from the literature. One think to add would be a bit more input validation tests: what happens if low > high for example or we have an empty sample etc.?
|
Thanks @dschmitz89! At some point, this new input validation should probably be shared with the other circular statistics functions - maybe when they are moved into this |
|
Thanks @dschmitz89!
Thought I might also try to interest you in Maritz-Jarrett standard errors (gh-24757). |
Reference issue
Closes gh-6644
Toward astropy/astropy#14427
Supersedes astropy/astropy#17502
What does this implement/fix?
This PR add
stats.circmedian, a function for calculating the circular median.As noted in the reference issues, there are multiple definitions for "circular median" in the literature, and the common definitions do not always produce a unique value for a given data set1. To address this, the documentation condenses a pretty thorough literature review and explains the implementation choices. It so happened that my choices for the default agreed with the most popular circular statistics software - R's
circularpackagemedianfunction, Matlab's CircStat librarycirc_medianfunction, and the defaults ofpycircstat2's ``circ_median` - so I think that's on pretty firm ground.There is a little less clarity about the (imperfectly named2)
'bisecting'convention. Even though the R and Matlab libraries don't include it, it may be worth including because this seems to be the original definition (e.g. Mardia Statistics of Directional Data 1972). When there is a unique bisecting median for data with an odd number of observations or two bisecting medians for data with an even number of observations, the definition is fairly straightforward, and this implementation agrees with thecountconvention ofpycircstat2. There's some disagreement about what to do in more general cases, which I've indicated byxfailing some tests for now, but the documentation explains our procedure, which I think is easy to defend.Additional information
The first commit migrates circular/directional statistics to dedicated files. I can make that a separate PR if desired.
I'm open to argument names other than
'convention'(e.g.kind,variant,definition), butmethodis more commonly used when there are multiple ways of computing/approximating a quantity with a single definition.conventionis what we used to distinguish between Pearson and Fisher kurtosis in the new RV infrastructure.I show that the community has converged on the "arc-distance" definition, but I don't understand why the geometric median ("Normalized$L_1$ mean$ in Reference [3]) is not used. Considering that trigonometric moments and derived quantitites (circular mean, circular variance, etc.) are all defined extrinsically, why should the circular median be defined intrinsically? I'd like to add this as an option. Here's a prototype, although some refactoring would help share code:
Details
Footnotes
That said, they tend to agree exactly for data from unimodal, continuous distributions, when the idea of a median is most useful. ↩
Because an arc-distance median also has the bisecting property, even if it's not how it's computed. The distinguishing characteristic of a "bisecting" median is that the semicircle about it contains more data than the semicircle about its antipode. ↩