Skip to content

ENH: stats.circmedian: function for calculating the circular median - #24166

Merged
dschmitz89 merged 13 commits into
scipy:mainfrom
mdhaber:circmedian
Aug 23, 2026
Merged

dschmitz89 merged 13 commits into
scipy:mainfrom
mdhaber:circmedian

Conversation

@mdhaber

@mdhaber mdhaber commented Dec 16, 2025 •

Copy link
Copy Markdown
Contributor

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 circular package median function, Matlab's CircStat library circ_median function, and the defaults of pycircstat2'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 the count convention of pycircstat2. There's some disagreement about what to do in more general cases, which I've indicated by xfailing 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), but method is more commonly used when there are multiple ways of computing/approximating a quantity with a single definition. convention is 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
# method='geometric'
def _geometric_median(sample, *, high=2*math.pi, low=0, tol, xp):
    # Computes the geometric median with Weisfeld's algorithm
    # https://en.wikipedia.org/wiki/Geometric_median
    T = high - low
    two_pi = 2*xp.pi
    x = xp.exp(1j * two_pi/T * (sample - low))
    y = xp.mean(x, axis=-1, keepdims=True)
    delta = xp.full_like(y, xp.inf)
    while xp.any(delta > tol):
        distances = abs(x - y)
        yk = xp.sum(x / distances, axis=-1) / xp.sum(1 / distances, axis=-1)
        delta = abs(y - yk)
        y = yk
    y = xpx.at(y)[xp.abs(y) < tol].set(xp.nan)
    median = xp.atan2(xp.imag(y), xp.real(y)) * T / two_pi + low
    return median[()]

Footnotes

  1. That said, they tend to agree exactly for data from unimodal, continuous distributions, when the idea of a median is most useful. ↩

  2. 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. ↩

@mdhaber
mdhaber requested a review from rgommers as a code owner December 16, 2025 17:00
@mdhaber mdhaber added scipy.stats enhancement A new feature or improvement labels Dec 16, 2025
@github-actions github-actions Bot added the Meson Items related to the introduction of Meson as the new build system for SciPy label Dec 16, 2025
@mdhaber
mdhaber removed the request for review from rgommers December 16, 2025 17:00
@rkern

rkern commented Dec 16, 2025

Copy link
Copy Markdown
Member

I concur in the choice of arc-distance as the preferred default. It's the most convenient way to link to "medians" in other spaces (minimizing the L1 distance of deviations, or its equivalent in the space).

convention works for me.

Comment thread scipy/stats/tests/test_circstats.py Outdated
#
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is just "migrated" code, but noticed "not -> nor," I think, while catching up on my GH notifications

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've un-migrated the code.

@fancidev fancidev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 $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.

Comment thread scipy/stats/_circstats.py Resolved
Comment thread scipy/stats/_circstats.py Outdated
`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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I read correctly, four cases are discussed here. Take the 'arc-distance'. Let $A$ denote the set of all angles that minimise the total arc distance, $S$ the sample points that minimise the total arc distance, $M$ the "middle" of adjacent pairs of points in $S$. Then:

  1. circular median = circular mean of $A$;
  2. circular median $\in S$ if $|S|$ is odd;
  3. circular median $\in M$ if $|S|$ is even;
  4. 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 $A$ looks like. (4) seems the most practical. It might be the case that (1) and (4) are actually equivalent, but I cannot prove it nor provide a counterexample. Also, does (1) + $|S|$ odd imply (2)? Does (1) and $|S|$ even imply (3)? These are not important questions but just food for thought.

Comment thread scipy/stats/_circstats.py Outdated
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work if the sample contains a single element?

@mdhaber mdhaber Dec 17, 2025 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should have used <=. I can make that change and add a test when there is something else to do.

Comment thread scipy/stats/_circstats.py Resolved

@mdhaber mdhaber left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... 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, $O(n \log n)$ implementation in terms of Array API functions (a mess of 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.

Comment thread scipy/stats/_circstats.py Resolved
Comment thread scipy/stats/_circstats.py Outdated
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

@mdhaber mdhaber Dec 17, 2025 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should have used <=. I can make that change and add a test when there is something else to do.

Comment thread scipy/stats/_circstats.py Resolved
@fancidev

fancidev commented Dec 31, 2025 •

Copy link
Copy Markdown
Contributor

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 $pi$, it seems customary to take the mid-point of the arc as the circular median. Otherwise, [2] (Definition 4.1) proposes to pick an arbitrary sample point minimizer, say one with the smallest value.

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, this is probably true given several sources appear to do this. Update: This is false. See counterexample in the second comment below.

Footnotes

  1. M. Storath and A. Weinmann, “Fast Median Filtering for Phase or Orientation Data,” IEEE Trans. Pattern Anal. Mach. Intell., vol. 40, no. 3, pp. 639–652, Mar. 2018, doi: 10.1109/TPAMI.2017.2692779. ↩

  2. S. Purkayastha, “An almost sure representation of sample circular median,” Journal of Statistical Planning and Inference, vol. 46, no. 1, pp. 77–91, July 1995, doi: 10.1016/0378-3758(94)00092-A. ↩

@fancidev

fancidev commented Dec 31, 2025 •

Copy link
Copy Markdown
Contributor

I'm open to argument names other than 'convention' (e.g. kind, variant, definition), but method is more commonly used when there are multiple ways of computing/approximating a quantity with a single definition. convention is what we used to distinguish between Pearson and Fisher kurtosis in the new RV infrastructure.

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 $\pi$, and NAN otherwise. Update: It is not. See counterexample in the next comment.

Footnotes

  1. M. Storath and A. Weinmann, “Fast Median Filtering for Phase or Orientation Data,” IEEE Trans. Pattern Anal. Mach. Intell., vol. 40, no. 3, pp. 639–652, Mar. 2018, doi: 10.1109/TPAMI.2017.2692779. ↩

@fancidev

fancidev commented Jan 1, 2026 •

Copy link
Copy Markdown
Contributor

Using a semi-exhaustive approach, I found a curious example where the current implementation (as well as that of R's circular package) produces a circular median that neither minimises the total arc distance nor bisects the datapoints.

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 $\{1, 9\}$. The minimum total arc distance is 13. According to the current implementation, the circular mean of the sample subset $\{1, 9, 9\}$ is 10; the corresponding total arc distance is 14, larger than the minimum, and the diameter that connects 10 o'clock and 4 o'clock does not bisect the sample points (there are 3 points below and 2 points above).

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 examples

The semi-exhaustive search works as follows. Suppose that $\theta$ minimises the total arc distance and neither $\theta$ nor its antipode is in the sample. Then we may perturb $\theta$ a little bit without changing the total arc length. The perturbed angle, $\theta'$, also qualifies for an arc-distance median. We may keep perturbing $\theta$ until $\theta'$ or its antipode hit a sample point. Therefore, the candidates for median are the sample points ($A$), their antipodes ($B$), and the intervals formed by adjacent points in $A \cup B$. The arc_distance_median function below enumerates all these candidates to find the "arg min".

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:

x=array([0, 5, 1, 5, 9]), med=[(1.0, 1.0), (5.0, 5.0)]
x=array([10,  4,  2,  7,  9]), med=[(7.0, 7.0), (9.0, 9.0)]
x=array([ 7, 11,  7,  2,  9]), med=[(7.0, 7.0), (9.0, 9.0)]
x=array([0, 5, 4, 9, 2]), med=[(2.0, 2.0), (4.0, 4.0)]
x=array([9, 1, 3, 5, 9]), med=[(1.0, 1.0), (5.0, 5.0), (9.0, 9.0)]
x=array([6, 8, 6, 1, 9]), med=[(6.0, 6.0), (8.0, 8.0)]
x=array([ 8,  1,  9, 11,  4]), med=[(9.0, 9.0), (11.0, 11.0)]
x=array([6, 8, 9, 1, 5]), med=[(6.0, 6.0), (8.0, 8.0)]
x=array([ 5,  7, 10,  0,  0]), med=[(10.0, 10.0), (0.0, 0.0)]
x=array([ 6,  1,  3, 10,  5]), med=[(3.0, 3.0), (5.0, 5.0)]
x=array([ 2, 10,  6, 11,  6]), med=[(6.0, 6.0), (10.0, 10.0)]
x=array([5, 9, 1, 9, 2]), med=[(1.0, 1.0), (9.0, 9.0)]
R script that produces the same result as the PR
library(circular)
median(circular(c(1, 2, 5, 9, 9) * 30, units = "degrees"))

Output:

Circular Data: 
Type = angles 
Units = degrees 
Template = none 
Modulo = asis 
Zero = 0 
Rotation = counter 
[1] -60
attr(,"medians")
[1]  30 270

@fancidev

fancidev commented Jan 5, 2026 •

Copy link
Copy Markdown
Contributor

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:

  • Specification: Return an angle that minimizes the mean circular deviation. If such angles are not unique, an arbitrary minimizer is returned.

  • Implementation: If the sample size is odd and there is a unique minimizer, return that. If the sample size is even and there are exactly two minimizers in the sample and they are not antipodal, return their mid point. Otherwise, return the first minimizer in the sample.

  • Post condition (accuracy guarantee): The mean circular deviation from the returned angle is equal to the (true) minimum circular deviation from any angle up to numerical noise (of a few epsilon).

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.)

@mdhaber mdhaber removed the Meson Items related to the introduction of Meson as the new build system for SciPy label Jan 28, 2026
Comment thread scipy/stats/_circstats.py Resolved
Comment thread scipy/stats/_circstats.py Resolved
Comment thread scipy/stats/_circstats.py Resolved
@dschmitz89

Copy link
Copy Markdown
Contributor

Are you happy with this PR @mdhaber ? Then I would take a detailed look.

@mdhaber

mdhaber commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor Author

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.

Comment thread scipy/stats/_circstats.py Resolved
Comment thread scipy/stats/_circstats.py Resolved
Comment thread scipy/stats/_circstats.py Resolved
@mdhaber

mdhaber commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

To simplify review (and merge conflict resolution), I moved the existing circular/directional stats functions back to _morestats.py and kept circmedian in the new _circstats.py. The existing functions can be moved over in a separate PR.

@mdhaber

mdhaber commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

GPU test failure is in main, so I pushed a lint-only commit.

@dschmitz89 dschmitz89 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.?

@mdhaber

mdhaber commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

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 _circstats.py file.

@dschmitz89 dschmitz89 added this to the 2.0.0 milestone Aug 23, 2026
@dschmitz89
dschmitz89 merged commit 888eca9 into scipy:main Aug 23, 2026
55 of 56 checks passed
@github-actions github-actions Bot added the needs-release-note-decision A maintainer should decide if this PR requires a release note. label Aug 23, 2026
@lucascolley lucascolley added needs-release-note A maintainer should add a release note written by a reviewer/author to the wiki. and removed needs-release-note-decision A maintainer should decide if this PR requires a release note. labels Aug 23, 2026
@mdhaber

mdhaber commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @dschmitz89!
There are a number of follow-ups to consider. Would you be interested in reviewing any?

Thought I might also try to interest you in Maritz-Jarrett standard errors (gh-24757).
In any case, thanks for taking a look at this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement A new feature or improvement needs-release-note A maintainer should add a release note written by a reviewer/author to the wiki. scipy.stats

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Circular median

7 participants