SPB Git forge

spb/metrika

Public

Stata-class statistics, GPU-accelerated by Apple Silicon. Native Swift — no Electron, no Python runtime, no compromises.

21commits 1branches 1releases
2.2 MBsize
maindefault branch
1 mo agolast push
Swift 92.4% HTML 3.3% R 3% Shell 1.3%

feat(stats): Bayesian linear regression via Gibbs sampling (bayes prefix)

- PhiloxStream in ZQGPU: sequential variate stream over a dedicated
  counter block (bit 62 + per-stream 2^48 words, disjoint from bootstrap
  and permutation streams); Box-Muller normals, Marsaglia-Tsang gammas
  (moments verified against theory at 200k draws)
- ZQBayesianRegression: semi-conjugate Gibbs with Stata bayes default
  priors (coefficients N(0, 10000), variance InvGamma(.01, .01)); exact
  full conditionals via dense Cholesky; posterior mean/sd and
  equal-tailed 95% credible intervals; ZQStats now depends on ZQGPU for
  the shared RNG
- engine: 'bayes [, mcmcsize() burnin() seed() normalprior()]: reg …'
  with reproducible chains; manual entry included
- validation: with diffuse priors the posterior reproduces OLS (mean
  within 5% of a posterior SD, sd ratio in [0.9, 1.15], CrI brackets the
  estimate, sigma recovers the DGP); tight priors shrink toward zero;
  chains bit-reproducible per seed
- 111 tests green (swift test and xcodebuild with GPU suites)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 1 mo ago (Aug 5, 2026) parent a3f7d61

8 changed files +561 −3

modified MetrikaKit/Package.swift +1 −1
@@ -48,7 +48,7 @@ let package = Package(
48 48 ),
49 49 .target(
50 50 name: "ZQStats",
51 − dependencies: ["ZQData"],
51 + dependencies: ["ZQData", "ZQGPU"],
52 52 swiftSettings: strictConcurrency
53 53 ),
54 54 .target(
modified MetrikaKit/Sources/ZQEngine/CommandHelp.swift +13 −0
@@ -230,6 +230,19 @@ public enum ZQCommandReference {
230 230 examples: ["bootstrap, reps(10000) seed(42): reg log_rev price"],
231 231 notes: "Replicates are counter-addressable: any subset recomputes identically regardless of chunking or backend. The planner dispatches ≥500 reps to the GPU when available."
232 232 ),
233 + ZQCommandDoc(
234 + verb: "bayes", category: "Resampling & simulation",
235 + summary: "Bayesian linear regression by Gibbs sampling: posterior means, standard deviations, and 95% credible intervals.",
236 + syntax: "bayes [, mcmcsize(#) burnin(#) seed(#) normalprior(#)]: regress depvar indepvars",
237 + options: [
238 + ("mcmcsize(#)", "posterior draws after burn-in (default 10000)"),
239 + ("burnin(#)", "discarded warm-up iterations (default 2500)"),
240 + ("seed(#)", "Philox seed — chains are exactly reproducible"),
241 + ("normalprior(#)", "prior variance of the N(0, #) coefficient priors (default 10000)"),
242 + ],
243 + examples: ["bayes, mcmcsize(20000) seed(42): reg log_rev price"],
244 + notes: "Priors: coefficients N(0, normalprior), variance InvGamma(0.01, 0.01) — Stata's bayes defaults. With diffuse priors the posterior reproduces OLS."
245 + ),
233 246 ZQCommandDoc(
234 247 verb: "permute", category: "Resampling & simulation",
235 248 summary: "Permutation test: the response is permuted, the model refit, and empirical two-sided p-values reported per coefficient.",
modified MetrikaKit/Sources/ZQEngine/Session.swift +96 −0
@@ -208,6 +208,7 @@ public actor ZQSession {
208 208 case "display": return try handleDisplay(command)
209 209 case "bootstrap": return try await handleBootstrap(command, backend: backend)
210 210 case "permute": return try await handlePermute(command)
211 + case "bayes": return try handleBayes(command)
211 212 case "graph": return try handleGraph(command)
212 213 case "histogram", "scatter", "kdensity":
213 214 var promoted = command
@@ -2046,6 +2047,101 @@ public actor ZQSession {
2046 2047 return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)
2047 2048 }
2048 2049
2050 + // MARK: - Bayesian estimation
2051 +
2052 + /// `bayes [, mcmcsize(#) burnin(#) seed(#) normalprior(#)]: reg y x…`
2053 + /// — Gibbs-sampled Bayesian linear regression with Stata-style default
2054 + /// priors: N(0, 10000) on coefficients, InvGamma(0.01, 0.01) on σ².
2055 + private func handleBayes(_ command: ZQCommand) throws -> ZQResult {
2056 + guard let body = command.body else {
2057 + throw ZQEngineError("bayes: syntax is 'bayes [, options]: regress …'")
2058 + }
2059 + guard body.verb == "regress" else {
2060 + throw ZQEngineError("bayes currently supports 'regress' bodies only")
2061 + }
2062 + if let seedText = command.option("seed")?.firstArgument {
2063 + guard let value = UInt64(seedText) else {
2064 + throw ZQEngineError("bayes: invalid seed")
2065 + }
2066 + seed = value
2067 + }
2068 + func intOption(_ name: String, default defaultValue: Int) throws -> Int {
2069 + guard let text = command.option(name)?.firstArgument else { return defaultValue }
2070 + guard let value = Int(text), value > 0 else {
2071 + throw ZQEngineError("bayes: invalid \(name)()")
2072 + }
2073 + return value
2074 + }
2075 + let mcmcSize = try intOption("mcmcsize", default: 10_000)
2076 + let burnIn = try intOption("burnin", default: 2_500)
2077 + var priorVariance = 10_000.0
2078 + if let text = command.option("normalprior")?.firstArgument {
2079 + guard let value = Double(text), value > 0 else {
2080 + throw ZQEngineError("bayes: invalid normalprior()")
2081 + }
2082 + priorVariance = value
2083 + }
2084 +
2085 + let sample = try buildRegressionSample(body, clusterVariable: nil)
2086 + let result = try ZQBayesianRegression.fitGibbs(
2087 + y: sample.y,
2088 + predictors: sample.predictors,
2089 + includeConstant: !body.hasOption("noconstant"),
2090 + mcmcSize: mcmcSize,
2091 + burnIn: burnIn,
2092 + seed: seed,
2093 + coefficientPriorVariance: priorVariance
2094 + )
2095 + lastEstimation = nil // predict after bayes needs posterior draws
2096 +
2097 + var lines = ["Bayesian linear regression (Gibbs)"]
2098 + if sample.droppedMissing > 0 {
2099 + lines.append("(\(sample.droppedMissing) observations dropped due to missing values)")
2100 + }
2101 + for (label, value) in [
2102 + ("Number of obs", "\(result.observationCount)"),
2103 + ("MCMC iterations", "\(result.mcmcSize)"),
2104 + ("Burn-in", "\(result.burnIn)"),
2105 + ("Priors", "b ~ N(0, \(TableFormatter.general(priorVariance))), sigma2 ~ IG(.01, .01)"),
2106 + ] {
2107 + lines.append(
2108 + TableFormatter.pad(label, 46, right: false) + "= " +
2109 + TableFormatter.pad(value, 24)
2110 + )
2111 + }
2112 + lines.append("")
2113 +
2114 + let widths = [12, 12, 12, 24]
2115 + lines.append(
2116 + TableFormatter.pad(sample.responseName, widths[0]) + " | " +
2117 + TableFormatter.pad("Mean", widths[1]) + " " +
2118 + TableFormatter.pad("Std. dev.", widths[2]) + " " +
2119 + TableFormatter.pad("[95% cred. interval]", widths[3])
2120 + )
2121 + lines.append(TableFormatter.rule(widths))
2122 +
2123 + var scalars: [String: Double] = [
2124 + "N": Double(result.observationCount),
2125 + "mcmcsize": Double(result.mcmcSize),
2126 + "burnin": Double(result.burnIn),
2127 + ]
2128 + for coefficient in result.coefficients + [result.sigma] {
2129 + lines.append(
2130 + TableFormatter.pad(coefficient.name, widths[0]) + " | " +
2131 + TableFormatter.pad(TableFormatter.general(coefficient.posteriorMean), widths[1]) + " " +
2132 + TableFormatter.pad(TableFormatter.general(coefficient.posteriorSD), widths[2]) + " " +
2133 + TableFormatter.pad(
2134 + TableFormatter.general(coefficient.credibleLower) + " " +
2135 + TableFormatter.general(coefficient.credibleUpper),
2136 + widths[3]
2137 + )
2138 + )
2139 + scalars["b_\(coefficient.name)"] = coefficient.posteriorMean
2140 + scalars["sd_\(coefficient.name)"] = coefficient.posteriorSD
2141 + }
2142 + return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)
2143 + }
2144 +
2049 2145 // MARK: - Permutation test
2050 2146
2051 2147 /// `permute, reps(#) [seed(#)]: reg y x…` — permutes the response
added MetrikaKit/Sources/ZQGPU/PhiloxStream.swift +77 −0
@@ -0,0 +1,77 @@
1 +//
2 +// PhiloxStream.swift
3 +// Metrika
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +//
9 +
10 +import Foundation
11 +
12 +/// Sequential random-variate stream over the Philox counter space, for
13 +/// samplers whose draws are inherently ordered (MCMC chains). Unlike the
14 +/// bootstrap's counter-addressable draws, a stream consumes positions one
15 +/// by one — rejection samplers use a variable number — but the sequence
16 +/// is fully determined by (seed, stream id), so chains are reproducible
17 +/// and independent chains never overlap.
18 +///
19 +/// Stream ids occupy bit 62 of the word-counter space plus a 2⁴⁸-word
20 +/// block per id, disjoint from bootstrap draws (low positions) and
21 +/// permutation keys (bit 63).
22 +public struct PhiloxStream: Sendable {
23 + private let generator: Philox4x32
24 + private let base: UInt64
25 + private var position: UInt64 = 0
26 +
27 + public init(seed: UInt64, stream: UInt64 = 0) {
28 + self.generator = Philox4x32(seed: seed)
29 + self.base = (UInt64(1) << 62) &+ stream &* (UInt64(1) << 48)
30 + }
31 +
32 + /// Uniform in (0, 1) — endpoints excluded so inverse-CDF transforms
33 + /// and logs stay finite.
34 + public mutating func nextUniform() -> Double {
35 + position &+= 1
36 + let u = generator.uniform(at: base &+ position)
37 + return min(max(u, 5e-324), 1 - 2.2e-16)
38 + }
39 +
40 + /// Standard normal via Box–Muller (two uniforms per pair, the spare
41 + /// is cached).
42 + private var cachedNormal: Double?
43 + public mutating func nextNormal() -> Double {
44 + if let cached = cachedNormal {
45 + cachedNormal = nil
46 + return cached
47 + }
48 + let u1 = nextUniform()
49 + let u2 = nextUniform()
50 + let radius = (-2 * Foundation.log(u1)).squareRoot()
51 + let angle = 2 * Double.pi * u2
52 + cachedNormal = radius * Foundation.sin(angle)
53 + return radius * Foundation.cos(angle)
54 + }
55 +
56 + /// Gamma(shape, rate) via Marsaglia–Tsang squeeze (with the standard
57 + /// boost for shape < 1).
58 + public mutating func nextGamma(shape: Double, rate: Double) -> Double {
59 + precondition(shape > 0 && rate > 0)
60 + if shape < 1 {
61 + // Gamma(a) = Gamma(a+1) · U^(1/a)
62 + let boosted = nextGamma(shape: shape + 1, rate: rate)
63 + return boosted * Foundation.pow(nextUniform(), 1 / shape)
64 + }
65 + let d = shape - 1.0 / 3.0
66 + let c = 1 / (9 * d).squareRoot()
67 + while true {
68 + let z = nextNormal()
69 + let v = (1 + c * z) * (1 + c * z) * (1 + c * z)
70 + guard v > 0 else { continue }
71 + let u = nextUniform()
72 + if Foundation.log(u) < 0.5 * z * z + d - d * v + d * Foundation.log(v) {
73 + return d * v / rate
74 + }
75 + }
76 + }
77 +}
modified MetrikaKit/Sources/ZQParser/KnownVerbs.swift +2 −1
@@ -47,6 +47,7 @@ public struct ZQVerbTable: Sendable {
47 47 "areg": 4,
48 48 "elasticnet": 7,
49 49 "lasso": 5,
50 + "bayes": 5,
50 51 "bootstrap": 9,
51 52 "permute": 7,
52 53 "jackknife": 9,
@@ -71,7 +72,7 @@ public struct ZQVerbTable: Sendable {
71 72 ],
72 73 fileVerbs: ["use", "save", "import", "export", "log"],
73 74 assignmentVerbs: ["generate", "replace", "egen"],
74 − prefixVerbs: ["bootstrap", "permute", "jackknife"],
75 + prefixVerbs: ["bayes", "bootstrap", "permute", "jackknife"],
75 76 compoundVerbs: ["graph", "ivregress"]
76 77 )
77 78
added MetrikaKit/Sources/ZQStats/BayesianRegression.swift +223 −0
@@ -0,0 +1,223 @@
1 +//
2 +// BayesianRegression.swift
3 +// Metrika
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +//
9 +
10 +import Foundation
11 +import ZQGPU
12 +
13 +/// Bayesian linear regression by Gibbs sampling (CLAUDE.md §1 MCMC
14 +/// module). Semi-conjugate model with Stata `bayes` default priors:
15 +///
16 +/// y | β, σ² ~ N(Xβ, σ²I)
17 +/// β_j ~ N(0, τ²), τ² = 10 000 by default
18 +/// σ² ~ InvGamma(a₀, b₀), a₀ = b₀ = 0.01 by default
19 +///
20 +/// Full conditionals are exact (Gibbs, acceptance rate 1):
21 +/// β | σ², y ~ N(Vₙ X'y/σ², Vₙ), Vₙ = (I/τ² + X'X/σ²)⁻¹
22 +/// σ² | β, y ~ InvGamma(a₀ + n/2, b₀ + ‖y − Xβ‖²/2)
23 +///
24 +/// Draws come from the Philox stream, so a (seed, chain) pair fully
25 +/// determines the chain.
26 +public struct ZQBayesCoefficient: Equatable, Sendable {
27 + public var name: String
28 + public var posteriorMean: Double
29 + public var posteriorSD: Double
30 + public var credibleLower: Double // equal-tailed 2.5%
31 + public var credibleUpper: Double // 97.5%
32 +}
33 +
34 +public struct ZQBayesResult: Equatable, Sendable {
35 + public var coefficients: [ZQBayesCoefficient]
36 + /// Posterior summary of σ (the residual standard deviation).
37 + public var sigma: ZQBayesCoefficient
38 + public var observationCount: Int
39 + public var mcmcSize: Int
40 + public var burnIn: Int
41 +}
42 +
43 +public enum ZQBayesianRegression {
44 +
45 + public static func fitGibbs(
46 + y: [Double],
47 + predictors: [(name: String, values: [Double])],
48 + includeConstant: Bool = true,
49 + mcmcSize: Int = 10_000,
50 + burnIn: Int = 2_500,
51 + seed: UInt64,
52 + coefficientPriorVariance: Double = 10_000,
53 + sigmaPriorShape: Double = 0.01,
54 + sigmaPriorRate: Double = 0.01
55 + ) throws -> ZQBayesResult {
56 + let n = y.count
57 + var names = predictors.map(\.name)
58 + if includeConstant { names.append("_cons") }
59 + let k = names.count
60 + guard n > k else {
61 + throw ZQStatsError("bayes: insufficient observations: n=\(n), k=\(k)")
62 + }
63 + guard mcmcSize > 10 else { throw ZQStatsError("bayes: mcmcsize too small") }
64 +
65 + // Design (column-major) and sufficient statistics.
66 + var x = [Double]()
67 + x.reserveCapacity(n * k)
68 + for column in predictors {
69 + guard column.values.count == n else {
70 + throw ZQStatsError("regressor '\(column.name)' has wrong length")
71 + }
72 + x.append(contentsOf: column.values)
73 + }
74 + if includeConstant { x.append(contentsOf: [Double](repeating: 1, count: n)) }
75 +
76 + var xtx = [Double](repeating: 0, count: k * k)
77 + var xty = [Double](repeating: 0, count: k)
78 + for j in 0..<k {
79 + for i in j..<k {
80 + var sum = 0.0
81 + for row in 0..<n { sum += x[i * n + row] * x[j * n + row] }
82 + xtx[j * k + i] = sum
83 + xtx[i * k + j] = sum
84 + }
85 + var sum = 0.0
86 + for row in 0..<n { sum += x[j * n + row] * y[row] }
87 + xty[j] = sum
88 + }
89 +
90 + // Start at OLS-ish values via a ridge solve to be safe.
91 + var stream = PhiloxStream(seed: seed)
92 + var beta = [Double](repeating: 0, count: k)
93 + var sigma2 = max(y.reduce(0) { $0 + $1 * $1 } / Double(n), 1e-8)
94 +
95 + let total = burnIn + mcmcSize
96 + var betaDraws = [[Double]](repeating: [], count: mcmcSize)
97 + var sigmaDraws = [Double](repeating: 0, count: mcmcSize)
98 + let priorPrecision = 1 / coefficientPriorVariance
99 +
100 + for iteration in 0..<total {
101 + // β | σ²: precision Λ = I/τ² + X'X/σ², posterior N(Λ⁻¹X'y/σ², Λ⁻¹).
102 + var lambda = [Double](repeating: 0, count: k * k)
103 + for j in 0..<k {
104 + for i in 0..<k {
105 + lambda[j * k + i] = xtx[j * k + i] / sigma2
106 + }
107 + lambda[j * k + j] += priorPrecision
108 + }
109 + let rhs = xty.map { $0 / sigma2 }
110 +
111 + // Cholesky of Λ (lower L, column-major): solve for the mean and
112 + // draw β = mean + L⁻ᵀ z (since Λ = L Lᵀ ⇒ Var = L⁻ᵀ L⁻¹).
113 + let chol = try choleskyLower(lambda, k: k)
114 + let mean = try choleskySolve(chol, k: k, rhs: rhs)
115 + var z = [Double](repeating: 0, count: k)
116 + for j in 0..<k { z[j] = stream.nextNormal() }
117 + let noise = try backSolveTransposed(chol, k: k, rhs: z)
118 + for j in 0..<k { beta[j] = mean[j] + noise[j] }
119 +
120 + // σ² | β: InvGamma(a₀ + n/2, b₀ + RSS/2).
121 + var rss = 0.0
122 + for row in 0..<n {
123 + var fitted = 0.0
124 + for j in 0..<k { fitted += beta[j] * x[j * n + row] }
125 + rss += (y[row] - fitted) * (y[row] - fitted)
126 + }
127 + let precision = stream.nextGamma(
128 + shape: sigmaPriorShape + Double(n) / 2,
129 + rate: sigmaPriorRate + rss / 2
130 + )
131 + sigma2 = 1 / precision
132 +
133 + if iteration >= burnIn {
134 + betaDraws[iteration - burnIn] = beta
135 + sigmaDraws[iteration - burnIn] = sigma2.squareRoot()
136 + }
137 + }
138 +
139 + // Posterior summaries.
140 + func summarize(_ name: String, _ draws: [Double]) -> ZQBayesCoefficient {
141 + let m = draws.reduce(0, +) / Double(draws.count)
142 + let variance = draws.reduce(0) { $0 + ($1 - m) * ($1 - m) }
143 + / Double(draws.count - 1)
144 + let sorted = draws.sorted()
145 + func quantile(_ p: Double) -> Double {
146 + let position = p * Double(sorted.count - 1)
147 + let lower = Int(position)
148 + let fraction = position - Double(lower)
149 + if lower + 1 < sorted.count {
150 + return sorted[lower] * (1 - fraction) + sorted[lower + 1] * fraction
151 + }
152 + return sorted[lower]
153 + }
154 + return ZQBayesCoefficient(
155 + name: name,
156 + posteriorMean: m,
157 + posteriorSD: variance.squareRoot(),
158 + credibleLower: quantile(0.025),
159 + credibleUpper: quantile(0.975)
160 + )
161 + }
162 +
163 + var coefficients: [ZQBayesCoefficient] = []
164 + for j in 0..<k {
165 + coefficients.append(summarize(names[j], betaDraws.map { $0[j] }))
166 + }
167 + return ZQBayesResult(
168 + coefficients: coefficients,
169 + sigma: summarize("sigma", sigmaDraws),
170 + observationCount: n,
171 + mcmcSize: mcmcSize,
172 + burnIn: burnIn
173 + )
174 + }
175 +
176 + // MARK: - Small dense Cholesky helpers (k×k, column-major)
177 +
178 + private static func choleskyLower(_ a: [Double], k: Int) throws -> [Double] {
179 + var l = [Double](repeating: 0, count: k * k)
180 + for j in 0..<k {
181 + var diagonal = a[j * k + j]
182 + for p in 0..<j { diagonal -= l[p * k + j] * l[p * k + j] }
183 + guard diagonal > 0 else {
184 + throw ZQStatsError("bayes: posterior precision is not positive definite")
185 + }
186 + let root = diagonal.squareRoot()
187 + l[j * k + j] = root
188 + for i in (j + 1)..<k {
189 + var value = a[j * k + i]
190 + for p in 0..<j { value -= l[p * k + i] * l[p * k + j] }
191 + l[j * k + i] = value / root
192 + }
193 + }
194 + return l
195 + }
196 +
197 + /// Solves Λx = b given the lower Cholesky factor (forward then back).
198 + private static func choleskySolve(
199 + _ l: [Double], k: Int, rhs: [Double]
200 + ) throws -> [Double] {
201 + var z = [Double](repeating: 0, count: k)
202 + for i in 0..<k {
203 + var value = rhs[i]
204 + for p in 0..<i { value -= l[p * k + i] * z[p] }
205 + z[i] = value / l[i * k + i]
206 + }
207 + return try backSolveTransposed(l, k: k, rhs: z)
208 + }
209 +
210 + /// Solves Lᵀx = b (also the covariance-square-root transform for
211 + /// drawing from N(0, Λ⁻¹)).
212 + private static func backSolveTransposed(
213 + _ l: [Double], k: Int, rhs: [Double]
214 + ) throws -> [Double] {
215 + var solution = [Double](repeating: 0, count: k)
216 + for i in stride(from: k - 1, through: 0, by: -1) {
217 + var value = rhs[i]
218 + for p in (i + 1)..<k { value -= l[i * k + p] * solution[p] }
219 + solution[i] = value / l[i * k + i]
220 + }
221 + return solution
222 + }
223 +}
added MetrikaKit/Tests/MetrikaKitTests/BayesTests.swift +148 −0
@@ -0,0 +1,148 @@
1 +//
2 +// BayesTests.swift
3 +// Metrika
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +//
9 +
10 +import Foundation
11 +import Testing
12 +import ZQEngine
13 +import ZQGPU
14 +import ZQStats
15 +
16 +@Suite("Bayesian regression", .serialized)
17 +struct BayesTests {
18 +
19 + @Test("Philox stream normals and gammas match their theoretical moments")
20 + func variateGenerators() {
21 + var stream = PhiloxStream(seed: 42)
22 + let n = 200_000
23 +
24 + var normalSum = 0.0, normalSquares = 0.0
25 + for _ in 0..<n {
26 + let z = stream.nextNormal()
27 + normalSum += z
28 + normalSquares += z * z
29 + }
30 + #expect(abs(normalSum / Double(n)) < 0.01)
31 + #expect(abs(normalSquares / Double(n) - 1) < 0.02)
32 +
33 + // Gamma(3, rate 2): mean 1.5, variance 0.75.
34 + var gammaSum = 0.0, gammaSquares = 0.0
35 + for _ in 0..<n {
36 + let g = stream.nextGamma(shape: 3, rate: 2)
37 + gammaSum += g
38 + gammaSquares += g * g
39 + }
40 + let gammaMean = gammaSum / Double(n)
41 + let gammaVariance = gammaSquares / Double(n) - gammaMean * gammaMean
42 + #expect(abs(gammaMean - 1.5) < 0.01, "gamma mean \(gammaMean)")
43 + #expect(abs(gammaVariance - 0.75) < 0.02, "gamma variance \(gammaVariance)")
44 +
45 + // Shape < 1 boost path: Gamma(0.5, rate 1): mean 0.5.
46 + var smallSum = 0.0
47 + for _ in 0..<n { smallSum += stream.nextGamma(shape: 0.5, rate: 1) }
48 + #expect(abs(smallSum / Double(n) - 0.5) < 0.01)
49 + }
50 +
51 + @Test("flat priors recover OLS: posterior mean ≈ b̂, sd ≈ SE")
52 + func flatPriorAgreement() throws {
53 + // Synthetic data with a known DGP.
54 + var stream = PhiloxStream(seed: 7)
55 + let n = 300
56 + let x = (0..<n).map { _ in 5 + 10 * stream.nextUniform() }
57 + let y = x.map { 2 + 0.5 * $0 + 0.8 * stream.nextNormal() }
58 +
59 + let ols = try ZQOLS.fit(y: y, predictors: [("x", x)])
60 + let bayes = try ZQBayesianRegression.fitGibbs(
61 + y: y, predictors: [("x", x)],
62 + mcmcSize: 40_000, burnIn: 4_000, seed: 42,
63 + coefficientPriorVariance: 1e8
64 + )
65 +
66 + for (posterior, frequentist) in zip(bayes.coefficients, ols.coefficients) {
67 + // Monte-Carlo error with 40k (autocorrelated) draws: assert
68 + // within 5% of a posterior SD.
69 + let toleranceMean = 0.05 * frequentist.standardError
70 + #expect(
71 + abs(posterior.posteriorMean - frequentist.estimate) < toleranceMean,
72 + "mean[\(posterior.name)]: \(posterior.posteriorMean) vs \(frequentist.estimate)"
73 + )
74 + let sdRatio = posterior.posteriorSD / frequentist.standardError
75 + #expect(
76 + sdRatio > 0.9 && sdRatio < 1.15,
77 + "sd[\(posterior.name)] ratio \(sdRatio)"
78 + )
79 + // The 95% credible interval brackets the OLS estimate.
80 + #expect(posterior.credibleLower < frequentist.estimate)
81 + #expect(posterior.credibleUpper > frequentist.estimate)
82 + }
83 + // σ posterior around the DGP value 0.8.
84 + #expect(abs(bayes.sigma.posteriorMean - 0.8) < 0.1)
85 + }
86 +
87 + @Test("a tight prior shrinks coefficients toward zero")
88 + func priorShrinkage() throws {
89 + var stream = PhiloxStream(seed: 9)
90 + let n = 60
91 + let x = (0..<n).map { _ in stream.nextUniform() * 10 }
92 + let y = x.map { 3 * $0 + stream.nextNormal() }
93 +
94 + let flat = try ZQBayesianRegression.fitGibbs(
95 + y: y, predictors: [("x", x)],
96 + mcmcSize: 5_000, burnIn: 1_000, seed: 42,
97 + coefficientPriorVariance: 1e6
98 + )
99 + let tight = try ZQBayesianRegression.fitGibbs(
100 + y: y, predictors: [("x", x)],
101 + mcmcSize: 5_000, burnIn: 1_000, seed: 42,
102 + coefficientPriorVariance: 0.01
103 + )
104 + #expect(
105 + abs(tight.coefficients[0].posteriorMean)
106 + < abs(flat.coefficients[0].posteriorMean)
107 + )
108 + #expect(abs(flat.coefficients[0].posteriorMean - 3) < 0.2)
109 + }
110 +
111 + @Test("chains are reproducible for a fixed seed")
112 + func determinism() throws {
113 + var stream = PhiloxStream(seed: 3)
114 + let x = (0..<50).map { _ in stream.nextUniform() }
115 + let y = x.map { $0 + 0.1 * stream.nextNormal() }
116 +
117 + let a = try ZQBayesianRegression.fitGibbs(
118 + y: y, predictors: [("x", x)], mcmcSize: 1_000, burnIn: 100, seed: 42
119 + )
120 + let b = try ZQBayesianRegression.fitGibbs(
121 + y: y, predictors: [("x", x)], mcmcSize: 1_000, burnIn: 100, seed: 42
122 + )
123 + let c = try ZQBayesianRegression.fitGibbs(
124 + y: y, predictors: [("x", x)], mcmcSize: 1_000, burnIn: 100, seed: 43
125 + )
126 + #expect(a == b)
127 + #expect(a != c)
128 + }
129 +
130 + @Test("bayes prefix through the console")
131 + func consoleCommand() async throws {
132 + let fixtures = try Fixtures()
133 + let session = try ZQSession(discoverUserCommands: false)
134 + _ = try await session.execute("use \(fixtures.datasetURL.path)")
135 + _ = try await session.execute("gen log_rev = ln(revenue)")
136 +
137 + let ols = try await session.execute("reg log_rev price")
138 + let bayes = try await session.execute(
139 + "bayes, mcmcsize(20000) burnin(2000) seed(42) normalprior(100000000): reg log_rev price"
140 + )
141 + let posterior = try #require(bayes.scalars["b_price"])
142 + let frequentist = try #require(ols.scalars["b_price"])
143 + let se = try #require(ols.scalars["se_price"])
144 + #expect(abs(posterior - frequentist) < 0.05 * se)
145 + #expect(bayes.text.contains("Bayesian linear regression"))
146 + #expect(bayes.scalars["b_sigma"] != nil)
147 + }
148 +}
modified MetrikaKit/Tests/MetrikaKitTests/EngineTests.swift +1 −1
@@ -206,7 +206,7 @@ struct EngineTests {
206 206 "replace", "drop", "keep", "summarize", "tabulate", "correlate",
207 207 "regress", "logit", "probit", "poisson", "ivregress", "xtreg",
208 208 "xtset", "lasso", "elasticnet", "bootstrap", "permute", "predict",
209 − "margins", "scatter", "histogram", "kdensity", "graph", "display",
209 + "margins", "bayes", "scatter", "histogram", "kdensity", "graph", "display",
210 210 "set", "log", "help", "zscore",
211 211 ] {
212 212 #expect(documented.contains(verb), Comment(rawValue: "missing manual entry: \(verb)"))
213 213