From 06582977a9fb9bec87d5ec22e49b47394b5ba791 Mon Sep 17 00:00:00 2001 From: Jimmy M Andersson Date: Sat, 23 May 2026 16:17:46 +0200 Subject: [PATCH] fix(variance): return NaN for sample variance of single-element collection Population variance of a single observation is 0 (no spread), but sample variance is mathematically undefined because the denominator n-1 equals 0. The prior guard short-circuited both cases with 0, silently producing incorrect results for the sample case. Tests are split into separate population and sample cases to make the distinction explicit, and two constant-value two-element cases are added to cover the zero-spread boundary. Fixes: #179 --- .../Dispersion/Variability.swift | 7 +++- .../StandardDeviationTests.swift | 37 ++++++++++++++++--- .../Dispersion Tests/VarianceTests.swift | 31 ++++++++++++++-- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/Sources/StatKit/Descriptive Statistics/Dispersion/Variability.swift b/Sources/StatKit/Descriptive Statistics/Dispersion/Variability.swift index a7c4e88..db1068f 100644 --- a/Sources/StatKit/Descriptive Statistics/Dispersion/Variability.swift +++ b/Sources/StatKit/Descriptive Statistics/Dispersion/Variability.swift @@ -14,7 +14,12 @@ public extension Collection { ) -> Double { guard !self.isEmpty else { return .signalingNaN } - guard self.count > 1 else { return 0 } + guard self.count > 1 else { + switch composition { + case .sample: return .signalingNaN + case .population: return 0 + } + } let mean = self.mean(variable: variable) diff --git a/Tests/StatKitTests/Descriptive Statistics Tests/Dispersion Tests/StandardDeviationTests.swift b/Tests/StatKitTests/Descriptive Statistics Tests/Dispersion Tests/StandardDeviationTests.swift index bf969a5..c2494e5 100644 --- a/Tests/StatKitTests/Descriptive Statistics Tests/Dispersion Tests/StandardDeviationTests.swift +++ b/Tests/StatKitTests/Descriptive Statistics Tests/Dispersion Tests/StandardDeviationTests.swift @@ -8,9 +8,11 @@ struct StandardDeviationTests { ((-5 ... 5).map(\.realValue), 3.3166247904, DataSetComposition.sample), ((1 ... 5).map(\.realValue), 1.4142135624, DataSetComposition.population), ((-5 ... 5).map(\.realValue), 3.1622776602, DataSetComposition.population), - ]) - func validData(data: [Double], expectedVariance: Double, composition: DataSetComposition) { - #expect(data.standardDeviation(variable: \.self, from: composition).isApproximatelyEqual(to: expectedVariance, absoluteTolerance: 1e-6)) + ([42.0, 42.0], 0.0, DataSetComposition.sample), + ([42.0, 42.0], 0.0, DataSetComposition.population), + ] as [([Double], Double, DataSetComposition)]) + func validData(data: [Double], expectedStdDev: Double, composition: DataSetComposition) { + #expect(data.standardDeviation(variable: \.self, from: composition).isApproximatelyEqual(to: expectedStdDev, absoluteTolerance: 1e-6)) } @Test("Standard deviation of empty collection is undefined", arguments: [[Double]()], DataSetComposition.allCases) @@ -18,8 +20,31 @@ struct StandardDeviationTests { #expect(data.standardDeviation(variable: \.self, from: composition).isNaN) } - @Test("Standard deviation of single element collection is 0", arguments: [[1], [-1]] , DataSetComposition.allCases) - func singleElementCollection(data: [Double], composition: DataSetComposition) { - #expect(data.standardDeviation(variable: \.self, from: composition) == 0) + @Test( + "Population standard deviation of single-element collection is 0", + arguments: [[1.0], [-1.0], [42.0]] as [[Double]] + ) + func singleElementPopulationStdDev(data: [Double]) { + #expect(data.standardDeviation(variable: \.self, from: .population) == 0) + } + + @Test( + "Sample standard deviation of single-element collection is undefined", + arguments: [[1.0], [-1.0], [42.0]] as [[Double]] + ) + func singleElementSampleStdDev(data: [Double]) { + #expect(data.standardDeviation(variable: \.self, from: .sample).isNaN) + } + + @Test("Standard deviation of collection containing NaN is NaN", arguments: DataSetComposition.allCases) + func collectionContainingNaN(composition: DataSetComposition) { + let data = [1.0, Double.nan, 3.0] + #expect(data.standardDeviation(variable: \.self, from: composition).isNaN) + } + + @Test("Standard deviation of collection containing infinity is NaN", arguments: DataSetComposition.allCases) + func collectionContainingInfinity(composition: DataSetComposition) { + let data = [1.0, Double.infinity, 3.0] + #expect(data.standardDeviation(variable: \.self, from: composition).isNaN) } } diff --git a/Tests/StatKitTests/Descriptive Statistics Tests/Dispersion Tests/VarianceTests.swift b/Tests/StatKitTests/Descriptive Statistics Tests/Dispersion Tests/VarianceTests.swift index 330a885..619e11d 100644 --- a/Tests/StatKitTests/Descriptive Statistics Tests/Dispersion Tests/VarianceTests.swift +++ b/Tests/StatKitTests/Descriptive Statistics Tests/Dispersion Tests/VarianceTests.swift @@ -10,6 +10,8 @@ struct VarianceTests { ((-5 ... 5).map(\.realValue), 11.0, DataSetComposition.sample), ((1 ... 5).map(\.realValue), 2, DataSetComposition.population), ((-5 ... 5).map(\.realValue), 10, DataSetComposition.population), + ([42.0, 42.0], 0.0, DataSetComposition.sample), + ([42.0, 42.0], 0.0, DataSetComposition.population), ] as [([Double], Double, DataSetComposition)] ) func validData(data: [Double], expectedVariance: Double, composition: DataSetComposition) { @@ -21,8 +23,31 @@ struct VarianceTests { #expect(data.variance(variable: \.self, from: composition).isNaN) } - @Test("Variance of single element collection is 0", arguments: [[1], [-1]] , DataSetComposition.allCases) - func singleElementCollection(data: [Double], composition: DataSetComposition) { - #expect(data.variance(variable: \.self, from: composition) == 0) + @Test( + "Population variance of single-element collection is 0", + arguments: [[1.0], [-1.0], [42.0]] as [[Double]] + ) + func singleElementPopulationVariance(data: [Double]) { + #expect(data.variance(variable: \.self, from: .population) == 0) + } + + @Test( + "Sample variance of single-element collection is undefined", + arguments: [[1.0], [-1.0], [42.0]] as [[Double]] + ) + func singleElementSampleVariance(data: [Double]) { + #expect(data.variance(variable: \.self, from: .sample).isNaN) + } + + @Test("Variance of collection containing NaN is NaN", arguments: DataSetComposition.allCases) + func collectionContainingNaN(composition: DataSetComposition) { + let data = [1.0, Double.nan, 3.0] + #expect(data.variance(variable: \.self, from: composition).isNaN) + } + + @Test("Variance of collection containing infinity is NaN", arguments: DataSetComposition.allCases) + func collectionContainingInfinity(composition: DataSetComposition) { + let data = [1.0, Double.infinity, 3.0] + #expect(data.variance(variable: \.self, from: composition).isNaN) } }