// // GradientBoosting.swift // Metrika // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // import Foundation /// Gradient-boosted regression trees with squared-error loss, following /// xgboost's exact-greedy algorithm precisely so results cross-validate: /// /// gain = ½[G_L²/(H_L+λ) + G_R²/(H_R+λ) − (G_L+G_R)²/(H_L+H_R+λ)] − γ /// leaf = −G/(H+λ) /// /// with g_i = ŷ_i − y_i and h_i = 1 for squared loss. Splits are placed /// at midpoints between consecutive distinct feature values (xgboost's /// convention), `x < split` goes left, and missing features follow the /// default-left rule. No subsampling — training is deterministic. public struct ZQBoostModel: Equatable, Sendable { public struct Node: Equatable, Sendable { /// Feature index for internal nodes; nil marks a leaf. public var feature: Int? public var split: Double public var left: Int public var right: Int public var value: Double // leaf weight } public struct Tree: Equatable, Sendable { public var nodes: [Node] public func predict(_ features: [Double]) -> Double { var index = 0 while let feature = nodes[index].feature { let value = features[feature] index = value.isNaN || value < nodes[index].split ? nodes[index].left : nodes[index].right } return nodes[index].value } } public var featureNames: [String] public var baseScore: Double public var learningRate: Double public var trees: [Tree] /// Prediction for one observation's feature vector (ordered as /// `featureNames`; NaN = missing). public func predict(_ features: [Double]) -> Double { var result = baseScore for tree in trees { result += learningRate * tree.predict(features) } return result } } public enum ZQGradientBoosting { /// - Parameters: /// - rounds: number of trees. /// - learningRate: shrinkage η applied to every leaf. /// - maxDepth: maximum tree depth (1 = stumps). /// - lambda: L2 regularization on leaf weights (xgboost default 1). /// - gamma: minimum gain to split (default 0). /// - minChildWeight: minimum hessian sum per child (= observation /// count under squared loss; default 1). /// - baseScore: initial prediction; nil = mean of y. public static func fit( y: [Double], features: [(name: String, values: [Double])], rounds: Int, learningRate: Double = 0.3, maxDepth: Int = 6, lambda: Double = 1, gamma: Double = 0, minChildWeight: Double = 1, baseScore: Double? = nil ) throws -> ZQBoostModel { let n = y.count let p = features.count guard p > 0 else { throw ZQStatsError("boost: features required") } guard n > 1 else { throw ZQStatsError("boost: insufficient observations") } guard rounds > 0 else { throw ZQStatsError("boost: rounds() must be positive") } guard maxDepth >= 1 else { throw ZQStatsError("boost: maxdepth() must be ≥ 1") } for column in features where column.values.count != n { throw ZQStatsError("boost: feature '\(column.name)' has wrong length") } // Pre-sorted feature indices (missing excluded; they default left). var sortedIndices = [[Int]](repeating: [], count: p) for j in 0..
Int {
let rowSet = Set(rows)
let gTotal = rows.reduce(0.0) { $0 + gradients[$1] }
let hTotal = Double(rows.count)
let index = nodes.count
nodes.append(ZQBoostModel.Node(
feature: nil, split: 0, left: -1, right: -1,
value: -gTotal / (hTotal + lambda)
))
guard depth < maxDepth, rows.count > 1 else { return index }
// Exact greedy split search over every feature.
let parentScore = gTotal * gTotal / (hTotal + lambda)
var bestGain = 0.0
var bestFeature = -1
var bestSplit = 0.0
for j in 0..