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): gradient-boosted regression trees (boost command)

- ZQGradientBoosting: exact-greedy trees cloning xgboost's algorithm —
  gain 1/2[GL2/(HL+l) + GR2/(HR+l) - G2/(H+l)] - gamma, leaf -G/(H+l),
  midpoint splits between consecutive distinct values, missing rows
  default left, pre-sorted feature indices; squared loss, deterministic
  (no subsampling)
- engine: 'boost y x…, rounds(#) [eta() maxdepth() lambda()]' reporting
  training R2/RMSE with an in-sample caveat; model stored in the
  estimation state (Kind.boost) so predict routes through the trees
  (missing features follow the default direction); margins and GLM
  statistics refused after boost
- validation: per-observation prediction parity with R xgboost 3.2
  (exact method, base_score = mean) at 1e-4 (xgboost is float32
  internally); a stump finds the exact midpoint split with lambda 0;
  training loss decreases monotonically in rounds
- manual entry + coverage test
- 115 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 eb00f69

10 changed files +524 −1

modified MetrikaKit/Sources/ZQEngine/CommandHelp.swift +14 −0
@@ -218,6 +218,20 @@ public enum ZQCommandReference {
218 218 notes: "Matches R glmnet, including its gaussian y-standardization convention (ridge penalties scale with sd of the response)."
219 219 ),
220 220
221 + ZQCommandDoc(
222 + verb: "boost", category: "Machine learning",
223 + summary: "Gradient-boosted regression trees (xgboost-style exact greedy, squared loss). Deterministic — no subsampling.",
224 + syntax: "boost depvar features, rounds(#) [eta(#) maxdepth(#) lambda(#)]",
225 + options: [
226 + ("rounds(#)", "number of trees"),
227 + ("eta(#)", "learning rate, default 0.3"),
228 + ("maxdepth(#)", "tree depth, default 6"),
229 + ("lambda(#)", "L2 regularization on leaf weights, default 1"),
230 + ],
231 + examples: ["boost log_rev z1 z2 orders, rounds(100) eta(0.1) maxdepth(3)", "predict yhat"],
232 + notes: "Matches R xgboost predictions on identical settings. Training R² is in-sample — expect it to be optimistic."
233 + ),
234 +
221 235 // ------------------------------------------ Resampling & simulation
222 236 ZQCommandDoc(
223 237 verb: "bootstrap", category: "Resampling & simulation",
modified MetrikaKit/Sources/ZQEngine/Session.swift +140 −0
@@ -197,6 +197,7 @@ public actor ZQSession {
197 197 case "predict": return try handlePredict(command)
198 198 case "margins": return try handleMargins(command)
199 199 case "elasticnet": return try handleElasticNet(command, defaultAlpha: nil)
200 + case "boost": return try handleBoost(command)
200 201 case "lasso": return try handleElasticNet(command, defaultAlpha: 1)
201 202 case "logit": return try handleGLM(command, family: .logit)
202 203 case "probit": return try handleGLM(command, family: .probit)
@@ -603,6 +604,7 @@ public actor ZQSession {
603 604 enum Kind: Equatable, Sendable {
604 605 case ols, iv
605 606 case glm(ZQGLMFamily)
607 + case boost(ZQBoostModel)
606 608 }
607 609
608 610 var kind: Kind
@@ -1502,6 +1504,14 @@ public actor ZQSession {
1502 1504 throw ZQEngineError("predict: syntax is 'predict newvar [, statistic]'")
1503 1505 }
1504 1506
1507 + // Boosted models predict through their trees, not a linear xb.
1508 + if case .boost(let model) = estimation.kind {
1509 + return try predictBoost(
1510 + model: model, target: target, command: command,
1511 + responseName: estimation.responseName
1512 + )
1513 + }
1514 +
1505 1515 // Statistic selection and validation against the model kind.
1506 1516 enum Statistic { case xb, residuals, probability, mean }
1507 1517 var statistic: Statistic
@@ -1509,6 +1519,7 @@ public actor ZQSession {
1509 1519 case .ols, .iv: statistic = .xb
1510 1520 case .glm(.logit), .glm(.probit): statistic = .probability
1511 1521 case .glm(.poisson): statistic = .mean
1522 + case .boost: fatalError("handled above")
1512 1523 }
1513 1524 for option in command.options {
1514 1525 switch option.name {
@@ -1584,6 +1595,8 @@ public actor ZQSession {
1584 1595 result[i] = yValues[i] - ZQDistributions.normalCDF(xb[i])
1585 1596 case .glm(.poisson):
1586 1597 result[i] = yValues[i] - Foundation.exp(xb[i])
1598 + case .boost:
1599 + break // handled by predictBoost
1587 1600 }
1588 1601 }
1589 1602 for i in 0..<n where yMissing[i] { missing[i] = true }
@@ -1720,6 +1733,89 @@ public actor ZQSession {
1720 1733 return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)
1721 1734 }
1722 1735
1736 + /// `boost y x…, rounds(#) [eta(#) maxdepth(#) lambda(#)]` —
1737 + /// gradient-boosted regression trees (xgboost exact-greedy, squared
1738 + /// loss). Deterministic: no subsampling.
1739 + private func handleBoost(_ command: ZQCommand) throws -> ZQResult {
1740 + let sample = try buildRegressionSample(command, clusterVariable: nil)
1741 + func numberOption(_ name: String, default defaultValue: Double) throws -> Double {
1742 + guard let text = command.option(name)?.firstArgument else { return defaultValue }
1743 + guard let value = Double(text), value > 0 else {
1744 + throw ZQEngineError("boost: invalid \(name)()")
1745 + }
1746 + return value
1747 + }
1748 + guard let roundsText = command.option("rounds")?.firstArgument,
1749 + let rounds = Int(roundsText), rounds > 0 else {
1750 + throw ZQEngineError("boost: rounds(#) required")
1751 + }
1752 + let model = try ZQGradientBoosting.fit(
1753 + y: sample.y,
1754 + features: sample.predictors,
1755 + rounds: rounds,
1756 + learningRate: try numberOption("eta", default: 0.3),
1757 + maxDepth: Int(try numberOption("maxdepth", default: 6)),
1758 + lambda: try numberOption("lambda", default: 1)
1759 + )
1760 +
1761 + // Training fit.
1762 + let n = sample.y.count
1763 + var rss = 0.0
1764 + var tss = 0.0
1765 + let meanY = sample.y.reduce(0, +) / Double(n)
1766 + for i in 0..<n {
1767 + let vector = sample.predictors.map { $0.values[i] }
1768 + let predicted = model.predict(vector)
1769 + rss += (sample.y[i] - predicted) * (sample.y[i] - predicted)
1770 + tss += (sample.y[i] - meanY) * (sample.y[i] - meanY)
1771 + }
1772 + let rSquared = tss > 0 ? 1 - rss / tss : .nan
1773 + let rmse = (rss / Double(n)).squareRoot()
1774 +
1775 + recordEstimation(
1776 + kind: .boost(model),
1777 + responseName: sample.responseName,
1778 + coefficients: sample.predictors.map {
1779 + ZQCoefficient(
1780 + name: $0.name, estimate: .nan, standardError: .nan,
1781 + tStatistic: .nan, pValue: .nan,
1782 + confidenceLower: .nan, confidenceUpper: .nan
1783 + )
1784 + },
1785 + definitions: sample.definitions,
1786 + includeConstant: false,
1787 + sampleSize: n
1788 + )
1789 +
1790 + var lines = ["Gradient-boosted trees (squared loss)"]
1791 + if sample.droppedMissing > 0 {
1792 + lines.append("(\(sample.droppedMissing) observations dropped due to missing values)")
1793 + }
1794 + for (label, value) in [
1795 + ("Number of obs", "\(n)"),
1796 + ("Trees", "\(rounds)"),
1797 + ("Learning rate (eta)", TableFormatter.general(model.learningRate)),
1798 + ("Max depth", command.option("maxdepth")?.firstArgument ?? "6"),
1799 + ("Training R-squared", TableFormatter.fixed(rSquared, decimals: 4)),
1800 + ("Training RMSE", TableFormatter.general(rmse, significant: 6)),
1801 + ] {
1802 + lines.append(
1803 + TableFormatter.pad(label, 46, right: false) + "= " +
1804 + TableFormatter.pad(value, 12)
1805 + )
1806 + }
1807 + lines.append("")
1808 + lines.append("Use 'predict newvar' for fitted values. Training fit is in-sample —")
1809 + lines.append("expect it to be optimistic relative to held-out data.")
1810 + return ZQResult(
1811 + text: lines.joined(separator: "\n"),
1812 + scalars: [
1813 + "N": Double(n), "rounds": Double(rounds),
1814 + "r2": rSquared, "rmse": rmse,
1815 + ]
1816 + )
1817 + }
1818 +
1723 1819 /// `margins, dydx(varlist)` — average marginal effects with
1724 1820 /// delta-method standard errors. OLS/IV effects are the coefficients
1725 1821 /// themselves; GLM effects average dμ/dx over the estimation sample:
@@ -1763,6 +1859,8 @@ public actor ZQSession {
1763 1859 let beta = estimation.coefficients[j].value
1764 1860
1765 1861 switch estimation.kind {
1862 + case .boost:
1863 + throw ZQEngineError("margins: not available after boost")
1766 1864 case .ols, .iv:
1767 1865 // Linear model: the marginal effect is the coefficient.
1768 1866 effects.append(Effect(
@@ -1882,6 +1980,48 @@ public actor ZQSession {
1882 1980 return ZQResult(text: lines.joined(separator: "\n"), scalars: scalars)
1883 1981 }
1884 1982
1983 + /// Boosted-tree predictions: trees route missing features down the
1984 + /// default (left) branch, so predictions exist for every observation.
1985 + private func predictBoost(
1986 + model: ZQBoostModel, target: String, command: ZQCommand,
1987 + responseName: String
1988 + ) throws -> ZQResult {
1989 + for option in command.options
1990 + where !["xb", "residuals", "resid"].contains(option.name) {
1991 + throw ZQEngineError("predict: '\(option.name)' is not available after boost")
1992 + }
1993 + let wantsResiduals = command.hasOption("residuals") || command.hasOption("resid")
1994 +
1995 + let n = frame.rowCount
1996 + let columns = try model.featureNames.map { try frame.requireNumeric($0) }
1997 + var values = [Double](repeating: .nan, count: n)
1998 + var missing = [Bool](repeating: false, count: n)
1999 + var yValues = [Double](repeating: 0, count: n)
2000 + var yMissing = [Bool](repeating: false, count: n)
2001 + if wantsResiduals {
2002 + (yValues, yMissing) = try frame.requireNumeric(responseName)
2003 + }
2004 +
2005 + for i in 0..<n {
2006 + let vector = columns.map { $0.missing[i] ? Double.nan : $0.values[i] }
2007 + let predicted = model.predict(vector)
2008 + if wantsResiduals {
2009 + if yMissing[i] {
2010 + missing[i] = true
2011 + } else {
2012 + values[i] = yValues[i] - predicted
2013 + }
2014 + } else {
2015 + values[i] = predicted
2016 + }
2017 + }
2018 + try frame.addColumn(ZQColumn(
2019 + name: target, data: .float64(values: values, missing: missing)
2020 + ))
2021 + let label = wantsResiduals ? "residuals" : "boosted prediction"
2022 + return ZQResult(text: "(\(label) '\(target)' generated)")
2023 + }
2024 +
1885 2025 // MARK: - Bootstrap prefix
1886 2026
1887 2027 private func handleBootstrap(
modified MetrikaKit/Sources/ZQParser/KnownVerbs.swift +1 −0
@@ -45,6 +45,7 @@ public struct ZQVerbTable: Sendable {
45 45 "xtreg": 5,
46 46 "xtset": 5,
47 47 "areg": 4,
48 + "boost": 5,
48 49 "elasticnet": 7,
49 50 "lasso": 5,
50 51 "bayes": 5,
added MetrikaKit/Sources/ZQStats/GradientBoosting.swift +222 −0
@@ -0,0 +1,222 @@
1 +//
2 +// GradientBoosting.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 +/// Gradient-boosted regression trees with squared-error loss, following
13 +/// 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 placed
19 +/// at midpoints between consecutive distinct feature values (xgboost's
20 +/// convention), `x < split` goes left, and missing features follow the
21 +/// default-left rule. No subsampling — training is deterministic.
22 +public 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: Double
27 + public var left: Int
28 + public var right: Int
29 + public var value: Double // leaf weight
30 + }
31 +
32 + public struct Tree: Equatable, Sendable {
33 + public var nodes: [Node]
34 +
35 + public func predict(_ features: [Double]) -> Double {
36 + var index = 0
37 + while let feature = nodes[index].feature {
38 + let value = features[feature]
39 + index = value.isNaN || value < nodes[index].split
40 + ? nodes[index].left
41 + : nodes[index].right
42 + }
43 + return nodes[index].value
44 + }
45 + }
46 +
47 + public var featureNames: [String]
48 + public var baseScore: Double
49 + public var learningRate: Double
50 + public var trees: [Tree]
51 +
52 + /// Prediction for one observation's feature vector (ordered as
53 + /// `featureNames`; NaN = missing).
54 + public func predict(_ features: [Double]) -> Double {
55 + var result = baseScore
56 + for tree in trees {
57 + result += learningRate * tree.predict(features)
58 + }
59 + return result
60 + }
61 +}
62 +
63 +public enum ZQGradientBoosting {
64 +
65 + /// - 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 (= observation
72 + /// 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? = nil
84 + ) throws -> ZQBoostModel {
85 + let n = y.count
86 + let p = features.count
87 + 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 + }
94 +
95 + // 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].values
99 + sortedIndices[j] = (0..<n)
100 + .filter { !values[$0].isNaN }
101 + .sorted { values[$0] < values[$1] }
102 + }
103 +
104 + let base = baseScore ?? y.reduce(0, +) / Double(n)
105 + var predictions = [Double](repeating: base, count: n)
106 + var trees: [ZQBoostModel.Tree] = []
107 +
108 + 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.nodes
123 + )
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 + }
130 +
131 + return ZQBoostModel(
132 + featureNames: features.map(\.name),
133 + baseScore: base,
134 + learningRate: learningRate,
135 + trees: trees
136 + )
137 + }
138 +
139 + /// Recursively grows one node; returns its index in `nodes`.
140 + @discardableResult
141 + 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)
156 +
157 + let index = nodes.count
158 + 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 }
163 +
164 + // Exact greedy split search over every feature.
165 + let parentScore = gTotal * gTotal / (hTotal + lambda)
166 + var bestGain = 0.0
167 + var bestFeature = -1
168 + var bestSplit = 0.0
169 + for j in 0..<features.count {
170 + let values = features[j].values
171 + 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)
175 +
176 + var gLeft = gMissing // missing rows follow the left child
177 + var hLeft = hMissing
178 + for position in 0..<(ordered.count - 1) {
179 + gLeft += gradients[ordered[position]]
180 + hLeft += 1
181 + let current = values[ordered[position]]
182 + let next = values[ordered[position + 1]]
183 + guard next > current else { continue }
184 + let gRight = gTotal - gLeft
185 + let hRight = hTotal - hLeft
186 + guard hLeft >= minChildWeight, hRight >= minChildWeight else { continue }
187 + let gain = 0.5 * (
188 + gLeft * gLeft / (hLeft + lambda)
189 + + gRight * gRight / (hRight + lambda)
190 + - parentScore
191 + ) - gamma
192 + if gain > bestGain {
193 + bestGain = gain
194 + bestFeature = j
195 + bestSplit = (current + next) / 2
196 + }
197 + }
198 + }
199 + guard bestFeature >= 0 else { return index }
200 +
201 + let values = features[bestFeature].values
202 + 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 }
205 +
206 + nodes[index].feature = bestFeature
207 + nodes[index].split = bestSplit
208 + 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: &nodes
213 + )
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: &nodes
219 + )
220 + return index
221 + }
222 +}
added MetrikaKit/Tests/MetrikaKitTests/BoostTests.swift +113 −0
@@ -0,0 +1,113 @@
1 +//
2 +// BoostTests.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 +/// Gradient boosting vs R xgboost (exact greedy, no subsampling —
17 +/// deterministic). xgboost computes in float32, so per-observation
18 +/// prediction agreement is asserted at 1e-4 relative.
19 +@Suite("Gradient boosting", .serialized)
20 +struct BoostTests {
21 + let fixtures: Fixtures
22 + let session: ZQSession
23 +
24 + init() async throws {
25 + self.fixtures = try Fixtures()
26 + self.session = try ZQSession(discoverUserCommands: false)
27 + _ = try await session.execute("use \(fixtures.datasetURL.path)")
28 + _ = try await session.execute("gen log_rev = ln(revenue)")
29 + }
30 +
31 + @Test("matches xgboost predictions observation by observation")
32 + func xgboostParity() async throws {
33 + let result = try await session.execute(
34 + "boost log_rev z1 z2 orders, rounds(20) eta(0.3) maxdepth(3) lambda(1)"
35 + )
36 + #expect(result.scalars["N"] == 60)
37 + expectClose(
38 + try #require(result.scalars["rmse"]),
39 + fixtures["gbm_rmse"], rtol: 1e-4, "training RMSE"
40 + )
41 +
42 + _ = try await session.execute("predict bhat")
43 + let frame = await session.frame
44 + let (predictions, _) = try frame.requireNumeric("bhat")
45 + for (row, key) in [(0, "gbm_pred_1"), (16, "gbm_pred_17"),
46 + (41, "gbm_pred_42"), (56, "gbm_pred_57")] {
47 + expectClose(
48 + predictions[row], fixtures[key], rtol: 1e-4,
49 + "prediction[\(row + 1)]"
50 + )
51 + }
52 + _ = try await session.execute("drop bhat")
53 + }
54 +
55 + @Test("a single stump finds the obvious split")
56 + func stumpSplit() throws {
57 + // Step function: y = 0 for x < 5, y = 10 for x ≥ 5.
58 + let x = (0..<20).map(Double.init)
59 + let y = x.map { $0 < 5 ? 0.0 : 10.0 }
60 + let model = try ZQGradientBoosting.fit(
61 + y: y, features: [("x", x)],
62 + rounds: 1, learningRate: 1, maxDepth: 1, lambda: 0
63 + )
64 + let tree = model.trees[0]
65 + #expect(tree.nodes[0].feature == 0)
66 + #expect(tree.nodes[0].split == 4.5)
67 + // Leaf values are exact means of the residuals under lambda 0.
68 + #expect(abs(model.predict([2]) - 0) < 1e-12)
69 + #expect(abs(model.predict([9]) - 10) < 1e-12)
70 + }
71 +
72 + @Test("training loss decreases every round")
73 + func monotoneTrainingLoss() throws {
74 + var stream = PhiloxStream(seed: 5)
75 + let n = 150
76 + let x1 = (0..<n).map { _ in stream.nextUniform() * 10 }
77 + let x2 = (0..<n).map { _ in stream.nextUniform() * 10 }
78 + let y = (0..<n).map { i in
79 + Foundation.sin(x1[i]) + 0.3 * x2[i] + 0.2 * stream.nextNormal()
80 + }
81 +
82 + var previous = Double.infinity
83 + for rounds in [1, 5, 20, 80] {
84 + let model = try ZQGradientBoosting.fit(
85 + y: y, features: [("x1", x1), ("x2", x2)],
86 + rounds: rounds, learningRate: 0.3, maxDepth: 3
87 + )
88 + var rss = 0.0
89 + for i in 0..<n {
90 + let predicted = model.predict([x1[i], x2[i]])
91 + rss += (y[i] - predicted) * (y[i] - predicted)
92 + }
93 + #expect(rss < previous, "rounds \(rounds): rss \(rss)")
94 + previous = rss
95 + }
96 + // Deep boosting on smooth data should fit very well in-sample.
97 + #expect(previous / Double(n) < 0.05)
98 + }
99 +
100 + @Test("boost requires rounds() and margins refuses afterwards")
101 + func validation() async throws {
102 + await #expect(throws: ZQEngineError.self) {
103 + _ = try await session.execute("boost log_rev z1")
104 + }
105 + _ = try await session.execute("boost log_rev z1 z2, rounds(5)")
106 + await #expect(throws: ZQEngineError.self) {
107 + _ = try await session.execute("margins, dydx(z1)")
108 + }
109 + await #expect(throws: ZQEngineError.self) {
110 + _ = try await session.execute("predict p, pr")
111 + }
112 + }
113 +}
modified MetrikaKit/Tests/MetrikaKitTests/EngineTests.swift +1 −1
@@ -205,7 +205,7 @@ struct EngineTests {
205 205 "use", "save", "clear", "describe", "list", "count", "generate",
206 206 "replace", "drop", "keep", "summarize", "tabulate", "correlate",
207 207 "regress", "logit", "probit", "poisson", "ivregress", "xtreg",
208 − "xtset", "lasso", "elasticnet", "bootstrap", "permute", "predict",
208 + "xtset", "lasso", "elasticnet", "boost", "bootstrap", "permute", "predict",
209 209 "margins", "bayes", "scatter", "histogram", "kdensity", "graph", "display",
210 210 "set", "log", "help", "zscore",
211 211 ] {
modified MetrikaKit/Tests/MetrikaKitTests/Fixtures/expected.tsv +5 −0
@@ -88,6 +88,11 @@ enet_b_price -0.058669179277376367
88 88 enet_b_z1 -0.014365113727136758
89 89 enet_b_z2 9.5044046897303132e-05
90 90 enet_b_orders 0
91 +gbm_rmse 0.074954829650842755
92 +gbm_pred_1 2.6102960109710693
93 +gbm_pred_17 2.4943184852600098
94 +gbm_pred_42 3.2452964782714844
95 +gbm_pred_57 2.9414722919464111
91 96 corr_rev_price -0.88919780339930277
92 97 corr_rev_logrev 0.98089325161602359
93 98 dist_pchisq_3p8_1 0.94874741714263044
modified MetrikaKit/Tests/MetrikaKitTests/Fixtures/regression_v117.dta +0 −0

Binary file not shown.

modified MetrikaKit/Tests/MetrikaKitTests/Fixtures/regression_v118.dta +0 −0

Binary file not shown.

modified Tests/Fixtures/generate.R +28 −0
@@ -352,6 +352,34 @@ if (requireNamespace("glmnet", quietly = TRUE)) {
352 352 cat("glmnet not installed — skipping elastic-net fixtures\n")
353 353 }
354 354
355 +# ------------------------------------------------------- gradient boosting
356 +# xgboost exact-greedy at fixed settings, no subsampling — deterministic.
357 +# Complete-case features only (missing-value default-direction search
358 +# differs between implementations).
359 +if (requireNamespace("xgboost", quietly = TRUE)) {
360 + # Full sample: these variables have no missing values, so the engine's
361 + # listwise deletion keeps all 60 rows.
362 + Xgb <- as.matrix(data[, c("z1", "z2", "orders")])
363 + ygb <- log(data$revenue)
364 + base <- mean(ygb)
365 + booster <- xgboost::xgboost(
366 + x = Xgb, y = ygb,
367 + nrounds = 20, max_depth = 3, learning_rate = 0.3,
368 + reg_lambda = 1, reg_alpha = 0, min_child_weight = 1,
369 + subsample = 1, colsample_bytree = 1,
370 + tree_method = "exact", base_score = base,
371 + nthread = 1, verbosity = 0
372 + )
373 + pred <- predict(booster, Xgb)
374 + emit("gbm_rmse", sqrt(mean((ygb - pred)^2)))
375 + emit("gbm_pred_1", pred[1])
376 + emit("gbm_pred_17", pred[17])
377 + emit("gbm_pred_42", pred[42])
378 + emit("gbm_pred_57", pred[57])
379 +} else {
380 + cat("xgboost not installed — skipping boosting fixtures\n")
381 +}
382 +
355 383 # -------------------------------------------------------------- correlate
356 384 emit("corr_rev_price", cor(d$revenue, d$price))
357 385 emit("corr_rev_logrev", cor(d$revenue, d$log_rev))
358 386