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%
11.9 KB · 338 lines swift
Raw Blame History
1//2//  OLS.swift3//  Metrika4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import Foundation1112/// Variance estimator selection for linear regression.13public enum ZQVarianceEstimator: Equatable, Sendable {14    /// Classical homoskedastic σ²(X'X)⁻¹.15    case classical16    /// Heteroskedasticity-consistent sandwich estimators. `robust` in17    /// Stata parlance maps to `.hc1`.18    case hc0, hc1, hc2, hc319    /// Cluster-robust: `cluster` holds one group label per observation.20    case cluster([Int])21}2223/// One row of a regression coefficient table.24public struct ZQCoefficient: Equatable, Sendable {25    public var name: String26    public var estimate: Double27    public var standardError: Double28    public var tStatistic: Double29    public var pValue: Double30    public var confidenceLower: Double31    public var confidenceUpper: Double3233    public init(34        name: String, estimate: Double, standardError: Double,35        tStatistic: Double, pValue: Double,36        confidenceLower: Double, confidenceUpper: Double37    ) {38        self.name = name39        self.estimate = estimate40        self.standardError = standardError41        self.tStatistic = tStatistic42        self.pValue = pValue43        self.confidenceLower = confidenceLower44        self.confidenceUpper = confidenceUpper45    }46}4748/// Full OLS fit result.49public struct ZQOLSResult: Equatable, Sendable {50    public var coefficients: [ZQCoefficient]51    public var observationCount: Int52    public var degreesOfFreedomResidual: Int53    /// Degrees of freedom used for t statistics and CIs: n−k classically,54    /// G−1 under clustering (Stata convention).55    public var inferenceDF: Double56    public var rSquared: Double57    public var adjustedRSquared: Double58    public var rootMSE: Double59    public var fStatistic: Double?60    public var fPValue: Double?61    public var fDF: (Double, Double)? {62        get { fDFStorage.map { ($0.first, $0.second) } }63        set { fDFStorage = newValue.map { FDF($0.0, $0.1) } }64    }65    public var clusterCount: Int?66    public var residuals: [Double]67    /// Full covariance matrix of the coefficients, column-major k×k,68    /// aligned with `coefficients` — needed by delta-method69    /// post-estimation (margins).70    public var vce: [Double]7172    private struct FDF: Equatable, Sendable {73        let first: Double74        let second: Double75        init(_ first: Double, _ second: Double) {76            self.first = first77            self.second = second78        }79        var asTuple: (Double, Double) { (first, second) }80    }8182    private var fDFStorage: FDF?8384    public init(85        coefficients: [ZQCoefficient],86        observationCount: Int,87        degreesOfFreedomResidual: Int,88        inferenceDF: Double,89        rSquared: Double,90        adjustedRSquared: Double,91        rootMSE: Double,92        fStatistic: Double?,93        fPValue: Double?,94        fDF: (Double, Double)?,95        clusterCount: Int?,96        residuals: [Double],97        vce: [Double] = []98    ) {99        self.coefficients = coefficients100        self.observationCount = observationCount101        self.degreesOfFreedomResidual = degreesOfFreedomResidual102        self.inferenceDF = inferenceDF103        self.rSquared = rSquared104        self.adjustedRSquared = adjustedRSquared105        self.rootMSE = rootMSE106        self.fStatistic = fStatistic107        self.fPValue = fPValue108        self.fDFStorage = fDF.map { FDF($0.0, $0.1) }109        self.clusterCount = clusterCount110        self.residuals = residuals111        self.vce = vce112    }113}114115public struct ZQStatsError: Error, Equatable, Sendable, CustomStringConvertible {116    public var message: String117    public init(_ message: String) { self.message = message }118    public var description: String { message }119}120121/// Ordinary least squares on fully observed (already listwise-deleted)122/// data. Solved by LAPACK QR (dgeqrf/dormqr) — X'X is never formed for123/// the coefficient path (CLAUDE.md §5).124public enum ZQOLS {125126    /// - Parameters:127    ///   - y: response, length n128    ///   - predictors: named regressor columns (length n each), intercept129    ///     excluded — it is appended automatically as `_cons` unless130    ///     `includeConstant` is false.131    public static func fit(132        y: [Double],133        predictors: [(name: String, values: [Double])],134        includeConstant: Bool = true,135        variance: ZQVarianceEstimator = .classical,136        confidenceLevel: Double = 0.95137    ) throws -> ZQOLSResult {138        let n = y.count139        var names = predictors.map(\.name)140        if includeConstant { names.append("_cons") }141        let k = names.count142        guard n > k else {143            throw ZQStatsError("insufficient observations: n=\(n), k=\(k)")144        }145        for column in predictors where column.values.count != n {146            throw ZQStatsError("regressor '\(column.name)' has wrong length")147        }148149        // Design matrix, column-major.150        var x = [Double]()151        x.reserveCapacity(n * k)152        for column in predictors { x.append(contentsOf: column.values) }153        if includeConstant { x.append(contentsOf: [Double](repeating: 1, count: n)) }154155        let qr: LinearAlgebra.QR156        let beta: [Double]157        let xtxInverse: [Double]158        do {159            qr = try LinearAlgebra.QR(matrix: x, rows: n, cols: k)160            beta = try qr.solve(rhs: y)161            xtxInverse = try qr.crossProductInverse()162        } catch {163            throw ZQStatsError(164                "design matrix is rank deficient or QR failed: \(error)"165            )166        }167168        // Residuals and fit statistics.169        let fitted = LinearAlgebra.multiply(matrix: x, rows: n, cols: k, vector: beta)170        var residuals = [Double](repeating: 0, count: n)171        var rss = 0.0172        for i in 0..<n {173            residuals[i] = y[i] - fitted[i]174            rss += residuals[i] * residuals[i]175        }176        let meanY = y.reduce(0, +) / Double(n)177        let tss = y.reduce(0) { $0 + ($1 - meanY) * ($1 - meanY) }178        let dfResidual = n - k179        let sigma2 = rss / Double(dfResidual)180        let rSquared = tss > 0 ? 1 - rss / tss : .nan181        let dfModel = k - (includeConstant ? 1 : 0)182        let adjustedRSquared =183            1 - (1 - rSquared) * Double(n - (includeConstant ? 1 : 0)) / Double(dfResidual)184185        // Covariance matrix.186        var clusterCount: Int? = nil187        let vce: [Double]188        switch variance {189        case .classical:190            vce = xtxInverse.map { $0 * sigma2 }191192        case .hc0, .hc1, .hc2, .hc3:193            let leverage: [Double]?194            if variance == .hc2 || variance == .hc3 {195                let q = try qr.thinQ()196                var h = [Double](repeating: 0, count: n)197                for j in 0..<k {198                    for i in 0..<n {199                        let value = q[j * n + i]200                        h[i] += value * value201                    }202                }203                leverage = h204            } else {205                leverage = nil206            }207            var weights = [Double](repeating: 0, count: n)208            for i in 0..<n {209                let e2 = residuals[i] * residuals[i]210                switch variance {211                case .hc0: weights[i] = e2212                case .hc1: weights[i] = e2 * Double(n) / Double(dfResidual)213                case .hc2: weights[i] = e2 / (1 - leverage![i])214                case .hc3: weights[i] = e2 / ((1 - leverage![i]) * (1 - leverage![i]))215                default: break216                }217            }218            let meat = weightedCrossProduct(x: x, n: n, k: k, weights: weights)219            vce = LinearAlgebra.sandwich(bread: xtxInverse, meat: meat, k: k)220221        case .cluster(let groups):222            guard groups.count == n else {223                throw ZQStatsError("cluster variable has wrong length")224            }225            var scores: [Int: [Double]] = [:]226            for i in 0..<n {227                var u = scores[groups[i]] ?? [Double](repeating: 0, count: k)228                for j in 0..<k {229                    u[j] += x[j * n + i] * residuals[i]230                }231                scores[groups[i]] = u232            }233            let g = scores.count234            guard g > 1 else {235                throw ZQStatsError("cluster variable must define at least 2 groups")236            }237            clusterCount = g238            var meat = [Double](repeating: 0, count: k * k)239            for u in scores.values {240                for j in 0..<k {241                    for i in 0..<k {242                        meat[j * k + i] += u[i] * u[j]243                    }244                }245            }246            // Stata regress small-sample factor: G/(G−1) · (n−1)/(n−k).247            let scale = Double(g) / Double(g - 1) * Double(n - 1) / Double(dfResidual)248            for index in meat.indices { meat[index] *= scale }249            vce = LinearAlgebra.sandwich(bread: xtxInverse, meat: meat, k: k)250        }251252        // Inference: t statistics against n−k df, or G−1 under clustering.253        let inferenceDF: Double254        if let g = clusterCount {255            inferenceDF = Double(g - 1)256        } else {257            inferenceDF = Double(dfResidual)258        }259        let tCritical = ZQDistributions.studentTQuantile(260            0.5 + confidenceLevel / 2, df: inferenceDF261        )262263        var coefficients: [ZQCoefficient] = []264        for j in 0..<k {265            let se = vce[j * k + j].squareRoot()266            let t = beta[j] / se267            coefficients.append(ZQCoefficient(268                name: names[j],269                estimate: beta[j],270                standardError: se,271                tStatistic: t,272                pValue: ZQDistributions.tTestPValue(t, df: inferenceDF),273                confidenceLower: beta[j] - tCritical * se,274                confidenceUpper: beta[j] + tCritical * se275            ))276        }277278        // Overall Wald F test on all non-constant coefficients.279        var fStatistic: Double? = nil280        var fPValue: Double? = nil281        var fDF: (Double, Double)? = nil282        if dfModel > 0 {283            let restricted = Array(0..<dfModel)284            var subVce = [Double](repeating: 0, count: dfModel * dfModel)285            var subBeta = [Double](repeating: 0, count: dfModel)286            for (jj, j) in restricted.enumerated() {287                subBeta[jj] = beta[j]288                for (ii, i) in restricted.enumerated() {289                    subVce[jj * dfModel + ii] = vce[j * k + i]290                }291            }292            if let solved = try? LinearAlgebra.solveSymmetric(subVce, k: dfModel, rhs: subBeta) {293                let wald = zip(subBeta, solved).reduce(0) { $0 + $1.0 * $1.1 }294                let f = wald / Double(dfModel)295                fStatistic = f296                fDF = (Double(dfModel), inferenceDF)297                fPValue = ZQDistributions.fTestPValue(298                    f, df1: Double(dfModel), df2: inferenceDF299                )300            }301        }302303        return ZQOLSResult(304            coefficients: coefficients,305            observationCount: n,306            degreesOfFreedomResidual: dfResidual,307            inferenceDF: inferenceDF,308            rSquared: rSquared,309            adjustedRSquared: adjustedRSquared,310            rootMSE: sigma2.squareRoot(),311            fStatistic: fStatistic,312            fPValue: fPValue,313            fDF: fDF,314            clusterCount: clusterCount,315            residuals: residuals,316            vce: vce317        )318    }319320    /// meat = Σ_i w_i · x_i x_iᵀ for column-major X.321    private static func weightedCrossProduct(322        x: [Double], n: Int, k: Int, weights: [Double]323    ) -> [Double] {324        var meat = [Double](repeating: 0, count: k * k)325        for j in 0..<k {326            for i in j..<k {327                var sum = 0.0328                for row in 0..<n {329                    sum += weights[row] * x[i * n + row] * x[j * n + row]330                }331                meat[j * k + i] = sum332                meat[i * k + j] = sum333            }334        }335        return meat336    }337}338