Skip to content

Commit 278bcc2

Browse files
feat(logistic-distribution): add LogisticDistribution with PDF, CDF, and sampling
Implements numerically stable PDF and CDF using a symmetric log-sigmoid decomposition (logCDF(z) + logCDF(-z)) that correctly handles ±infinity without NaN. Sampling uses the quantile function via inverse transform. Fixes: #170
1 parent b07d1c8 commit 278bcc2

2 files changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import RealModule
2+
3+
/// A type modelling a Logistic Distribution.
4+
public struct LogisticDistribution: ContinuousDistribution, UnivariateDistribution {
5+
/// The location parameter.
6+
public let mu: Double
7+
8+
/// The scale parameter.
9+
public let scale: Double
10+
11+
/// Creates a Logistic Distribution with a specified location and scale.
12+
/// - parameter mu: The location parameter for this distribution.
13+
/// - parameter scale: The scale parameter for this distribution.
14+
public init(mu: Double, scale: Double) {
15+
precondition(
16+
0 < scale,
17+
"The scale parameter must be strictly greater than 0 (\(scale) was used)."
18+
)
19+
20+
self.mu = mu
21+
self.scale = scale
22+
}
23+
24+
public var mean: Double {
25+
return mu
26+
}
27+
28+
public var variance: Double {
29+
return .pow(.pi, 2) / 3 * .pow(scale, 2)
30+
}
31+
32+
public var skewness: Double {
33+
return 0
34+
}
35+
36+
public var excessKurtosis: Double {
37+
return 6 / 5
38+
}
39+
40+
public func pdf(x: Double, logarithmic: Bool = false) -> Double {
41+
let z = (x - mu) / scale
42+
let logPDF = logCDF(z: z) + logCDF(z: -z) - .log(scale)
43+
return logarithmic ? logPDF : .exp(logPDF)
44+
}
45+
46+
public func cdf(x: Double, logarithmic: Bool = false) -> Double {
47+
let value = logCDF(z: (x - mu) / scale)
48+
return logarithmic ? value : .exp(value)
49+
}
50+
51+
public func sample() -> Double {
52+
quantile(Double.random(in: Double.leastNonzeroMagnitude ..< 1))
53+
}
54+
55+
public func sample(_ numberOfElements: Int) -> [Double] {
56+
precondition(0 < numberOfElements, "The requested number of samples need to be greater than 0.")
57+
var rng = Xoroshiro256StarStar()
58+
return (1 ... numberOfElements).map { _ in
59+
quantile(Double.random(in: Double.leastNonzeroMagnitude ..< 1, using: &rng))
60+
}
61+
}
62+
63+
private func quantile(_ p: Double) -> Double {
64+
mu + scale * (.log(p) - .log(onePlus: -p))
65+
}
66+
67+
private func logCDF(z: Double) -> Double {
68+
if z >= 0 {
69+
return -Double.log(onePlus: Double.exp(-z))
70+
} else {
71+
return z - Double.log(onePlus: Double.exp(z))
72+
}
73+
}
74+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import Testing
2+
import StatKit
3+
4+
@Suite("Logistic Distribution Tests", .tags(.distribution))
5+
struct LogisticDistributionTests {
6+
@Test(
7+
"Valid distribution parameters return correct mean",
8+
arguments: [
9+
(0.0, 1.0, 0.0),
10+
(2.0, 3.0, 2.0),
11+
]
12+
)
13+
func validInputReturnsCorrectMean(mu: Double, scale: Double, expectedMean: Double) async throws {
14+
let mean = LogisticDistribution(mu: mu, scale: scale).mean
15+
#expect(mean.isApproximatelyEqual(to: expectedMean, absoluteTolerance: 1e-6))
16+
}
17+
18+
@Test(
19+
"Valid distribution parameters return correct variance",
20+
arguments: [
21+
(0.0, 1.0, 3.289868134),
22+
(2.0, 3.0, 29.608813203),
23+
]
24+
)
25+
func validInputReturnsCorrectVariance(mu: Double, scale: Double, expectedVariance: Double) async throws {
26+
let variance = LogisticDistribution(mu: mu, scale: scale).variance
27+
#expect(variance.isApproximatelyEqual(to: expectedVariance, absoluteTolerance: 1e-6))
28+
}
29+
30+
@Test(
31+
"Valid distribution parameters return correct skewness",
32+
arguments: [
33+
(0.0, 1.0, 0.0),
34+
(2.0, 3.0, 0.0),
35+
]
36+
)
37+
func validInputReturnsCorrectSkewness(mu: Double, scale: Double, expectedSkewness: Double) async throws {
38+
let skewness = LogisticDistribution(mu: mu, scale: scale).skewness
39+
#expect(skewness.isApproximatelyEqual(to: expectedSkewness, absoluteTolerance: 1e-6))
40+
}
41+
42+
@Test(
43+
"Valid distribution parameters return correct excess kurtosis",
44+
arguments: [
45+
(0.0, 1.0, 1.2),
46+
(2.0, 3.0, 1.2),
47+
]
48+
)
49+
func validInputReturnsCorrectExcessKurtosis(mu: Double, scale: Double, expectedKurtosis: Double) async throws {
50+
let kurtosis = LogisticDistribution(mu: mu, scale: scale).excessKurtosis
51+
#expect(kurtosis.isApproximatelyEqual(to: expectedKurtosis, absoluteTolerance: 1e-6))
52+
}
53+
54+
@Test(
55+
"Valid distribution parameters return correct CDF value",
56+
arguments: [
57+
(0.0, 1.0, -2.0, 0.119202922),
58+
(0.0, 1.0, -1.0, 0.268941421),
59+
(0.0, 1.0, 0.0, 0.500000000),
60+
(0.0, 1.0, 1.0, 0.731058579),
61+
(0.0, 1.0, 2.0, 0.880797078),
62+
(2.0, 3.0, -1.0, 0.268941420),
63+
(2.0, 3.0, 2.0, 0.500000000),
64+
(2.0, 3.0, 5.0, 0.731058580),
65+
]
66+
)
67+
func validInputReturnsCorrectCDF(mu: Double, scale: Double, x: Double, expectedCDF: Double) async throws {
68+
let cdf = LogisticDistribution(mu: mu, scale: scale).cdf(x: x)
69+
#expect(cdf.isApproximatelyEqual(to: expectedCDF, absoluteTolerance: 1e-6))
70+
}
71+
72+
@Test(
73+
"Valid distribution parameters return correct log CDF value",
74+
arguments: [
75+
(0.0, 1.0, -2.0, -2.126928011),
76+
(0.0, 1.0, 0.0, -0.693147181),
77+
(0.0, 1.0, 2.0, -0.126928011),
78+
(2.0, 3.0, -1.0, -1.3132617),
79+
(2.0, 3.0, 2.0, -0.6931472),
80+
(2.0, 3.0, 5.0, -0.3132617),
81+
]
82+
)
83+
func validInputReturnsCorrectLogCDF(mu: Double, scale: Double, x: Double, expectedLogCDF: Double) async throws {
84+
let cdf = LogisticDistribution(mu: mu, scale: scale).cdf(x: x, logarithmic: true)
85+
#expect(cdf.isApproximatelyEqual(to: expectedLogCDF, absoluteTolerance: 1e-6))
86+
}
87+
88+
@Test(
89+
"Valid distribution parameters return correct PDF value",
90+
arguments: [
91+
(0.0, 1.0, -2.0, 0.104993585),
92+
(0.0, 1.0, -1.0, 0.196611933),
93+
(0.0, 1.0, 0.0, 0.250000000),
94+
(0.0, 1.0, 1.0, 0.196611933),
95+
(0.0, 1.0, 2.0, 0.104993585),
96+
(2.0, 3.0, -1.0, 0.065537310),
97+
(2.0, 3.0, 2.0, 0.083333330),
98+
(2.0, 3.0, 5.0, 0.065537310),
99+
]
100+
)
101+
func validInputReturnsCorrectPDF(mu: Double, scale: Double, x: Double, expectedPDF: Double) async throws {
102+
let pdf = LogisticDistribution(mu: mu, scale: scale).pdf(x: x)
103+
#expect(pdf.isApproximatelyEqual(to: expectedPDF, absoluteTolerance: 1e-6))
104+
}
105+
106+
@Test(
107+
"Valid distribution parameters return correct log PDF value",
108+
arguments: [
109+
(0.0, 1.0, -2.0, -2.253856),
110+
(0.0, 1.0, -1.0, -1.626523),
111+
(0.0, 1.0, 0.0, -1.386294),
112+
(0.0, 1.0, 1.0, -1.626523),
113+
(0.0, 1.0, 2.0, -2.253856),
114+
(2.0, 3.0, -1.0, -2.725136),
115+
(2.0, 3.0, 2.0, -2.484907),
116+
(2.0, 3.0, 5.0, -2.725136),
117+
]
118+
)
119+
func validInputReturnsCorrectLogPDF(mu: Double, scale: Double, x: Double, expectedLogPDF: Double) async throws {
120+
let pdf = LogisticDistribution(mu: mu, scale: scale).pdf(x: x, logarithmic: true)
121+
#expect(pdf.isApproximatelyEqual(to: expectedLogPDF, absoluteTolerance: 1e-6))
122+
}
123+
124+
@Test(
125+
"PDF at ±infinity returns correct value",
126+
arguments: [(0.0, 1.0), (2.0, 3.0)]
127+
)
128+
func pdfAtInfinityIsZero(mu: Double, scale: Double) async throws {
129+
let distribution = LogisticDistribution(mu: mu, scale: scale)
130+
#expect(distribution.pdf(x: .infinity) == 0)
131+
#expect(distribution.pdf(x: -.infinity) == 0)
132+
}
133+
134+
@Test(
135+
"Log PDF at ±infinity returns correct value",
136+
arguments: [(0.0, 1.0), (2.0, 3.0)]
137+
)
138+
func logPDFAtInfinityIsNegativeInfinity(mu: Double, scale: Double) async throws {
139+
let distribution = LogisticDistribution(mu: mu, scale: scale)
140+
#expect(distribution.pdf(x: .infinity, logarithmic: true) == -.infinity)
141+
#expect(distribution.pdf(x: -.infinity, logarithmic: true) == -.infinity)
142+
}
143+
144+
@Test(
145+
"CDF at ±infinity returns correct value",
146+
arguments: [(0.0, 1.0), (2.0, 3.0)]
147+
)
148+
func cdfAtInfinityIsCorrect(mu: Double, scale: Double) async throws {
149+
let distribution = LogisticDistribution(mu: mu, scale: scale)
150+
#expect(distribution.cdf(x: -.infinity) == 0)
151+
#expect(distribution.cdf(x: .infinity) == 1)
152+
}
153+
154+
@Test(
155+
"Log CDF at ±infinity returns correct value",
156+
arguments: [(0.0, 1.0), (2.0, 3.0)]
157+
)
158+
func logCDFAtInfinityIsCorrect(mu: Double, scale: Double) async throws {
159+
let distribution = LogisticDistribution(mu: mu, scale: scale)
160+
#expect(distribution.cdf(x: -.infinity, logarithmic: true) == -.infinity)
161+
#expect(distribution.cdf(x: .infinity, logarithmic: true) == 0)
162+
}
163+
164+
#if swift(>=6.2)
165+
@Test("Non-positive scale triggers a precondition failure")
166+
func nonPositiveScaleTriggersPreconditionFailure() async {
167+
await #expect(processExitsWith: .failure) {
168+
_ = LogisticDistribution(mu: 0, scale: 0)
169+
}
170+
}
171+
172+
@Test("Non-positive sample count triggers a precondition failure")
173+
func nonPositiveSampleCountTriggersPreconditionFailure() async {
174+
await #expect(processExitsWith: .failure) {
175+
_ = LogisticDistribution(mu: 0, scale: 1).sample(0)
176+
}
177+
}
178+
#endif
179+
180+
@Test("Sampling from a distribution returns correct proportions")
181+
func testSampling() async throws {
182+
let numberOfSamples = 1_000_000
183+
let distribution = LogisticDistribution(mu: 0, scale: 1)
184+
let samples = distribution.sample(numberOfSamples)
185+
186+
var proportions = samples.reduce(into: [Int: Double]()) { result, number in result[Int(number.rounded(.down)), default: 0] += 1 }
187+
for key in proportions.keys { proportions[key]? /= Double(numberOfSamples) }
188+
189+
#expect(samples.count == numberOfSamples)
190+
#expect(proportions[-5, default: 0.0].isApproximatelyEqual(to: 0.01129336, absoluteTolerance: 0.01))
191+
#expect(proportions[-4, default: 0.0].isApproximatelyEqual(to: 0.02943966, absoluteTolerance: 0.01))
192+
#expect(proportions[-3, default: 0.0].isApproximatelyEqual(to: 0.07177705, absoluteTolerance: 0.01))
193+
#expect(proportions[-2, default: 0.0].isApproximatelyEqual(to: 0.14973850, absoluteTolerance: 0.01))
194+
#expect(proportions[-1, default: 0.0].isApproximatelyEqual(to: 0.23105858, absoluteTolerance: 0.01))
195+
#expect(proportions[ 0, default: 0.0].isApproximatelyEqual(to: 0.23105858, absoluteTolerance: 0.01))
196+
#expect(proportions[ 1, default: 0.0].isApproximatelyEqual(to: 0.14973850, absoluteTolerance: 0.01))
197+
#expect(proportions[ 2, default: 0.0].isApproximatelyEqual(to: 0.07177705, absoluteTolerance: 0.01))
198+
#expect(proportions[ 3, default: 0.0].isApproximatelyEqual(to: 0.02943966, absoluteTolerance: 0.01))
199+
#expect(proportions[ 4, default: 0.0].isApproximatelyEqual(to: 0.01129336, absoluteTolerance: 0.01))
200+
}
201+
}

0 commit comments

Comments
 (0)