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%
1//2// GradientBoosting.swift3// Metrika4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import Foundation1112/// Gradient-boosted regression trees with squared-error loss, following13/// xgboost's exact-greedy algorithm precisely so results cross-validate:14///15/// gain = ½[G_L²/(H_L+λ) + G_R²/(H_R+λ) − (G_L+G_R)²/(H_L+H_R+λ)] − γ16/// leaf = −G/(H+λ)17///18/// with g_i = ŷ_i − y_i and h_i = 1 for squared loss. Splits are placed19/// at midpoints between consecutive distinct feature values (xgboost's20/// convention), `x < split` goes left, and missing features follow the21/// default-left rule. No subsampling — training is deterministic.22public struct ZQBoostModel: Equatable, Sendable {23 public struct Node: Equatable, Sendable {24 /// Feature index for internal nodes; nil marks a leaf.25 public var feature: Int?26 public var split: Double27 public var left: Int28 public var right: Int29 public var value: Double // leaf weight30 }3132 public struct Tree: Equatable, Sendable {33 public var nodes: [Node]3435 public func predict(_ features: [Double]) -> Double {36 var index = 037 while let feature = nodes[index].feature {38 let value = features[feature]39 index = value.isNaN || value < nodes[index].split40 ? nodes[index].left41 : nodes[index].right42 }43 return nodes[index].value44 }45 }4647 public var featureNames: [String]48 public var baseScore: Double49 public var learningRate: Double50 public var trees: [Tree]5152 /// Prediction for one observation's feature vector (ordered as53 /// `featureNames`; NaN = missing).54 public func predict(_ features: [Double]) -> Double {55 var result = baseScore56 for tree in trees {57 result += learningRate * tree.predict(features)58 }59 return result60 }61}6263public enum ZQGradientBoosting {6465 /// - Parameters:66 /// - rounds: number of trees.67 /// - learningRate: shrinkage η applied to every leaf.68 /// - maxDepth: maximum tree depth (1 = stumps).69 /// - lambda: L2 regularization on leaf weights (xgboost default 1).70 /// - gamma: minimum gain to split (default 0).71 /// - minChildWeight: minimum hessian sum per child (= observation72 /// count under squared loss; default 1).73 /// - baseScore: initial prediction; nil = mean of y.74 public static func fit(75 y: [Double],76 features: [(name: String, values: [Double])],77 rounds: Int,78 learningRate: Double = 0.3,79 maxDepth: Int = 6,80 lambda: Double = 1,81 gamma: Double = 0,82 minChildWeight: Double = 1,83 baseScore: Double? = nil84 ) throws -> ZQBoostModel {85 let n = y.count86 let p = features.count87 guard p > 0 else { throw ZQStatsError("boost: features required") }88 guard n > 1 else { throw ZQStatsError("boost: insufficient observations") }89 guard rounds > 0 else { throw ZQStatsError("boost: rounds() must be positive") }90 guard maxDepth >= 1 else { throw ZQStatsError("boost: maxdepth() must be ≥ 1") }91 for column in features where column.values.count != n {92 throw ZQStatsError("boost: feature '\(column.name)' has wrong length")93 }9495 // Pre-sorted feature indices (missing excluded; they default left).96 var sortedIndices = [[Int]](repeating: [], count: p)97 for j in 0..<p {98 let values = features[j].values99 sortedIndices[j] = (0..<n)100 .filter { !values[$0].isNaN }101 .sorted { values[$0] < values[$1] }102 }103104 let base = baseScore ?? y.reduce(0, +) / Double(n)105 var predictions = [Double](repeating: base, count: n)106 var trees: [ZQBoostModel.Tree] = []107108 for _ in 0..<rounds {109 // Squared loss: g = ŷ − y, h = 1.110 let gradients = (0..<n).map { predictions[$0] - y[$0] }111 var tree = ZQBoostModel.Tree(nodes: [])112 buildNode(113 rows: Array(0..<n),114 depth: 0,115 features: features,116 sortedIndices: sortedIndices,117 gradients: gradients,118 maxDepth: maxDepth,119 lambda: lambda,120 gamma: gamma,121 minChildWeight: minChildWeight,122 into: &tree.nodes123 )124 for i in 0..<n {125 let vector = (0..<p).map { features[$0].values[i] }126 predictions[i] += learningRate * tree.predict(vector)127 }128 trees.append(tree)129 }130131 return ZQBoostModel(132 featureNames: features.map(\.name),133 baseScore: base,134 learningRate: learningRate,135 trees: trees136 )137 }138139 /// Recursively grows one node; returns its index in `nodes`.140 @discardableResult141 private static func buildNode(142 rows: [Int],143 depth: Int,144 features: [(name: String, values: [Double])],145 sortedIndices: [[Int]],146 gradients: [Double],147 maxDepth: Int,148 lambda: Double,149 gamma: Double,150 minChildWeight: Double,151 into nodes: inout [ZQBoostModel.Node]152 ) -> Int {153 let rowSet = Set(rows)154 let gTotal = rows.reduce(0.0) { $0 + gradients[$1] }155 let hTotal = Double(rows.count)156157 let index = nodes.count158 nodes.append(ZQBoostModel.Node(159 feature: nil, split: 0, left: -1, right: -1,160 value: -gTotal / (hTotal + lambda)161 ))162 guard depth < maxDepth, rows.count > 1 else { return index }163164 // Exact greedy split search over every feature.165 let parentScore = gTotal * gTotal / (hTotal + lambda)166 var bestGain = 0.0167 var bestFeature = -1168 var bestSplit = 0.0169 for j in 0..<features.count {170 let values = features[j].values171 let ordered = sortedIndices[j].filter { rowSet.contains($0) }172 guard ordered.count > 1 else { continue }173 let gMissing = gTotal - ordered.reduce(0.0) { $0 + gradients[$1] }174 let hMissing = hTotal - Double(ordered.count)175176 var gLeft = gMissing // missing rows follow the left child177 var hLeft = hMissing178 for position in 0..<(ordered.count - 1) {179 gLeft += gradients[ordered[position]]180 hLeft += 1181 let current = values[ordered[position]]182 let next = values[ordered[position + 1]]183 guard next > current else { continue }184 let gRight = gTotal - gLeft185 let hRight = hTotal - hLeft186 guard hLeft >= minChildWeight, hRight >= minChildWeight else { continue }187 let gain = 0.5 * (188 gLeft * gLeft / (hLeft + lambda)189 + gRight * gRight / (hRight + lambda)190 - parentScore191 ) - gamma192 if gain > bestGain {193 bestGain = gain194 bestFeature = j195 bestSplit = (current + next) / 2196 }197 }198 }199 guard bestFeature >= 0 else { return index }200201 let values = features[bestFeature].values202 let leftRows = rows.filter { values[$0].isNaN || values[$0] < bestSplit }203 let rightRows = rows.filter { !values[$0].isNaN && values[$0] >= bestSplit }204 guard !leftRows.isEmpty, !rightRows.isEmpty else { return index }205206 nodes[index].feature = bestFeature207 nodes[index].split = bestSplit208 nodes[index].left = buildNode(209 rows: leftRows, depth: depth + 1, features: features,210 sortedIndices: sortedIndices, gradients: gradients,211 maxDepth: maxDepth, lambda: lambda, gamma: gamma,212 minChildWeight: minChildWeight, into: &nodes213 )214 nodes[index].right = buildNode(215 rows: rightRows, depth: depth + 1, features: features,216 sortedIndices: sortedIndices, gradients: gradients,217 maxDepth: maxDepth, lambda: lambda, gamma: gamma,218 minChildWeight: minChildWeight, into: &nodes219 )220 return index221 }222}223