SPB Git

spb/metrika Public

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

Swift 92.4% HTML 3.3% R 3% Shell 1.3%
8.3 KB · 224 lines swift
Raw Blame History
1//2//  BayesianRegression.swift3//  Metrika4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import Foundation11import ZQGPU1213/// Bayesian linear regression by Gibbs sampling (CLAUDE.md §1 MCMC14/// module). Semi-conjugate model with Stata `bayes` default priors:15///16///   y | β, σ²  ~  N(Xβ, σ²I)17///   β_j        ~  N(0, τ²),        τ² = 10 000 by default18///   σ²         ~  InvGamma(a₀, b₀), a₀ = b₀ = 0.01 by default19///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 fully25/// determines the chain.26public struct ZQBayesCoefficient: Equatable, Sendable {27    public var name: String28    public var posteriorMean: Double29    public var posteriorSD: Double30    public var credibleLower: Double   // equal-tailed 2.5%31    public var credibleUpper: Double   // 97.5%32}3334public struct ZQBayesResult: Equatable, Sendable {35    public var coefficients: [ZQBayesCoefficient]36    /// Posterior summary of σ (the residual standard deviation).37    public var sigma: ZQBayesCoefficient38    public var observationCount: Int39    public var mcmcSize: Int40    public var burnIn: Int41}4243public enum ZQBayesianRegression {4445    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.0155    ) throws -> ZQBayesResult {56        let n = y.count57        var names = predictors.map(\.name)58        if includeConstant { names.append("_cons") }59        let k = names.count60        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") }6465        // 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)) }7576        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.081                for row in 0..<n { sum += x[i * n + row] * x[j * n + row] }82                xtx[j * k + i] = sum83                xtx[i * k + j] = sum84            }85            var sum = 0.086            for row in 0..<n { sum += x[j * n + row] * y[row] }87            xty[j] = sum88        }8990        // 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)9495        let total = burnIn + mcmcSize96        var betaDraws = [[Double]](repeating: [], count: mcmcSize)97        var sigmaDraws = [Double](repeating: 0, count: mcmcSize)98        let priorPrecision = 1 / coefficientPriorVariance99100        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] / sigma2106                }107                lambda[j * k + j] += priorPrecision108            }109            let rhs = xty.map { $0 / sigma2 }110111            // Cholesky of Λ (lower L, column-major): solve for the mean and112            // 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] }119120            // σ² | β: InvGamma(a₀ + n/2, b₀ + RSS/2).121            var rss = 0.0122            for row in 0..<n {123                var fitted = 0.0124                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 / 2130            )131            sigma2 = 1 / precision132133            if iteration >= burnIn {134                betaDraws[iteration - burnIn] = beta135                sigmaDraws[iteration - burnIn] = sigma2.squareRoot()136            }137        }138139        // 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] * fraction151                }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        }162163        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: burnIn173        )174    }175176    // MARK: - Small dense Cholesky helpers (k×k, column-major)177178    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] = root188            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 / root192            }193        }194        return l195    }196197    /// 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    }209210    /// Solves Lᵀx = b (also the covariance-square-root transform for211    /// 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 solution222    }223}224