Repository navigation
statistics.quantiles(exclusive) returns unsorted cut points for duplicate float-inexact data #150318
Description
Activity
- addedtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or errorstdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directory
on May 23, 2026 Hi. If you're not planning on opening a PR, I would love to work on this 😁
Is sorting really the right answer here?
do not always round to n*x, yielding a result one ULP off.
In practice, I don't think this would ever matter.
Is sorting really the right answer here?
I think it would be better to catch the condition here, otherwise it would sort unnecesarily
Is sorting really the right answer here?
I think it's a bad answer.
I think it would be better to catch the condition here
How about using different (equivalent for reals) expression:
interpolated = data[j-1] + (data[j] - data[j-1])*delta/n?
'inclusive' is not affected
I am getting the same error with the method inclusive.
assert r == sorted(r), (x, n, method, r) ^^^^^^^^^^^^^^ AssertionError: (3.141592653589793, 13, 'inclusive', [3.1415926535897936, 3.1415926535897927, 3.1415926535897936, 3.1415926535897936, 3.1415926535897936, 3.1415926535897936, 3.1415926535897936, 3.1415926535897936, 3.1415926535897936, 3.1415926535897936, 3.1415926535897927, 3.1415926535897936])Inclusive Code:
m = ld - 1 result = [] for i in range(1, n): j, delta = divmod(i * m, n) interpolated = (data[j] * (n - delta) + data[j + 1] * delta) / n result.append(interpolated) return resultTest case:
def test_quantiles_monotonic_with_duplicates(): for x in [3.141592653589793, 1/3, 0.1, 1e300, 1e-300]: for n in range(2, 20): for method in ('exclusive', 'inclusive'): r = quantiles([x]*2, n=n, method=method) assert r == sorted(r), (x, n, method, r) test_quantiles_monotonic_with_duplicates()Fixed code (not 100% the same code as the suggested fix in the mentioned commit, just a demo with a single if clause added, does not handle delta issues or float)
if method == 'inclusive': m = ld - 1 result = [] for i in range(1, n): j, delta = divmod(i * m, n) if data[j] == data[j + 1]: interpolated = data[j] else: interpolated = (data[j] * (n - delta) + data[j + 1] * delta) / n result.append(interpolated) return resultEdit: fixed j, j+1
Bug description
statistics.quantiles(data, n=N, method='exclusive')withN≥ 3 returns cut points that are not monotonically non-decreasing whendatacontains two equal but float-inexact values. The first and last cut points compute one ULP below the data value, while interior cut points compute exactly the data value, so the returned list is unsorted.Reproducer is one-liner:
Why this is a bug
The
quantilesdocumentation says it "Divide(s) data into n continuous intervals with equal probability. Returns a list ofn - 1cut points separating the intervals." Cut points that separate intervals must be monotonically non-decreasing — otherwise the "intervals" they delimit overlap or invert. Several downstream uses (e.g.bisect.bisect_left(cut_points, x)to bucket a value) rely silently on this invariant and will return wrong bucket indices when it's violated.The
'exclusive'method computes each cut point with the formula:When
data[j-1] == data[j] == xexactly,interpolated = (x*(n-delta) + x*delta)/nshould bexmathematically, but for non-power-of-twodelta/nratios the float operationsx*(n-delta) + x*deltado not always round ton*x, yielding a result one ULP off. The error happens asymmetrically acrossi, so adjacent cut points differ by 1 ULP and the list ends up unsorted.'inclusive'is not affected because its formula is structured to be exact when data are identical.Suggested fix
Sort the result before returning (cheap, O(n log n) on n − 1 elements which is already trivial):
This preserves the existing values exactly when they are already monotonic and only reorders the few corner cases where float rounding broke the invariant. A regression test:
CPython versions tested on
3.9, 3.12, 3.14
Operating systems tested on
macOS
Linked PRs
quantiles(method='exclusive')returning unsorted cut points for duplicate floats #150326