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%
4.0 KB · 114 lines swift
Raw Blame History
1//2//  BoostTests.swift3//  Metrika4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import Foundation11import Testing12import ZQEngine13import ZQGPU14import ZQStats1516/// Gradient boosting vs R xgboost (exact greedy, no subsampling —17/// deterministic). xgboost computes in float32, so per-observation18/// prediction agreement is asserted at 1e-4 relative.19@Suite("Gradient boosting", .serialized)20struct BoostTests {21    let fixtures: Fixtures22    let session: ZQSession2324    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    }3031    @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        )4142        _ = try await session.execute("predict bhat")43        let frame = await session.frame44        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    }5455    @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: 063        )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    }7172    @Test("training loss decreases every round")73    func monotoneTrainingLoss() throws {74        var stream = PhiloxStream(seed: 5)75        let n = 15076        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 in79            Foundation.sin(x1[i]) + 0.3 * x2[i] + 0.2 * stream.nextNormal()80        }8182        var previous = Double.infinity83        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: 387            )88            var rss = 0.089            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 = rss95        }96        // Deep boosting on smooth data should fit very well in-sample.97        #expect(previous / Double(n) < 0.05)98    }99100    @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}114