fix: return the value as all three quartiles for single-element input

Quartile called Median on empty half-slices when the input held one value
and discarded both errors, so it returned {NaN, v, NaN} with a nil error.
The NaN spread silently through InterQuartileRange, Midhinge and Trimean.

A single observation is its own lower hinge, median and upper hinge, which
is what Percentile already returns for the same data. Return that instead,
and note why the remaining discarded Median errors cannot fire.
This commit is contained in:
dualfroz committed 2026-09-03 14:05:13 +02:00
1 parent 05657cd5a8
commit 62beb61195
3 files changed
+57 -1

No files matched your search

+3
View File
@@ -2183,6 +2183,9 @@ func Quartile(input Float64Data) (Quartiles, error)
``` ```
Quartile returns the three quartile points from a slice of data Quartile returns the three quartile points from a slice of data
A single value is its own lower hinge, median and upper hinge, so all
three quartiles are that value.
+13 -1
View File
@@ -10,6 +10,9 @@ type Quartiles struct {
} }
// Quartile returns the three quartile points from a slice of data // Quartile returns the three quartile points from a slice of data
//
// A single value is its own lower hinge, median and upper hinge, so all
// three quartiles are that value.
func Quartile(input Float64Data) (Quartiles, error) { func Quartile(input Float64Data) (Quartiles, error) {
il := input.Len() il := input.Len()
@@ -17,6 +20,14 @@ func Quartile(input Float64Data) (Quartiles, error) {
return Quartiles{}, EmptyInputErr return Quartiles{}, EmptyInputErr
} }
// One value is its own quartiles, which also matches what Percentile
// returns for that value at any percent. The general path below cannot
// produce it: the cutoffs would leave both halves empty and the two
// hinges would come back as NaN.
if il == 1 {
return Quartiles{input[0], input[0], input[0]}, nil
}
// Start by sorting a copy of the slice // Start by sorting a copy of the slice
copy := sortedCopy(input) copy := sortedCopy(input)
@@ -32,7 +43,8 @@ func Quartile(input Float64Data) (Quartiles, error) {
c2 = c1 + 1 c2 = c1 + 1
} }
// Find the Medians with the cutoff points // Find the Medians with the cutoff points. Both halves hold at least
// one value for every length above one, so Median cannot fail here.
Q1, _ := Median(copy[:c1]) Q1, _ := Median(copy[:c1])
Q2, _ := Median(copy) Q2, _ := Median(copy)
Q3, _ := Median(copy[c2:]) Q3, _ := Median(copy[c2:])
+41
View File
@@ -41,6 +41,47 @@ func TestQuartile(t *testing.T) {
} }
} }
func TestQuartile_SingleValue(t *testing.T) {
quartiles, err := stats.Quartile([]float64{43})
if err != nil {
t.Errorf("Should not have returned an error; got %v", err)
}
if quartiles.Q1 != 43 {
t.Errorf("Q1 %v != 43", quartiles.Q1)
}
if quartiles.Q2 != 43 {
t.Errorf("Q2 %v != 43", quartiles.Q2)
}
if quartiles.Q3 != 43 {
t.Errorf("Q3 %v != 43", quartiles.Q3)
}
// The quartiles of one value agree with Percentile on the same value.
for _, percent := range []float64{25, 50, 75} {
value, err := stats.Percentile([]float64{43}, percent)
if err != nil || value != 43 {
t.Errorf("Percentile %v on one value: got %v (err %v)", percent, value, err)
}
}
}
func TestQuartile_SingleValueDependents(t *testing.T) {
iqr, err := stats.InterQuartileRange([]float64{43})
if err != nil || iqr != 0 {
t.Errorf("IQR of one value: got %v (err %v), want 0", iqr, err)
}
mh, err := stats.Midhinge([]float64{43})
if err != nil || mh != 43 {
t.Errorf("Midhinge of one value: got %v (err %v), want 43", mh, err)
}
tr, err := stats.Trimean([]float64{43})
if err != nil || tr != 43 {
t.Errorf("Trimean of one value: got %v (err %v), want 43", tr, err)
}
}
func TestInterQuartileRange(t *testing.T) { func TestInterQuartileRange(t *testing.T) {
s1 := []float64{102, 104, 105, 107, 108, 109, 110, 112, 115, 116, 118} s1 := []float64{102, 104, 105, 107, 108, 109, 110, 112, 115, 116, 118}
iqr, _ := stats.InterQuartileRange(s1) iqr, _ := stats.InterQuartileRange(s1)