From 8f346e12732b6087f1f75b6f1f6b115d3e22d98f Mon Sep 17 00:00:00 2001 From: Joshua Lee Date: Sat, 22 Aug 2026 15:50:33 -0400 Subject: [PATCH 1/4] init bst based branch assignment --- benchmarks/README.md | 17 + benchmarks/bench_sgt_mae_fit.py | 333 ++++++++++++++++++ .../results/sgt_mae_fit_20260821T221517Z.csv | 7 + .../results/sgt_mae_fit_20260821T221517Z.json | 195 ++++++++++ .../results/sgt_mae_fit_20260821T222009Z.csv | 7 + .../results/sgt_mae_fit_20260821T222009Z.json | 195 ++++++++++ .../results/sgt_mae_fit_20260821T222425Z.csv | 7 + .../results/sgt_mae_fit_20260821T222425Z.json | 196 +++++++++++ benchmarks/results/sgt_mae_fit_latest.json | 196 +++++++++++ benchmarks/results/sgt_mae_fit_summary.json | 29 ++ cpp/CMakeLists.txt | 5 + .../AbsoluteErrorBranchAssignment.cpp | 172 +++++++-- .../AbsoluteErrorBranchAssignment.h | 55 ++- .../BranchAssignmentFactory.cpp | 5 + .../MaeBranchConfig.h | 34 ++ .../ShapeFunctionSplitSearch.cpp | 30 ++ cpp/src/algorithms/WeightedMAETree.cpp | 284 +++++++++++++++ cpp/src/algorithms/WeightedMAETree.h | 110 ++++++ cpp/tests/bench_mae_branch_assignment.cpp | 173 +++++++++ cpp/tests/test_weighted_mae_tree.cpp | 93 +++++ 20 files changed, 2106 insertions(+), 37 deletions(-) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/bench_sgt_mae_fit.py create mode 100644 benchmarks/results/sgt_mae_fit_20260821T221517Z.csv create mode 100644 benchmarks/results/sgt_mae_fit_20260821T221517Z.json create mode 100644 benchmarks/results/sgt_mae_fit_20260821T222009Z.csv create mode 100644 benchmarks/results/sgt_mae_fit_20260821T222009Z.json create mode 100644 benchmarks/results/sgt_mae_fit_20260821T222425Z.csv create mode 100644 benchmarks/results/sgt_mae_fit_20260821T222425Z.json create mode 100644 benchmarks/results/sgt_mae_fit_latest.json create mode 100644 benchmarks/results/sgt_mae_fit_summary.json create mode 100644 cpp/src/BranchAssignmentObjectives/MaeBranchConfig.h create mode 100644 cpp/src/algorithms/WeightedMAETree.cpp create mode 100644 cpp/src/algorithms/WeightedMAETree.h create mode 100644 cpp/tests/bench_mae_branch_assignment.cpp create mode 100644 cpp/tests/test_weighted_mae_tree.cpp diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..c5d4ac8 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,17 @@ +# MAE / shape-tree fit benchmarks +# +# Run from the repo root after building the C++ extensions: +# +# cmake --build build -j +# # optional: copy *.so into the active venv site-packages +# MPLCONFIGDIR=/tmp/mpl PYTHONPATH=build python benchmarks/bench_sgt_mae_fit.py +# +# The script force-loads ``build/*.so`` when present so a stale venv copy is not used. +# +# Environment knobs used by the native AbsoluteError path: +# SGTLEARN_MAE_CD=1 enable MAE coordinate descent during fit +# SGTLEARN_MAE_BACKEND=sort|bst branch-assignment implementation +# +# The bench sets ``tao_n_runs=0`` so timings isolate tree growth / CD (not TAO). +# +# Results land in benchmarks/results/. diff --git a/benchmarks/bench_sgt_mae_fit.py b/benchmarks/bench_sgt_mae_fit.py new file mode 100644 index 0000000..b5ba57e --- /dev/null +++ b/benchmarks/bench_sgt_mae_fit.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Benchmark SGTRegressor(absolute_error) fit: sort vs BST branch assignment. + +Requires the native extension built with MAE CD / backend env hooks. +Coordinate descent for MAE is enabled via ``SGTLEARN_MAE_CD=1`` so the two +backends are exercised during ``fit`` (default product path still skips MAE CD). +``tao_n_runs=0`` so timings reflect tree growth / branch assignment, not TAO. + +Usage (from repo root, with build/ on PYTHONPATH or an editable install):: + + PYTHONPATH=build python benchmarks/bench_sgt_mae_fit.py + +Writes:: + + benchmarks/results/sgt_mae_fit_.json # raw runs + benchmarks/results/sgt_mae_fit_.csv # per-case rows + benchmarks/results/sgt_mae_fit_latest.json # copy of newest + benchmarks/results/sgt_mae_fit_summary.json # aggregates +""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import statistics +import sys +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +RESULTS_DIR = Path(__file__).resolve().parent / "results" + + +@dataclass(frozen=True) +class Case: + name: str + n_samples: int + n_features: int + max_depth: int + inner_max_depth: int + num_partitions: int + repeats: int + + +CASES: list[Case] = [ + # Deeper inner trees + higher fan-out → more bins and CD moves (where BST wins). + Case("small_2k_x_8", 2_000, 8, 3, 3, 4, 3), + Case("medium_5k_x_12", 5_000, 12, 4, 4, 4, 3), + Case("large_8k_x_16", 8_000, 16, 4, 4, 6, 2), +] + + +def _make_data(n_samples: int, n_features: int, seed: int) -> tuple[np.ndarray, np.ndarray]: + rng = np.random.default_rng(seed) + X = rng.normal(size=(n_samples, n_features)).astype(np.float64) + # Nonlinear + heavy tails so absolute_error is a reasonable criterion. + y = ( + np.sin(X[:, 0]) + + 0.5 * X[:, 1] ** 2 + + 0.1 * rng.standard_t(df=3, size=n_samples) + ).astype(np.float64) + return X, y + + +def _safe_n_leaves(model: Any) -> int | None: + est = getattr(model, "_est", None) + if est is None: + return None + if hasattr(est, "num_leaves"): + try: + return int(est.num_leaves) + except Exception: + return None + return None + + +def run_backend( + backend: str, + cases: list[Case], + *, + seed: int, + warmup: bool, +) -> list[dict[str, Any]]: + from sgtlearn import SGTRegressor + + rows: list[dict[str, Any]] = [] + os.environ["SGTLEARN_MAE_CD"] = "1" + os.environ["SGTLEARN_MAE_BACKEND"] = backend + + if warmup: + Xw, yw = _make_data(400, 4, seed) + m = SGTRegressor( + criterion="absolute_error", + max_depth=2, + inner_max_depth=2, + num_partitions=2, + coordinate_descent_max_iters=5, + coordinate_descent_patience=2, + tao_n_runs=0, + random_state=seed, + ) + m.fit(Xw, yw) + + for case in cases: + times: list[float] = [] + n_nodes_last = None + n_leaves_last = None + for r in range(case.repeats): + os.environ["SGTLEARN_MAE_CD"] = "1" + os.environ["SGTLEARN_MAE_BACKEND"] = backend + Xr, yr = _make_data(case.n_samples, case.n_features, seed + r * 17) + model = SGTRegressor( + criterion="absolute_error", + max_depth=case.max_depth, + inner_max_depth=case.inner_max_depth, + num_partitions=case.num_partitions, + coordinate_descent_max_iters=15, + coordinate_descent_patience=5, + tao_n_runs=0, + random_state=seed + r, + ) + t0 = time.perf_counter() + model.fit(Xr, yr) + elapsed = time.perf_counter() - t0 + times.append(elapsed) + n_leaves_last = _safe_n_leaves(model) + est = getattr(model, "_est", None) + n_nodes_last = int(getattr(est, "num_nodes", -1)) if est is not None else None + print( + f" [{backend}] {case.name} rep={r + 1}/{case.repeats}: " + f"{elapsed:.3f}s", + flush=True, + ) + + rows.append( + { + "backend": backend, + "case": case.name, + "n_samples": case.n_samples, + "n_features": case.n_features, + "max_depth": case.max_depth, + "inner_max_depth": case.inner_max_depth, + "num_partitions": case.num_partitions, + "repeats": case.repeats, + "fit_seconds_mean": statistics.fmean(times), + "fit_seconds_std": statistics.stdev(times) if len(times) > 1 else 0.0, + "fit_seconds_min": min(times), + "fit_seconds_max": max(times), + "fit_seconds_all": times, + "n_leaves": n_leaves_last, + "n_nodes": n_nodes_last, + "mae_cd": True, + } + ) + return rows + + +def aggregate(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + by_case: dict[str, dict[str, dict[str, Any]]] = {} + for row in rows: + by_case.setdefault(row["case"], {})[row["backend"]] = row + + out: list[dict[str, Any]] = [] + for case_name, backends in by_case.items(): + sort_row = backends.get("sort") + bst_row = backends.get("bst") + if not sort_row or not bst_row: + continue + sort_t = float(sort_row["fit_seconds_mean"]) + bst_t = float(bst_row["fit_seconds_mean"]) + out.append( + { + "case": case_name, + "sort_fit_seconds_mean": sort_t, + "bst_fit_seconds_mean": bst_t, + "speedup_sort_over_bst": (sort_t / bst_t) if bst_t > 0 else None, + "n_samples": sort_row["n_samples"], + "n_features": sort_row["n_features"], + } + ) + return out + + +def _preload_native_extensions(build_dir: Path) -> None: + """Prefer freshly built ``*.so`` from cmake ``build/`` over a stale venv copy.""" + import importlib.util + + for name in ( + "ShapeGeneralizedTrees", + "Discretizers", + "TreeAlternatingOptimization", + ): + matches = sorted(build_dir.glob(f"{name}.cpython-*.so")) + if not matches: + continue + so_path = matches[-1] + sys.modules.pop(name, None) + spec = importlib.util.spec_from_file_location(name, so_path) + if spec is None or spec.loader is None: + continue + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--no-warmup", action="store_true") + parser.add_argument( + "--cases", + nargs="*", + default=None, + help="Optional subset of case names to run", + ) + args = parser.parse_args() + + build_dir = ROOT / "build" + if build_dir.is_dir(): + sys.path.insert(0, str(build_dir)) + _preload_native_extensions(build_dir) + + try: + import ShapeGeneralizedTrees # noqa: F401 + from sgtlearn import SGTRegressor # noqa: F401 + + print(f"Using ShapeGeneralizedTrees from {ShapeGeneralizedTrees.__file__}") + except ImportError as exc: + print( + "Failed to import native ShapeGeneralizedTrees / sgtlearn.\n" + "Build the extension (e.g. cmake --build build) and set " + "PYTHONPATH=build, or pip install -e .", + file=sys.stderr, + ) + print(exc, file=sys.stderr) + return 1 + + cases = CASES + if args.cases: + wanted = set(args.cases) + cases = [c for c in CASES if c.name in wanted] + if not cases: + print(f"No matching cases for {args.cases}", file=sys.stderr) + return 1 + + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + print("SGTRegressor absolute_error fit bench (MAE CD enabled)") + print(f"backends: sort, bst | cases: {[c.name for c in cases]}") + print("-" * 60) + + all_rows: list[dict[str, Any]] = [] + for backend in ("sort", "bst"): + print(f"\n=== backend={backend} ===", flush=True) + all_rows.extend( + run_backend( + backend, + cases, + seed=args.seed, + warmup=not args.no_warmup, + ) + ) + + summary = aggregate(all_rows) + payload = { + "timestamp_utc": ts, + "criterion": "absolute_error", + "mae_cd_env": "SGTLEARN_MAE_CD=1", + "backend_env": "SGTLEARN_MAE_BACKEND", + "tao_n_runs": 0, + "seed": args.seed, + "python": sys.version, + "cases": [asdict(c) for c in cases], + "runs": all_rows, + "aggregates": summary, + } + + json_path = RESULTS_DIR / f"sgt_mae_fit_{ts}.json" + latest_path = RESULTS_DIR / "sgt_mae_fit_latest.json" + summary_path = RESULTS_DIR / "sgt_mae_fit_summary.json" + csv_path = RESULTS_DIR / f"sgt_mae_fit_{ts}.csv" + + json_path.write_text(json.dumps(payload, indent=2) + "\n") + latest_path.write_text(json.dumps(payload, indent=2) + "\n") + summary_path.write_text(json.dumps({"timestamp_utc": ts, "aggregates": summary}, indent=2) + "\n") + + fieldnames = [ + "backend", + "case", + "n_samples", + "n_features", + "max_depth", + "inner_max_depth", + "num_partitions", + "repeats", + "fit_seconds_mean", + "fit_seconds_std", + "fit_seconds_min", + "fit_seconds_max", + "n_leaves", + "n_nodes", + "mae_cd", + ] + with csv_path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for row in all_rows: + writer.writerow(row) + + print("\n=== aggregates (sort / bst) ===") + for agg in summary: + print( + f"{agg['case']}: sort={agg['sort_fit_seconds_mean']:.3f}s " + f"bst={agg['bst_fit_seconds_mean']:.3f}s " + f"speedup={agg['speedup_sort_over_bst']:.2f}x" + ) + print(f"\nWrote {json_path}") + print(f"Wrote {csv_path}") + print(f"Wrote {summary_path}") + print(f"Wrote {latest_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/results/sgt_mae_fit_20260821T221517Z.csv b/benchmarks/results/sgt_mae_fit_20260821T221517Z.csv new file mode 100644 index 0000000..73e906a --- /dev/null +++ b/benchmarks/results/sgt_mae_fit_20260821T221517Z.csv @@ -0,0 +1,7 @@ +backend,case,n_samples,n_features,max_depth,inner_max_depth,num_partitions,repeats,fit_seconds_mean,fit_seconds_std,fit_seconds_min,fit_seconds_max,n_leaves,n_nodes,mae_cd +sort,small_2k_x_8,2000,8,3,2,2,3,0.7170423776600122,0.04532743109798002,0.6671643839945318,0.7557187439961126,8,15,True +sort,medium_5k_x_16,5000,16,4,3,2,3,9.294718123996669,1.6331239565537465,7.69252811500337,10.957111124997027,16,31,True +sort,large_10k_x_20,10000,20,5,3,4,2,23.86484242300503,7.531790040347504,18.53906261100201,29.19062223500805,663,910,True +bst,small_2k_x_8,2000,8,3,2,2,3,0.8411415899997033,0.21200585146752188,0.7171799460047623,1.085938204996637,8,15,True +bst,medium_5k_x_16,5000,16,4,3,2,3,6.549038820662342,0.6524301063140052,5.811855870997533,7.052114560996415,16,31,True +bst,large_10k_x_20,10000,20,5,3,4,2,24.472663106498658,7.562380600445612,19.12525250200997,29.820073710987344,663,910,True diff --git a/benchmarks/results/sgt_mae_fit_20260821T221517Z.json b/benchmarks/results/sgt_mae_fit_20260821T221517Z.json new file mode 100644 index 0000000..e0c8032 --- /dev/null +++ b/benchmarks/results/sgt_mae_fit_20260821T221517Z.json @@ -0,0 +1,195 @@ +{ + "timestamp_utc": "20260821T221517Z", + "criterion": "absolute_error", + "mae_cd_env": "SGTLEARN_MAE_CD=1", + "backend_env": "SGTLEARN_MAE_BACKEND", + "seed": 42, + "python": "3.14.2 (v3.14.2:df793163d58, Dec 5 2025, 12:18:06) [Clang 16.0.0 (clang-1600.0.26.6)]", + "cases": [ + { + "name": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 2, + "num_partitions": 2, + "repeats": 3 + }, + { + "name": "medium_5k_x_16", + "n_samples": 5000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 3, + "num_partitions": 2, + "repeats": 3 + }, + { + "name": "large_10k_x_20", + "n_samples": 10000, + "n_features": 20, + "max_depth": 5, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 2 + } + ], + "runs": [ + { + "backend": "sort", + "case": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 2, + "num_partitions": 2, + "repeats": 3, + "fit_seconds_mean": 0.7170423776600122, + "fit_seconds_std": 0.04532743109798002, + "fit_seconds_min": 0.6671643839945318, + "fit_seconds_max": 0.7557187439961126, + "fit_seconds_all": [ + 0.7557187439961126, + 0.7282440049893921, + 0.6671643839945318 + ], + "n_leaves": 8, + "n_nodes": 15, + "mae_cd": true + }, + { + "backend": "sort", + "case": "medium_5k_x_16", + "n_samples": 5000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 3, + "num_partitions": 2, + "repeats": 3, + "fit_seconds_mean": 9.294718123996669, + "fit_seconds_std": 1.6331239565537465, + "fit_seconds_min": 7.69252811500337, + "fit_seconds_max": 10.957111124997027, + "fit_seconds_all": [ + 9.23451513198961, + 10.957111124997027, + 7.69252811500337 + ], + "n_leaves": 16, + "n_nodes": 31, + "mae_cd": true + }, + { + "backend": "sort", + "case": "large_10k_x_20", + "n_samples": 10000, + "n_features": 20, + "max_depth": 5, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 2, + "fit_seconds_mean": 23.86484242300503, + "fit_seconds_std": 7.531790040347504, + "fit_seconds_min": 18.53906261100201, + "fit_seconds_max": 29.19062223500805, + "fit_seconds_all": [ + 29.19062223500805, + 18.53906261100201 + ], + "n_leaves": 663, + "n_nodes": 910, + "mae_cd": true + }, + { + "backend": "bst", + "case": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 2, + "num_partitions": 2, + "repeats": 3, + "fit_seconds_mean": 0.8411415899997033, + "fit_seconds_std": 0.21200585146752188, + "fit_seconds_min": 0.7171799460047623, + "fit_seconds_max": 1.085938204996637, + "fit_seconds_all": [ + 0.7203066189977108, + 0.7171799460047623, + 1.085938204996637 + ], + "n_leaves": 8, + "n_nodes": 15, + "mae_cd": true + }, + { + "backend": "bst", + "case": "medium_5k_x_16", + "n_samples": 5000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 3, + "num_partitions": 2, + "repeats": 3, + "fit_seconds_mean": 6.549038820662342, + "fit_seconds_std": 0.6524301063140052, + "fit_seconds_min": 5.811855870997533, + "fit_seconds_max": 7.052114560996415, + "fit_seconds_all": [ + 6.783146029993077, + 7.052114560996415, + 5.811855870997533 + ], + "n_leaves": 16, + "n_nodes": 31, + "mae_cd": true + }, + { + "backend": "bst", + "case": "large_10k_x_20", + "n_samples": 10000, + "n_features": 20, + "max_depth": 5, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 2, + "fit_seconds_mean": 24.472663106498658, + "fit_seconds_std": 7.562380600445612, + "fit_seconds_min": 19.12525250200997, + "fit_seconds_max": 29.820073710987344, + "fit_seconds_all": [ + 29.820073710987344, + 19.12525250200997 + ], + "n_leaves": 663, + "n_nodes": 910, + "mae_cd": true + } + ], + "aggregates": [ + { + "case": "small_2k_x_8", + "sort_fit_seconds_mean": 0.7170423776600122, + "bst_fit_seconds_mean": 0.8411415899997033, + "speedup_sort_over_bst": 0.852463350029173, + "n_samples": 2000, + "n_features": 8 + }, + { + "case": "medium_5k_x_16", + "sort_fit_seconds_mean": 9.294718123996669, + "bst_fit_seconds_mean": 6.549038820662342, + "speedup_sort_over_bst": 1.4192492025962125, + "n_samples": 5000, + "n_features": 16 + }, + { + "case": "large_10k_x_20", + "sort_fit_seconds_mean": 23.86484242300503, + "bst_fit_seconds_mean": 24.472663106498658, + "speedup_sort_over_bst": 0.9751632799075217, + "n_samples": 10000, + "n_features": 20 + } + ] +} diff --git a/benchmarks/results/sgt_mae_fit_20260821T222009Z.csv b/benchmarks/results/sgt_mae_fit_20260821T222009Z.csv new file mode 100644 index 0000000..5557035 --- /dev/null +++ b/benchmarks/results/sgt_mae_fit_20260821T222009Z.csv @@ -0,0 +1,7 @@ +backend,case,n_samples,n_features,max_depth,inner_max_depth,num_partitions,repeats,fit_seconds_mean,fit_seconds_std,fit_seconds_min,fit_seconds_max,n_leaves,n_nodes,mae_cd +sort,small_2k_x_8,2000,8,3,3,4,3,0.6652767063351348,0.016650878603842782,0.6462120420037536,0.6769667430053232,58,78,True +sort,medium_5k_x_12,5000,12,4,4,4,3,5.746949554998234,1.700660853702942,4.7060362929914845,7.709495650997269,226,308,True +sort,large_8k_x_16,8000,16,4,4,6,2,8.542716497504443,1.0543101937854475,7.79720661000465,9.288226385004236,950,1163,True +bst,small_2k_x_8,2000,8,3,3,4,3,0.6441729456710164,0.06147398203224151,0.573212355011492,0.681233240000438,58,78,True +bst,medium_5k_x_12,5000,12,4,4,4,3,6.021192238995961,2.0487426220132887,4.622615806001704,8.37285278098716,226,308,True +bst,large_8k_x_16,8000,16,4,4,6,2,9.011616504001722,0.926524601985064,8.356464675001916,9.666768333001528,950,1163,True diff --git a/benchmarks/results/sgt_mae_fit_20260821T222009Z.json b/benchmarks/results/sgt_mae_fit_20260821T222009Z.json new file mode 100644 index 0000000..058b0ee --- /dev/null +++ b/benchmarks/results/sgt_mae_fit_20260821T222009Z.json @@ -0,0 +1,195 @@ +{ + "timestamp_utc": "20260821T222009Z", + "criterion": "absolute_error", + "mae_cd_env": "SGTLEARN_MAE_CD=1", + "backend_env": "SGTLEARN_MAE_BACKEND", + "seed": 42, + "python": "3.14.2 (v3.14.2:df793163d58, Dec 5 2025, 12:18:06) [Clang 16.0.0 (clang-1600.0.26.6)]", + "cases": [ + { + "name": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 3 + }, + { + "name": "medium_5k_x_12", + "n_samples": 5000, + "n_features": 12, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 4, + "repeats": 3 + }, + { + "name": "large_8k_x_16", + "n_samples": 8000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 6, + "repeats": 2 + } + ], + "runs": [ + { + "backend": "sort", + "case": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 0.6652767063351348, + "fit_seconds_std": 0.016650878603842782, + "fit_seconds_min": 0.6462120420037536, + "fit_seconds_max": 0.6769667430053232, + "fit_seconds_all": [ + 0.6462120420037536, + 0.6769667430053232, + 0.6726513339963276 + ], + "n_leaves": 58, + "n_nodes": 78, + "mae_cd": true + }, + { + "backend": "sort", + "case": "medium_5k_x_12", + "n_samples": 5000, + "n_features": 12, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 5.746949554998234, + "fit_seconds_std": 1.700660853702942, + "fit_seconds_min": 4.7060362929914845, + "fit_seconds_max": 7.709495650997269, + "fit_seconds_all": [ + 7.709495650997269, + 4.7060362929914845, + 4.825316721005947 + ], + "n_leaves": 226, + "n_nodes": 308, + "mae_cd": true + }, + { + "backend": "sort", + "case": "large_8k_x_16", + "n_samples": 8000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 6, + "repeats": 2, + "fit_seconds_mean": 8.542716497504443, + "fit_seconds_std": 1.0543101937854475, + "fit_seconds_min": 7.79720661000465, + "fit_seconds_max": 9.288226385004236, + "fit_seconds_all": [ + 9.288226385004236, + 7.79720661000465 + ], + "n_leaves": 950, + "n_nodes": 1163, + "mae_cd": true + }, + { + "backend": "bst", + "case": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 0.6441729456710164, + "fit_seconds_std": 0.06147398203224151, + "fit_seconds_min": 0.573212355011492, + "fit_seconds_max": 0.681233240000438, + "fit_seconds_all": [ + 0.573212355011492, + 0.681233240000438, + 0.6780732420011191 + ], + "n_leaves": 58, + "n_nodes": 78, + "mae_cd": true + }, + { + "backend": "bst", + "case": "medium_5k_x_12", + "n_samples": 5000, + "n_features": 12, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 6.021192238995961, + "fit_seconds_std": 2.0487426220132887, + "fit_seconds_min": 4.622615806001704, + "fit_seconds_max": 8.37285278098716, + "fit_seconds_all": [ + 8.37285278098716, + 5.068108129999018, + 4.622615806001704 + ], + "n_leaves": 226, + "n_nodes": 308, + "mae_cd": true + }, + { + "backend": "bst", + "case": "large_8k_x_16", + "n_samples": 8000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 6, + "repeats": 2, + "fit_seconds_mean": 9.011616504001722, + "fit_seconds_std": 0.926524601985064, + "fit_seconds_min": 8.356464675001916, + "fit_seconds_max": 9.666768333001528, + "fit_seconds_all": [ + 9.666768333001528, + 8.356464675001916 + ], + "n_leaves": 950, + "n_nodes": 1163, + "mae_cd": true + } + ], + "aggregates": [ + { + "case": "small_2k_x_8", + "sort_fit_seconds_mean": 0.6652767063351348, + "bst_fit_seconds_mean": 0.6441729456710164, + "speedup_sort_over_bst": 1.0327610167516974, + "n_samples": 2000, + "n_features": 8 + }, + { + "case": "medium_5k_x_12", + "sort_fit_seconds_mean": 5.746949554998234, + "bst_fit_seconds_mean": 6.021192238995961, + "speedup_sort_over_bst": 0.954453757144373, + "n_samples": 5000, + "n_features": 12 + }, + { + "case": "large_8k_x_16", + "sort_fit_seconds_mean": 8.542716497504443, + "bst_fit_seconds_mean": 9.011616504001722, + "speedup_sort_over_bst": 0.9479671592450636, + "n_samples": 8000, + "n_features": 16 + } + ] +} diff --git a/benchmarks/results/sgt_mae_fit_20260821T222425Z.csv b/benchmarks/results/sgt_mae_fit_20260821T222425Z.csv new file mode 100644 index 0000000..d5f889b --- /dev/null +++ b/benchmarks/results/sgt_mae_fit_20260821T222425Z.csv @@ -0,0 +1,7 @@ +backend,case,n_samples,n_features,max_depth,inner_max_depth,num_partitions,repeats,fit_seconds_mean,fit_seconds_std,fit_seconds_min,fit_seconds_max,n_leaves,n_nodes,mae_cd +sort,small_2k_x_8,2000,8,3,3,4,3,11.64151620300739,0.5621140523917701,10.997175320007955,12.031442292005522,64,85,True +sort,medium_5k_x_12,5000,12,4,4,4,3,59.52303006566459,1.1807398659122517,58.50989505900361,60.81973345899314,251,335,True +sort,large_8k_x_16,8000,16,4,4,6,2,229.14787644099852,18.161574668327344,216.30570383599843,241.9900490459986,1032,1264,True +bst,small_2k_x_8,2000,8,3,3,4,3,7.713633298995167,0.20587623710384548,7.580647086986573,7.950775241988595,64,85,True +bst,medium_5k_x_12,5000,12,4,4,4,3,44.004765506334174,3.1475830078652334,41.09283641600632,47.344285339000635,251,335,True +bst,large_8k_x_16,8000,16,4,4,6,2,160.80786340999475,0.825977643737557,160.22380901699944,161.39191780299006,1032,1264,True diff --git a/benchmarks/results/sgt_mae_fit_20260821T222425Z.json b/benchmarks/results/sgt_mae_fit_20260821T222425Z.json new file mode 100644 index 0000000..cb66347 --- /dev/null +++ b/benchmarks/results/sgt_mae_fit_20260821T222425Z.json @@ -0,0 +1,196 @@ +{ + "timestamp_utc": "20260821T222425Z", + "criterion": "absolute_error", + "mae_cd_env": "SGTLEARN_MAE_CD=1", + "backend_env": "SGTLEARN_MAE_BACKEND", + "tao_n_runs": 0, + "seed": 42, + "python": "3.14.2 (v3.14.2:df793163d58, Dec 5 2025, 12:18:06) [Clang 16.0.0 (clang-1600.0.26.6)]", + "cases": [ + { + "name": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 3 + }, + { + "name": "medium_5k_x_12", + "n_samples": 5000, + "n_features": 12, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 4, + "repeats": 3 + }, + { + "name": "large_8k_x_16", + "n_samples": 8000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 6, + "repeats": 2 + } + ], + "runs": [ + { + "backend": "sort", + "case": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 11.64151620300739, + "fit_seconds_std": 0.5621140523917701, + "fit_seconds_min": 10.997175320007955, + "fit_seconds_max": 12.031442292005522, + "fit_seconds_all": [ + 12.031442292005522, + 11.89593099700869, + 10.997175320007955 + ], + "n_leaves": 64, + "n_nodes": 85, + "mae_cd": true + }, + { + "backend": "sort", + "case": "medium_5k_x_12", + "n_samples": 5000, + "n_features": 12, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 59.52303006566459, + "fit_seconds_std": 1.1807398659122517, + "fit_seconds_min": 58.50989505900361, + "fit_seconds_max": 60.81973345899314, + "fit_seconds_all": [ + 60.81973345899314, + 58.50989505900361, + 59.23946167899703 + ], + "n_leaves": 251, + "n_nodes": 335, + "mae_cd": true + }, + { + "backend": "sort", + "case": "large_8k_x_16", + "n_samples": 8000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 6, + "repeats": 2, + "fit_seconds_mean": 229.14787644099852, + "fit_seconds_std": 18.161574668327344, + "fit_seconds_min": 216.30570383599843, + "fit_seconds_max": 241.9900490459986, + "fit_seconds_all": [ + 216.30570383599843, + 241.9900490459986 + ], + "n_leaves": 1032, + "n_nodes": 1264, + "mae_cd": true + }, + { + "backend": "bst", + "case": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 7.713633298995167, + "fit_seconds_std": 0.20587623710384548, + "fit_seconds_min": 7.580647086986573, + "fit_seconds_max": 7.950775241988595, + "fit_seconds_all": [ + 7.580647086986573, + 7.609477568010334, + 7.950775241988595 + ], + "n_leaves": 64, + "n_nodes": 85, + "mae_cd": true + }, + { + "backend": "bst", + "case": "medium_5k_x_12", + "n_samples": 5000, + "n_features": 12, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 44.004765506334174, + "fit_seconds_std": 3.1475830078652334, + "fit_seconds_min": 41.09283641600632, + "fit_seconds_max": 47.344285339000635, + "fit_seconds_all": [ + 41.09283641600632, + 47.344285339000635, + 43.57717476399557 + ], + "n_leaves": 251, + "n_nodes": 335, + "mae_cd": true + }, + { + "backend": "bst", + "case": "large_8k_x_16", + "n_samples": 8000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 6, + "repeats": 2, + "fit_seconds_mean": 160.80786340999475, + "fit_seconds_std": 0.825977643737557, + "fit_seconds_min": 160.22380901699944, + "fit_seconds_max": 161.39191780299006, + "fit_seconds_all": [ + 160.22380901699944, + 161.39191780299006 + ], + "n_leaves": 1032, + "n_nodes": 1264, + "mae_cd": true + } + ], + "aggregates": [ + { + "case": "small_2k_x_8", + "sort_fit_seconds_mean": 11.64151620300739, + "bst_fit_seconds_mean": 7.713633298995167, + "speedup_sort_over_bst": 1.5092130714230214, + "n_samples": 2000, + "n_features": 8 + }, + { + "case": "medium_5k_x_12", + "sort_fit_seconds_mean": 59.52303006566459, + "bst_fit_seconds_mean": 44.004765506334174, + "speedup_sort_over_bst": 1.3526496364830458, + "n_samples": 5000, + "n_features": 12 + }, + { + "case": "large_8k_x_16", + "sort_fit_seconds_mean": 229.14787644099852, + "bst_fit_seconds_mean": 160.80786340999475, + "speedup_sort_over_bst": 1.4249792987844412, + "n_samples": 8000, + "n_features": 16 + } + ] +} diff --git a/benchmarks/results/sgt_mae_fit_latest.json b/benchmarks/results/sgt_mae_fit_latest.json new file mode 100644 index 0000000..cb66347 --- /dev/null +++ b/benchmarks/results/sgt_mae_fit_latest.json @@ -0,0 +1,196 @@ +{ + "timestamp_utc": "20260821T222425Z", + "criterion": "absolute_error", + "mae_cd_env": "SGTLEARN_MAE_CD=1", + "backend_env": "SGTLEARN_MAE_BACKEND", + "tao_n_runs": 0, + "seed": 42, + "python": "3.14.2 (v3.14.2:df793163d58, Dec 5 2025, 12:18:06) [Clang 16.0.0 (clang-1600.0.26.6)]", + "cases": [ + { + "name": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 3 + }, + { + "name": "medium_5k_x_12", + "n_samples": 5000, + "n_features": 12, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 4, + "repeats": 3 + }, + { + "name": "large_8k_x_16", + "n_samples": 8000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 6, + "repeats": 2 + } + ], + "runs": [ + { + "backend": "sort", + "case": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 11.64151620300739, + "fit_seconds_std": 0.5621140523917701, + "fit_seconds_min": 10.997175320007955, + "fit_seconds_max": 12.031442292005522, + "fit_seconds_all": [ + 12.031442292005522, + 11.89593099700869, + 10.997175320007955 + ], + "n_leaves": 64, + "n_nodes": 85, + "mae_cd": true + }, + { + "backend": "sort", + "case": "medium_5k_x_12", + "n_samples": 5000, + "n_features": 12, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 59.52303006566459, + "fit_seconds_std": 1.1807398659122517, + "fit_seconds_min": 58.50989505900361, + "fit_seconds_max": 60.81973345899314, + "fit_seconds_all": [ + 60.81973345899314, + 58.50989505900361, + 59.23946167899703 + ], + "n_leaves": 251, + "n_nodes": 335, + "mae_cd": true + }, + { + "backend": "sort", + "case": "large_8k_x_16", + "n_samples": 8000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 6, + "repeats": 2, + "fit_seconds_mean": 229.14787644099852, + "fit_seconds_std": 18.161574668327344, + "fit_seconds_min": 216.30570383599843, + "fit_seconds_max": 241.9900490459986, + "fit_seconds_all": [ + 216.30570383599843, + 241.9900490459986 + ], + "n_leaves": 1032, + "n_nodes": 1264, + "mae_cd": true + }, + { + "backend": "bst", + "case": "small_2k_x_8", + "n_samples": 2000, + "n_features": 8, + "max_depth": 3, + "inner_max_depth": 3, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 7.713633298995167, + "fit_seconds_std": 0.20587623710384548, + "fit_seconds_min": 7.580647086986573, + "fit_seconds_max": 7.950775241988595, + "fit_seconds_all": [ + 7.580647086986573, + 7.609477568010334, + 7.950775241988595 + ], + "n_leaves": 64, + "n_nodes": 85, + "mae_cd": true + }, + { + "backend": "bst", + "case": "medium_5k_x_12", + "n_samples": 5000, + "n_features": 12, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 4, + "repeats": 3, + "fit_seconds_mean": 44.004765506334174, + "fit_seconds_std": 3.1475830078652334, + "fit_seconds_min": 41.09283641600632, + "fit_seconds_max": 47.344285339000635, + "fit_seconds_all": [ + 41.09283641600632, + 47.344285339000635, + 43.57717476399557 + ], + "n_leaves": 251, + "n_nodes": 335, + "mae_cd": true + }, + { + "backend": "bst", + "case": "large_8k_x_16", + "n_samples": 8000, + "n_features": 16, + "max_depth": 4, + "inner_max_depth": 4, + "num_partitions": 6, + "repeats": 2, + "fit_seconds_mean": 160.80786340999475, + "fit_seconds_std": 0.825977643737557, + "fit_seconds_min": 160.22380901699944, + "fit_seconds_max": 161.39191780299006, + "fit_seconds_all": [ + 160.22380901699944, + 161.39191780299006 + ], + "n_leaves": 1032, + "n_nodes": 1264, + "mae_cd": true + } + ], + "aggregates": [ + { + "case": "small_2k_x_8", + "sort_fit_seconds_mean": 11.64151620300739, + "bst_fit_seconds_mean": 7.713633298995167, + "speedup_sort_over_bst": 1.5092130714230214, + "n_samples": 2000, + "n_features": 8 + }, + { + "case": "medium_5k_x_12", + "sort_fit_seconds_mean": 59.52303006566459, + "bst_fit_seconds_mean": 44.004765506334174, + "speedup_sort_over_bst": 1.3526496364830458, + "n_samples": 5000, + "n_features": 12 + }, + { + "case": "large_8k_x_16", + "sort_fit_seconds_mean": 229.14787644099852, + "bst_fit_seconds_mean": 160.80786340999475, + "speedup_sort_over_bst": 1.4249792987844412, + "n_samples": 8000, + "n_features": 16 + } + ] +} diff --git a/benchmarks/results/sgt_mae_fit_summary.json b/benchmarks/results/sgt_mae_fit_summary.json new file mode 100644 index 0000000..874d9fd --- /dev/null +++ b/benchmarks/results/sgt_mae_fit_summary.json @@ -0,0 +1,29 @@ +{ + "timestamp_utc": "20260821T222425Z", + "aggregates": [ + { + "case": "small_2k_x_8", + "sort_fit_seconds_mean": 11.64151620300739, + "bst_fit_seconds_mean": 7.713633298995167, + "speedup_sort_over_bst": 1.5092130714230214, + "n_samples": 2000, + "n_features": 8 + }, + { + "case": "medium_5k_x_12", + "sort_fit_seconds_mean": 59.52303006566459, + "bst_fit_seconds_mean": 44.004765506334174, + "speedup_sort_over_bst": 1.3526496364830458, + "n_samples": 5000, + "n_features": 12 + }, + { + "case": "large_8k_x_16", + "sort_fit_seconds_mean": 229.14787644099852, + "bst_fit_seconds_mean": 160.80786340999475, + "speedup_sort_over_bst": 1.4249792987844412, + "n_samples": 8000, + "n_features": 16 + } + ] +} diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 41f7d43..a88b771 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -84,6 +84,8 @@ add_library(sgtlearn_core STATIC src/Discretizers/univariate/GainHessianUnivariateDiscretizer.h src/algorithms/WaveletTreeMAE.h src/algorithms/WaveletTreeMAE.cpp + src/algorithms/WeightedMAETree.h + src/algorithms/WeightedMAETree.cpp src/Discretizers/univariate/GainHessianUnivariateDiscretizer.cpp src/Splitters/univariate/AbsoluteErrorSplitter.h src/Splitters/univariate/AbsoluteErrorSplitter.cpp @@ -121,6 +123,7 @@ add_library(sgtlearn_core STATIC src/BranchAssignmentObjectives/BranchAssignment.h src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp + src/BranchAssignmentObjectives/MaeBranchConfig.h src/BranchAssignmentObjectives/LeafAggregateProcessor.h src/BranchAssignmentObjectives/LeafAggregationBranchAssignment.h src/BranchAssignmentObjectives/LeafAggregationBranchAssignment.cpp @@ -223,8 +226,10 @@ if (SGTLEARN_BUILD_TESTS) add_executable(cpp_tests tests/test_wavelet_tree_mae.cpp + tests/test_weighted_mae_tree.cpp tests/test_splitters.cpp tests/test_branch_assignment.cpp + tests/bench_mae_branch_assignment.cpp ) target_link_libraries(cpp_tests PRIVATE diff --git a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp index bf4d31b..ad585df 100644 --- a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp +++ b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp @@ -1,6 +1,6 @@ /** * @file AbsoluteErrorBranchAssignment.cpp - * @brief MAE objective with partition medians over raw per-leaf target samples. + * @brief MAE objectives: BST-backed (default) and sort-based (reference). */ #include @@ -11,14 +11,14 @@ #include #include -AbsoluteErrorBranchAssignment::AbsoluteErrorBranchAssignment( - std::vector &assignments, size_t numPartitions, - std::vector>> &leafYs, - std::vector> &leafWs, std::vector &leafWeights, - const std::vector &leafSampleCounts) - : BranchAssignment(assignments, numPartitions, leafSampleCounts), - leafYs_(leafYs), leafWs_(leafWs), leafWeights_(leafWeights) { +namespace { +void validateAbsoluteErrorInputs( + const std::vector &assignments, size_t numPartitions, + const std::vector>> &leafYs, + const std::vector> &leafWs, + const std::vector &leafWeights, + const std::vector &leafSampleCounts, size_t &nOutputs) { if (assignments.size() != leafYs.size() || leafYs.size() != leafWs.size() || leafYs.size() != leafWeights.size()) throw std::runtime_error( @@ -27,9 +27,10 @@ AbsoluteErrorBranchAssignment::AbsoluteErrorBranchAssignment( throw std::runtime_error( "leafSampleCounts must have the same length as bin statistics"); + nOutputs = 0; for (const auto &binOutputs : leafYs) { if (!binOutputs.empty()) { - nOutputs_ = binOutputs.size(); + nOutputs = binOutputs.size(); break; } } @@ -43,19 +44,41 @@ AbsoluteErrorBranchAssignment::AbsoluteErrorBranchAssignment( "leafYs[i][o] and leafWs[i] must have the same length"); } } +} + +} // namespace + +// --------------------------------------------------------------------------- +// BST-backed AbsoluteErrorBranchAssignment +// --------------------------------------------------------------------------- + +AbsoluteErrorBranchAssignment::AbsoluteErrorBranchAssignment( + std::vector &assignments, size_t numPartitions, + std::vector>> &leafYs, + std::vector> &leafWs, std::vector &leafWeights, + const std::vector &leafSampleCounts) + : BranchAssignment(assignments, numPartitions, leafSampleCounts), + leafYs_(leafYs), leafWs_(leafWs), leafWeights_(leafWeights) { + + validateAbsoluteErrorInputs(assignments, numPartitions, leafYs, leafWs, + leafWeights, leafSampleCounts, nOutputs_); partitionWeight_.assign(numPartitions, 0.0); partitionLoss_.assign(numPartitions, 0.0); + trees_.resize(numPartitions); + for (size_t p = 0; p < numPartitions; ++p) + trees_[p].resize(nOutputs_); const size_t numLeaves = assignments.size(); - for (size_t b = 0; b < numLeaves; b++) { - if (assignments[b] < numPartitions) { - partitionWeight_[assignments[b]] += leafWeights_[b]; - partitionSampleCount_[assignments[b]] += leafSampleCounts_[b]; - } + for (size_t b = 0; b < numLeaves; ++b) { + if (assignments[b] >= numPartitions) + continue; + partitionWeight_[assignments[b]] += leafWeights_[b]; + partitionSampleCount_[assignments[b]] += leafSampleCounts_[b]; + insertLeafIntoPartition(b, assignments[b]); } - for (size_t p = 0; p < numPartitions; p++) { + for (size_t p = 0; p < numPartitions; ++p) { sumNumberOfSamples_ += partitionWeight_[p]; partitionLoss_[p] = computePartitionMae(p); weightedSumLoss_ += partitionWeight_[p] * partitionLoss_[p]; @@ -63,36 +86,51 @@ AbsoluteErrorBranchAssignment::AbsoluteErrorBranchAssignment( } double AbsoluteErrorBranchAssignment::objective() { - // if (!allLeavesAssigned_) - // throw std::runtime_error( - // "Cannot compute objective if any leaves have been unassigned"); return sumNumberOfSamples_ > 0.0 ? weightedSumLoss_ / static_cast(sumNumberOfSamples_) : 0.0; } -void AbsoluteErrorBranchAssignment::addLeaf(size_t leaf, size_t partition) { - // if (allLeavesAssigned_) - // throw std::runtime_error("Cannot assign a leaf if none ever left"); +void AbsoluteErrorBranchAssignment::insertLeafIntoPartition(size_t leaf, + size_t partition) { + if (nOutputs_ == 0) + return; + const auto &ws = leafWs_[leaf]; + for (size_t o = 0; o < nOutputs_; ++o) { + if (o >= leafYs_[leaf].size()) + continue; + trees_[partition][o].insert_batch(leafYs_[leaf][o], ws); + } +} +void AbsoluteErrorBranchAssignment::eraseLeafFromPartition(size_t leaf, + size_t partition) { + if (nOutputs_ == 0) + return; + const auto &ws = leafWs_[leaf]; + for (size_t o = 0; o < nOutputs_; ++o) { + if (o >= leafYs_[leaf].size()) + continue; + trees_[partition][o].remove_batch(leafYs_[leaf][o], ws); + } +} + +void AbsoluteErrorBranchAssignment::addLeaf(size_t leaf, size_t partition) { weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; partitionWeight_[partition] += leafWeights_[leaf]; partitionSampleCount_[partition] += leafSampleCounts_[leaf]; sumNumberOfSamples_ += leafWeights_[leaf]; + insertLeafIntoPartition(leaf, partition); + partitionLoss_[partition] = computePartitionMae(partition); weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; assignments[leaf] = partition; - // allLeavesAssigned_ = true; } void AbsoluteErrorBranchAssignment::removeLeaf(size_t leaf) { - // if (!allLeavesAssigned_) - // throw std::runtime_error( - // "More than one leaf cannot be removed from the objective"); - const size_t partition = assignments[leaf]; if (partition >= numPartitions) throw std::runtime_error( @@ -103,14 +141,90 @@ void AbsoluteErrorBranchAssignment::removeLeaf(size_t leaf) { partitionWeight_[partition] -= leafWeights_[leaf]; partitionSampleCount_[partition] -= leafSampleCounts_[leaf]; + eraseLeafFromPartition(leaf, partition); + partitionLoss_[partition] = computePartitionMae(partition); weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; assignments[leaf] = kUnassignedPartition(numPartitions); - // allLeavesAssigned_ = false; } -void AbsoluteErrorBranchAssignment::collectPartitionSamples( +double +AbsoluteErrorBranchAssignment::computePartitionMae(size_t partition) const { + double total = 0.0; + for (size_t o = 0; o < nOutputs_; ++o) + total += trees_[partition][o].mae(); + return total; +} + +// --------------------------------------------------------------------------- +// Sort-based reference (original algorithm) +// --------------------------------------------------------------------------- + +AbsoluteErrorBranchAssignmentSort::AbsoluteErrorBranchAssignmentSort( + std::vector &assignments, size_t numPartitions, + std::vector>> &leafYs, + std::vector> &leafWs, std::vector &leafWeights, + const std::vector &leafSampleCounts) + : BranchAssignment(assignments, numPartitions, leafSampleCounts), + leafYs_(leafYs), leafWs_(leafWs), leafWeights_(leafWeights) { + + validateAbsoluteErrorInputs(assignments, numPartitions, leafYs, leafWs, + leafWeights, leafSampleCounts, nOutputs_); + + partitionWeight_.assign(numPartitions, 0.0); + partitionLoss_.assign(numPartitions, 0.0); + + const size_t numLeaves = assignments.size(); + for (size_t b = 0; b < numLeaves; ++b) { + if (assignments[b] < numPartitions) { + partitionWeight_[assignments[b]] += leafWeights_[b]; + partitionSampleCount_[assignments[b]] += leafSampleCounts_[b]; + } + } + + for (size_t p = 0; p < numPartitions; ++p) { + sumNumberOfSamples_ += partitionWeight_[p]; + partitionLoss_[p] = computePartitionMae(p); + weightedSumLoss_ += partitionWeight_[p] * partitionLoss_[p]; + } +} + +double AbsoluteErrorBranchAssignmentSort::objective() { + return sumNumberOfSamples_ > 0.0 + ? weightedSumLoss_ / static_cast(sumNumberOfSamples_) + : 0.0; +} + +void AbsoluteErrorBranchAssignmentSort::addLeaf(size_t leaf, size_t partition) { + weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; + + partitionWeight_[partition] += leafWeights_[leaf]; + partitionSampleCount_[partition] += leafSampleCounts_[leaf]; + sumNumberOfSamples_ += leafWeights_[leaf]; + assignments[leaf] = partition; + + partitionLoss_[partition] = computePartitionMae(partition); + weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; +} + +void AbsoluteErrorBranchAssignmentSort::removeLeaf(size_t leaf) { + const size_t partition = assignments[leaf]; + if (partition >= numPartitions) + throw std::runtime_error( + "removeLeaf: leaf is not assigned to a valid partition"); + + weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; + sumNumberOfSamples_ -= leafWeights_[leaf]; + partitionWeight_[partition] -= leafWeights_[leaf]; + partitionSampleCount_[partition] -= leafSampleCounts_[leaf]; + assignments[leaf] = kUnassignedPartition(numPartitions); + + partitionLoss_[partition] = computePartitionMae(partition); + weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; +} + +void AbsoluteErrorBranchAssignmentSort::collectPartitionSamples( size_t partition, size_t output, std::vector &ys, std::vector &ws) const { ys.clear(); @@ -125,7 +239,7 @@ void AbsoluteErrorBranchAssignment::collectPartitionSamples( } double -AbsoluteErrorBranchAssignment::computePartitionMae(size_t partition) const { +AbsoluteErrorBranchAssignmentSort::computePartitionMae(size_t partition) const { double total = 0.0; std::vector ys; std::vector ws; diff --git a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h index c53954b..2141de3 100644 --- a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h +++ b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h @@ -2,20 +2,19 @@ /** * @file AbsoluteErrorBranchAssignment.h - * @brief MAE branch assignment using per-leaf raw ``y`` samples and partition medians. + * @brief MAE branch assignment with per-partition ``WeightedMAETree`` multisets. */ #include #include "BranchAssignment.h" +#include "algorithms/WeightedMAETree.h" #include /** * Multi-output MAE branch-assignment objective: per-partition loss is the SUM - * over outputs of the MAE about that output's median over the y values in the - * partition. Holds raw per-leaf, per-output y samples (``leafYs[bin][output]``) - * with per-sample weights shared across outputs (``leafWs[bin]``); add/remove - * recomputes the summed MAE for the affected partition(s). Single-output - * matches the old scalar path. + * over outputs of the MAE about that output's median. Each partition/output + * owns a ``WeightedMAETree``; ``addLeaf`` / ``removeLeaf`` batch-insert or + * batch-erase that bin's ``(y, w)`` samples in ``O(K log N)``. */ class AbsoluteErrorBranchAssignment : public BranchAssignment { public: @@ -40,7 +39,48 @@ class AbsoluteErrorBranchAssignment : public BranchAssignment { double weightedSumLoss_ = 0; double sumNumberOfSamples_ = 0; - bool allLeavesAssigned_ = true; + + std::vector partitionWeight_; + std::vector partitionLoss_; + /** ``trees_[partition][output]``. */ + std::vector> trees_; + + double computePartitionMae(size_t partition) const; + + void insertLeafIntoPartition(size_t leaf, size_t partition); + void eraseLeafFromPartition(size_t leaf, size_t partition); + + /** Valid partitions are [0, numPartitions); this marks a leaf not in any partition. */ + static constexpr size_t kUnassignedPartition(size_t numPartitions) { + return numPartitions; + } +}; + +/** + * Reference MAE branch assignment that re-sorts each affected partition on every + * add/remove (original ``O(n log n)`` path). Kept for correctness / perf tests. + */ +class AbsoluteErrorBranchAssignmentSort : public BranchAssignment { +public: + AbsoluteErrorBranchAssignmentSort( + std::vector &assignments, size_t numPartitions, + std::vector>> &leafYs, + std::vector> &leafWs, + std::vector &leafWeights, + const std::vector &leafSampleCounts); + + double objective() override; + void addLeaf(size_t leaf, size_t partition) override; + void removeLeaf(size_t leaf) override; + +private: + std::vector>> &leafYs_; + std::vector> &leafWs_; + std::vector &leafWeights_; + size_t nOutputs_ = 0; + + double weightedSumLoss_ = 0; + double sumNumberOfSamples_ = 0; std::vector partitionWeight_; std::vector partitionLoss_; @@ -50,7 +90,6 @@ class AbsoluteErrorBranchAssignment : public BranchAssignment { std::vector &ws) const; double computePartitionMae(size_t partition) const; - /** Valid partitions are [0, numPartitions); this marks a leaf not in any partition. */ static constexpr size_t kUnassignedPartition(size_t numPartitions) { return numPartitions; } diff --git a/cpp/src/BranchAssignmentObjectives/BranchAssignmentFactory.cpp b/cpp/src/BranchAssignmentObjectives/BranchAssignmentFactory.cpp index 8e02861..4b57653 100644 --- a/cpp/src/BranchAssignmentObjectives/BranchAssignmentFactory.cpp +++ b/cpp/src/BranchAssignmentObjectives/BranchAssignmentFactory.cpp @@ -9,6 +9,7 @@ #include "AbsoluteErrorBranchAssignment.h" #include "BranchAssignmentVariants.h" +#include "MaeBranchConfig.h" #include @@ -26,6 +27,10 @@ std::unique_ptr makeBranchAssignment( throw std::invalid_argument( "makeBranchAssignment(AbsoluteError): maeLeafYs and maeLeafWs " "required"); + if (mae_branch_config::backend() == mae_branch_config::Backend::Sort) + return std::make_unique( + assignments, numPartitions, *maeLeafYs, *maeLeafWs, leafWeights, + leafSampleCounts); return std::make_unique( assignments, numPartitions, *maeLeafYs, *maeLeafWs, leafWeights, leafSampleCounts); diff --git a/cpp/src/BranchAssignmentObjectives/MaeBranchConfig.h b/cpp/src/BranchAssignmentObjectives/MaeBranchConfig.h new file mode 100644 index 0000000..5e8f40a --- /dev/null +++ b/cpp/src/BranchAssignmentObjectives/MaeBranchConfig.h @@ -0,0 +1,34 @@ +#pragma once + +/** + * @file MaeBranchConfig.h + * @brief Runtime toggles for AbsoluteError branch assignment (benchmarking / experiments). + * + * Environment variables (read on each call): + * - ``SGTLEARN_MAE_BACKEND``: ``bst`` (default) or ``sort`` + * - ``SGTLEARN_MAE_CD``: ``1`` / ``true`` enables coordinate descent for + * ``absolute_error`` (default off for sklearn CART parity) + */ + +#include +#include + +namespace mae_branch_config { + +enum class Backend { Bst, Sort }; + +inline Backend backend() { + const char *v = std::getenv("SGTLEARN_MAE_BACKEND"); + if (v != nullptr && (std::strcmp(v, "sort") == 0 || std::strcmp(v, "Sort") == 0)) + return Backend::Sort; + return Backend::Bst; +} + +inline bool coordinateDescentEnabled() { + const char *v = std::getenv("SGTLEARN_MAE_CD"); + return v != nullptr && + (std::strcmp(v, "1") == 0 || std::strcmp(v, "true") == 0 || + std::strcmp(v, "TRUE") == 0 || std::strcmp(v, "yes") == 0); +} + +} // namespace mae_branch_config diff --git a/cpp/src/Estimators/ShapeFunctions/ShapeFunctionSplitSearch.cpp b/cpp/src/Estimators/ShapeFunctions/ShapeFunctionSplitSearch.cpp index 429a3b4..d17dc37 100644 --- a/cpp/src/Estimators/ShapeFunctions/ShapeFunctionSplitSearch.cpp +++ b/cpp/src/Estimators/ShapeFunctions/ShapeFunctionSplitSearch.cpp @@ -11,6 +11,7 @@ #include "BranchAssignmentObjectives/BranchAssignment.h" #include "BranchAssignmentObjectives/BranchAssignmentFactory.h" #include "BranchAssignmentObjectives/LeafAggregationBranchAssignment.h" +#include "BranchAssignmentObjectives/MaeBranchConfig.h" #include "algorithms/BinPartitionAssignments.h" #include "algorithms/CoordinateDescent.h" @@ -45,6 +46,32 @@ void refineShapeBranchAssignmentNested( leafSampleCounts, classesPerOutput, nOutputs); } +void refineShapeBranchAssignmentAbsoluteError( + std::unique_ptr &branchObj, size_t k, + size_t numRoutingBins, const CoordinateDescentParams &cdParams, + std::mt19937_64 &rng, + std::vector>> &maeLeafYs, + std::vector> &maeLeafWs, + std::vector &leafWeights, + const std::vector &leafSampleCounts) { + if (k >= numRoutingBins || !mae_branch_config::coordinateDescentEnabled()) + return; + + const std::vector snapshot = branchObj->assignments; + const double objBeforeCd = branchObj->objective(); + coordinateDescent(k, *branchObj, rng, cdParams.maxIters, cdParams.patience); + const double objAfterCd = branchObj->objective(); + if (std::isfinite(objAfterCd) && + objAfterCd <= objBeforeCd + kShapeFunctionCdImprovementEps) + return; + + std::vector rollback = snapshot; + std::vector> dummyLeafStats(maeLeafYs.size()); + branchObj = makeBranchAssignment( + LearningCriterion::AbsoluteError, rollback, k, dummyLeafStats, leafWeights, + leafSampleCounts, &maeLeafYs, &maeLeafWs); +} + void seedTrialBinAssignments(size_t k, size_t numRoutingBins, const std::vector> &stats, const std::vector &sizes, @@ -147,6 +174,9 @@ ShapeBranchAssignmentSearchResult searchShapeBranchAssignmentFromDiscretizer( branchObj = makeBranchAssignment(criterion, trialAssignments, k, dummyLeafStats, leafWeights, sizes, maeLeafYs, maeLeafWs); + refineShapeBranchAssignmentAbsoluteError( + branchObj, k, numRoutingBins, cdParams, rng, maeLeafYsStorage, + maeLeafWsStorage, leafWeights, sizes); } else { branchObj = makeBranchAssignment(criterion, trialAssignments, k, stats, leafWeights, sizes, classesPerOutput, diff --git a/cpp/src/algorithms/WeightedMAETree.cpp b/cpp/src/algorithms/WeightedMAETree.cpp new file mode 100644 index 0000000..2517d2f --- /dev/null +++ b/cpp/src/algorithms/WeightedMAETree.cpp @@ -0,0 +1,284 @@ +/** + * @file WeightedMAETree.cpp + * @brief Augmented AVL implementation for dynamic weighted median / MAE. + */ + +#include "algorithms/WeightedMAETree.h" + +#include +#include +#include + +namespace { + +constexpr double kWeightEps = 1e-15; +constexpr double kHalfTieEps = 1e-12; + +} // namespace + +void WeightedMAETree::pull(Node *n) { + if (!n) + return; + n->height = 1 + std::max(heightOf(n->left), heightOf(n->right)); + n->subWeight = n->weight + weightOf(n->left) + weightOf(n->right); + n->subWy = n->key * n->weight + wyOf(n->left) + wyOf(n->right); +} + +WeightedMAETree::Node *WeightedMAETree::rotateLeft(Node *x) { + Node *y = x->right; + x->right = y->left; + y->left = x; + pull(x); + pull(y); + return y; +} + +WeightedMAETree::Node *WeightedMAETree::rotateRight(Node *y) { + Node *x = y->left; + y->left = x->right; + x->right = y; + pull(y); + pull(x); + return x; +} + +WeightedMAETree::Node *WeightedMAETree::balance(Node *n) { + pull(n); + const int bf = heightOf(n->left) - heightOf(n->right); + if (bf > 1) { + if (heightOf(n->left->right) > heightOf(n->left->left)) + n->left = rotateLeft(n->left); + return rotateRight(n); + } + if (bf < -1) { + if (heightOf(n->right->left) > heightOf(n->right->right)) + n->right = rotateRight(n->right); + return rotateLeft(n); + } + return n; +} + +void WeightedMAETree::destroy(Node *n) { + if (!n) + return; + destroy(n->left); + destroy(n->right); + delete n; +} + +void WeightedMAETree::clear() { + destroy(root_); + root_ = nullptr; + totalWeight_ = 0.0; + totalWy_ = 0.0; +} + +WeightedMAETree::Node *WeightedMAETree::insertNode(Node *n, double key, + double w) { + if (!n) { + Node *created = new Node(); + created->key = key; + created->weight = w; + pull(created); + return created; + } + if (key < n->key) + n->left = insertNode(n->left, key, w); + else if (key > n->key) + n->right = insertNode(n->right, key, w); + else + n->weight += w; + return balance(n); +} + +WeightedMAETree::Node *WeightedMAETree::minNode(Node *n) { + while (n && n->left) + n = n->left; + return n; +} + +WeightedMAETree::Node *WeightedMAETree::eraseMin(Node *n) { + if (!n->left) + return n->right; + n->left = eraseMin(n->left); + return balance(n); +} + +WeightedMAETree::Node *WeightedMAETree::eraseNode(Node *n, double key, + double w) { + if (!n) + throw std::runtime_error("WeightedMAETree::erase: key not found"); + + if (key < n->key) + n->left = eraseNode(n->left, key, w); + else if (key > n->key) + n->right = eraseNode(n->right, key, w); + else { + n->weight -= w; + if (n->weight < -kWeightEps) + throw std::runtime_error("WeightedMAETree::erase: weight underflow"); + if (n->weight <= kWeightEps) { + Node *left = n->left; + Node *right = n->right; + delete n; + if (!right) + return left; + if (!left) + return right; + Node *m = minNode(right); + m->right = eraseMin(right); + m->left = left; + return balance(m); + } + } + return balance(n); +} + +void WeightedMAETree::insert(double y, double w) { + if (w < 0.0) + throw std::invalid_argument("WeightedMAETree::insert: negative weight"); + if (w <= kWeightEps) + return; + root_ = insertNode(root_, y, w); + totalWeight_ += w; + totalWy_ += y * w; +} + +void WeightedMAETree::erase(double y, double w) { + if (w < 0.0) + throw std::invalid_argument("WeightedMAETree::erase: negative weight"); + if (w <= kWeightEps) + return; + root_ = eraseNode(root_, y, w); + totalWeight_ -= w; + totalWy_ -= y * w; + if (totalWeight_ < 0.0 && totalWeight_ > -kWeightEps) + totalWeight_ = 0.0; + if (std::fabs(totalWy_) < kWeightEps) + totalWy_ = 0.0; +} + +void WeightedMAETree::insert_batch(const std::vector &ys, + const std::vector &ws) { + if (ys.size() != ws.size()) + throw std::invalid_argument( + "WeightedMAETree::insert_batch: ys/ws size mismatch"); + for (size_t i = 0; i < ys.size(); ++i) + insert(static_cast(ys[i]), static_cast(ws[i])); +} + +void WeightedMAETree::remove_batch(const std::vector &ys, + const std::vector &ws) { + if (ys.size() != ws.size()) + throw std::invalid_argument( + "WeightedMAETree::remove_batch: ys/ws size mismatch"); + for (size_t i = 0; i < ys.size(); ++i) + erase(static_cast(ys[i]), static_cast(ws[i])); +} + +void WeightedMAETree::aggregatesLessThan(const Node *n, double key, double &wOut, + double &wyOut) { + while (n) { + if (key <= n->key) { + n = n->left; + } else { + wOut += weightOf(n->left) + n->weight; + wyOut += wyOf(n->left) + n->key * n->weight; + n = n->right; + } + } +} + +bool WeightedMAETree::predecessor(const Node *n, double key, double &out) { + bool found = false; + while (n) { + if (n->key < key) { + out = n->key; + found = true; + n = n->right; + } else { + n = n->left; + } + } + return found; +} + +double WeightedMAETree::median() const { return medianAndMae().first; } + +double WeightedMAETree::mae() const { return medianAndMae().second; } + +std::pair WeightedMAETree::medianAndMae() const { + if (!root_ || totalWeight_ <= 0.0) + return {0.0, 0.0}; + + const double half = 0.5 * totalWeight_; + double wLeft = 0.0; + double wyLeft = 0.0; + const Node *n = root_; + const Node *medianNode = nullptr; + + while (n) { + const double leftW = weightOf(n->left); + if (wLeft + leftW > half) { + n = n->left; + continue; + } + if (wLeft + leftW + n->weight > half) { + wLeft += leftW; + wyLeft += wyOf(n->left); + medianNode = n; + break; + } + wLeft += leftW + n->weight; + wyLeft += wyOf(n->left) + n->key * n->weight; + n = n->right; + } + + if (!medianNode) { + // Degenerate: land on rightmost key (mirrors Criterion fallback). + n = root_; + while (n->right) + n = n->right; + medianNode = n; + wLeft = totalWeight_ - n->weight; + wyLeft = totalWy_ - n->key * n->weight; + } + + double m = medianNode->key; + if (std::fabs(wLeft - half) <= kHalfTieEps) { + double pred = 0.0; + if (predecessor(root_, medianNode->key, pred)) + m = 0.5 * (pred + medianNode->key); + } + + // Pinball about m with value split (y < m vs y > m); ties at m contribute 0. + double wLt = 0.0; + double wyLt = 0.0; + aggregatesLessThan(root_, m, wLt, wyLt); + + double wEq = 0.0; + double wyEq = 0.0; + if (std::fabs(m - medianNode->key) <= kHalfTieEps) { + wEq = medianNode->weight; + wyEq = medianNode->key * medianNode->weight; + } else { + // Half-tie average is not an inserted key; scan for an exact match anyway. + const Node *eq = root_; + while (eq) { + if (m < eq->key) + eq = eq->left; + else if (m > eq->key) + eq = eq->right; + else { + wEq = eq->weight; + wyEq = eq->key * eq->weight; + break; + } + } + } + + const double wGt = totalWeight_ - wLt - wEq; + const double wyGt = totalWy_ - wyLt - wyEq; + const double pinball = (m * wLt - wyLt) + (wyGt - m * wGt); + return {m, pinball / totalWeight_}; +} diff --git a/cpp/src/algorithms/WeightedMAETree.h b/cpp/src/algorithms/WeightedMAETree.h new file mode 100644 index 0000000..e3f87f6 --- /dev/null +++ b/cpp/src/algorithms/WeightedMAETree.h @@ -0,0 +1,110 @@ +#pragma once + +/** + * @file WeightedMAETree.h + * @brief Augmented AVL multiset for dynamic weighted median and pinball MAE. + * + * Keys are merged by value: each distinct ``y`` stores total weight at that + * value. Inserts/erases of a batch of size ``K`` are ``O(K log N)``. Median and + * MAE queries are ``O(log N)`` via subtree weight / ``Σw·y`` aggregates. + */ + +#include +#include +#include + +/** + * Self-balancing BST ordered by ``y``, maintaining per-subtree + * ``(Σw, Σw·y)`` so weighted median and MAE match ``Criterion::absoluteError`` + * under batch membership updates. + */ +class WeightedMAETree { +public: + WeightedMAETree() = default; + ~WeightedMAETree() { clear(); } + + WeightedMAETree(const WeightedMAETree &) = delete; + WeightedMAETree &operator=(const WeightedMAETree &) = delete; + + WeightedMAETree(WeightedMAETree &&other) noexcept + : root_(other.root_), totalWeight_(other.totalWeight_), + totalWy_(other.totalWy_) { + other.root_ = nullptr; + other.totalWeight_ = 0.0; + other.totalWy_ = 0.0; + } + + WeightedMAETree &operator=(WeightedMAETree &&other) noexcept { + if (this != &other) { + clear(); + root_ = other.root_; + totalWeight_ = other.totalWeight_; + totalWy_ = other.totalWy_; + other.root_ = nullptr; + other.totalWeight_ = 0.0; + other.totalWy_ = 0.0; + } + return *this; + } + + void clear(); + + /** Insert ``(y[i], w[i])`` for all ``i``; ``O(K log N)``. */ + void insert_batch(const std::vector &ys, + const std::vector &ws); + + /** Erase the same multiset of pairs previously inserted; ``O(K log N)``. */ + void remove_batch(const std::vector &ys, + const std::vector &ws); + + void insert(double y, double w); + void erase(double y, double w); + + double totalWeight() const { return totalWeight_; } + + /** Weighted median (sklearn half-tie average). ``O(log N)``. */ + double median() const; + + /** Mean absolute error about ``median()``. ``O(log N)``. */ + double mae() const; + + /** ``(median, mae)`` in one walk + aggregate query. */ + std::pair medianAndMae() const; + +private: + struct Node { + double key = 0.0; + double weight = 0.0; + double subWeight = 0.0; + double subWy = 0.0; + int height = 1; + Node *left = nullptr; + Node *right = nullptr; + }; + + Node *root_ = nullptr; + double totalWeight_ = 0.0; + double totalWy_ = 0.0; + + static int heightOf(const Node *n) { return n ? n->height : 0; } + static double weightOf(const Node *n) { return n ? n->subWeight : 0.0; } + static double wyOf(const Node *n) { return n ? n->subWy : 0.0; } + + static void pull(Node *n); + static Node *rotateLeft(Node *x); + static Node *rotateRight(Node *y); + static Node *balance(Node *n); + + Node *insertNode(Node *n, double key, double w); + Node *eraseNode(Node *n, double key, double w); + static Node *minNode(Node *n); + static Node *eraseMin(Node *n); + static void destroy(Node *n); + + /** Weight / ``Σw·y`` over keys strictly ``< key``. */ + static void aggregatesLessThan(const Node *n, double key, double &wOut, + double &wyOut); + + /** Largest key strictly less than ``key``, or false if none. */ + static bool predecessor(const Node *n, double key, double &out); +}; diff --git a/cpp/tests/bench_mae_branch_assignment.cpp b/cpp/tests/bench_mae_branch_assignment.cpp new file mode 100644 index 0000000..2ecaef3 --- /dev/null +++ b/cpp/tests/bench_mae_branch_assignment.cpp @@ -0,0 +1,173 @@ +/** + * @file bench_mae_branch_assignment.cpp + * @brief Wall-time comparison: sort-based vs BST AbsoluteError branch assignment. + */ + +#include +#include + +#include +#include + +#include +#include +#include +#include + +using Catch::Matchers::WithinAbs; +using clock_type = std::chrono::steady_clock; + +namespace { + +struct MaeScenario { + std::vector assignments; + std::vector>> leafYs; + std::vector> leafWs; + std::vector leafWeights; + std::vector leafSampleCounts; + size_t numPartitions = 0; +}; + +MaeScenario makeScenario(size_t numBins, size_t samplesPerBin, + size_t numPartitions, size_t nOutputs, + uint64_t seed) { + std::mt19937_64 rng(seed); + std::uniform_real_distribution yDist(-100.0F, 100.0F); + std::uniform_real_distribution wDist(0.5F, 2.0F); + + MaeScenario s; + s.numPartitions = numPartitions; + s.assignments.resize(numBins); + s.leafYs.resize(numBins); + s.leafWs.resize(numBins); + s.leafWeights.resize(numBins); + s.leafSampleCounts.assign(numBins, samplesPerBin); + + for (size_t b = 0; b < numBins; ++b) { + s.assignments[b] = b % numPartitions; + s.leafYs[b].resize(nOutputs); + s.leafWs[b].resize(samplesPerBin); + double wSum = 0.0; + for (size_t i = 0; i < samplesPerBin; ++i) { + s.leafWs[b][i] = wDist(rng); + wSum += static_cast(s.leafWs[b][i]); + } + s.leafWeights[b] = wSum; + for (size_t o = 0; o < nOutputs; ++o) { + s.leafYs[b][o].resize(samplesPerBin); + for (size_t i = 0; i < samplesPerBin; ++i) + s.leafYs[b][o][i] = yDist(rng); + } + } + return s; +} + +struct BenchResult { + double ms = 0.0; + double objective = 0.0; +}; + +BenchResult timeBstCd(MaeScenario &base, uint64_t seed, int repeats) { + double totalMs = 0.0; + double lastObj = 0.0; + for (int r = 0; r < repeats; ++r) { + auto asg = base.assignments; + AbsoluteErrorBranchAssignment obj(asg, base.numPartitions, base.leafYs, + base.leafWs, base.leafWeights, + base.leafSampleCounts); + std::mt19937_64 rng(seed + static_cast(r)); + const auto t0 = clock_type::now(); + lastObj = coordinateDescent(base.numPartitions, obj, rng, 8, 3); + const auto t1 = clock_type::now(); + totalMs += std::chrono::duration(t1 - t0).count(); + } + return {totalMs / static_cast(repeats), lastObj}; +} + +BenchResult timeSortCd(MaeScenario &base, uint64_t seed, int repeats) { + double totalMs = 0.0; + double lastObj = 0.0; + for (int r = 0; r < repeats; ++r) { + auto asg = base.assignments; + AbsoluteErrorBranchAssignmentSort obj(asg, base.numPartitions, base.leafYs, + base.leafWs, base.leafWeights, + base.leafSampleCounts); + std::mt19937_64 rng(seed + static_cast(r)); + const auto t0 = clock_type::now(); + lastObj = coordinateDescent(base.numPartitions, obj, rng, 8, 3); + const auto t1 = clock_type::now(); + totalMs += std::chrono::duration(t1 - t0).count(); + } + return {totalMs / static_cast(repeats), lastObj}; +} + +} // namespace + +TEST_CASE("AbsoluteError BST and sort objectives match under CD", + "[branch_assignment][absolute_error][correctness]") { + auto scenario = makeScenario(/*numBins=*/32, /*samplesPerBin=*/40, + /*numPartitions=*/4, /*nOutputs=*/1, /*seed=*/99); + + auto asgBst = scenario.assignments; + auto asgSort = scenario.assignments; + + AbsoluteErrorBranchAssignment bst(asgBst, scenario.numPartitions, + scenario.leafYs, scenario.leafWs, + scenario.leafWeights, + scenario.leafSampleCounts); + AbsoluteErrorBranchAssignmentSort sortObj(asgSort, scenario.numPartitions, + scenario.leafYs, scenario.leafWs, + scenario.leafWeights, + scenario.leafSampleCounts); + + REQUIRE_THAT(bst.objective(), WithinAbs(sortObj.objective(), 1e-6)); + + std::mt19937_64 rngBst(123); + std::mt19937_64 rngSort(123); + const double bstFinal = + coordinateDescent(scenario.numPartitions, bst, rngBst, 8, 3); + const double sortFinal = + coordinateDescent(scenario.numPartitions, sortObj, rngSort, 8, 3); + REQUIRE_THAT(bstFinal, WithinAbs(sortFinal, 1e-5)); + REQUIRE(asgBst == asgSort); +} + +TEST_CASE("Bench AbsoluteError branch assignment: sort vs BST", + "[.benchmark]") { + struct Case { + const char *name; + size_t bins; + size_t samplesPerBin; + size_t parts; + size_t outputs; + int repeats; + }; + + const Case cases[] = { + {"small 64 bins x 20", 64, 20, 4, 1, 5}, + {"medium 128 bins x 50", 128, 50, 4, 1, 3}, + {"large 256 bins x 100", 256, 100, 8, 1, 2}, + {"multi-out 128 x 40 x 3", 128, 40, 4, 3, 3}, + }; + + std::cout << '\n' + << "AbsoluteError CD bench (sort = re-sort partition; bst = " + "WeightedMAETree)\n"; + std::cout << "--------------------------------------------------------------" + "--------\n"; + + for (const Case &c : cases) { + auto scenario = + makeScenario(c.bins, c.samplesPerBin, c.parts, c.outputs, 2026); + const auto sortRes = timeSortCd(scenario, 7, c.repeats); + const auto bstRes = timeBstCd(scenario, 7, c.repeats); + const double speedup = sortRes.ms > 0.0 ? (sortRes.ms / bstRes.ms) : 0.0; + std::cout << c.name << ": sort " << sortRes.ms << " ms, bst " << bstRes.ms + << " ms, speedup " << speedup << "x (obj sort=" << sortRes.objective + << " bst=" << bstRes.objective << ")\n"; + + REQUIRE(bstRes.ms > 0.0); + REQUIRE(sortRes.ms > 0.0); + } + std::cout << std::flush; +} diff --git a/cpp/tests/test_weighted_mae_tree.cpp b/cpp/tests/test_weighted_mae_tree.cpp new file mode 100644 index 0000000..7853d96 --- /dev/null +++ b/cpp/tests/test_weighted_mae_tree.cpp @@ -0,0 +1,93 @@ +/** + * @file test_weighted_mae_tree.cpp + * @brief Correctness tests for ``WeightedMAETree`` vs ``Criterion::absoluteError``. + */ + +#include +#include + +#include +#include + +#include +#include + +using Catch::Matchers::WithinAbs; + +namespace { + +Criterion::AbsoluteErrorStats brute(const std::vector &ys, + const std::vector &ws) { + return Criterion::absoluteError(ys, ws); +} + +} // namespace + +TEST_CASE("WeightedMAETree median/mae match Criterion on random batches", + "[weighted_mae_tree]") { + std::mt19937 rng(7); + std::uniform_real_distribution yDist(-50.0F, 50.0F); + std::uniform_real_distribution wDist(0.1F, 3.0F); + + for (int n : {1, 2, 3, 10, 64, 257}) { + std::vector ys(static_cast(n)); + std::vector ws(static_cast(n)); + for (int i = 0; i < n; ++i) { + ys[static_cast(i)] = yDist(rng); + ws[static_cast(i)] = wDist(rng); + } + + WeightedMAETree tree; + tree.insert_batch(ys, ws); + const auto ref = brute(ys, ws); + const auto got = tree.medianAndMae(); + REQUIRE_THAT(got.first, WithinAbs(ref.median, 1e-5)); + REQUIRE_THAT(got.second, WithinAbs(ref.mae, 1e-5)); + REQUIRE_THAT(tree.totalWeight(), WithinAbs(ref.totalWeight, 1e-6)); + } +} + +TEST_CASE("WeightedMAETree supports remove_batch round-trip", + "[weighted_mae_tree]") { + std::vector ys = {1.F, 2.F, 3.F, 4.F, 5.F, 2.F}; + std::vector ws = {1.F, 1.F, 2.F, 1.F, 1.F, 0.5F}; + std::vector dropY = {2.F, 4.F}; + std::vector dropW = {1.F, 1.F}; + + WeightedMAETree tree; + tree.insert_batch(ys, ws); + tree.remove_batch(dropY, dropW); + + std::vector remainY = {1.F, 3.F, 5.F, 2.F}; + std::vector remainW = {1.F, 2.F, 1.F, 0.5F}; + const auto ref = brute(remainY, remainW); + const auto got = tree.medianAndMae(); + REQUIRE_THAT(got.first, WithinAbs(ref.median, 1e-6)); + REQUIRE_THAT(got.second, WithinAbs(ref.mae, 1e-6)); +} + +TEST_CASE("WeightedMAETree half-tie median matches Criterion", + "[weighted_mae_tree]") { + // Equal total weight on each side of the cut → average of adjacent keys. + std::vector ys = {1.F, 3.F}; + std::vector ws = {1.F, 1.F}; + WeightedMAETree tree; + tree.insert_batch(ys, ws); + const auto ref = brute(ys, ws); + REQUIRE_THAT(tree.median(), WithinAbs(ref.median, 1e-12)); + REQUIRE_THAT(tree.mae(), WithinAbs(ref.mae, 1e-12)); + REQUIRE_THAT(tree.median(), WithinAbs(2.0, 1e-12)); +} + +TEST_CASE("WeightedMAETree duplicate keys", "[weighted_mae_tree]") { + std::vector ys(100, 4.5F); + std::vector ws(100, 0.25F); + ys[0] = -10.F; + ws[0] = 1.F; + + WeightedMAETree tree; + tree.insert_batch(ys, ws); + const auto ref = brute(ys, ws); + REQUIRE_THAT(tree.median(), WithinAbs(ref.median, 1e-6)); + REQUIRE_THAT(tree.mae(), WithinAbs(ref.mae, 1e-6)); +} From 8ebbc472f90a08d46f04df289a206b378384251b Mon Sep 17 00:00:00 2001 From: Joshua Lee Date: Sat, 22 Aug 2026 17:18:45 -0400 Subject: [PATCH 2/4] implemented merge/filter based partitions for branch assignment class for MAE --- cpp/CMakeLists.txt | 5 + .../AbsoluteErrorBranchAssignment.cpp | 304 +++++++----------- .../AbsoluteErrorBranchAssignment.h | 79 ++--- .../AbsoluteErrorBranchAssignmentBst.cpp | 125 +++++++ .../AbsoluteErrorBranchAssignmentBst.h | 50 +++ .../AbsoluteErrorBranchAssignmentCommon.h | 51 +++ .../AbsoluteErrorBranchAssignmentSort.cpp | 128 ++++++++ .../AbsoluteErrorBranchAssignmentSort.h | 49 +++ .../BranchAssignmentFactory.cpp | 28 +- .../BranchAssignmentVariants.h | 2 + .../MaeBranchConfig.h | 13 +- cpp/src/Criterion.cpp | 59 ++-- cpp/src/Criterion.h | 7 + cpp/tests/bench_mae_branch_assignment.cpp | 146 ++++++--- 14 files changed, 741 insertions(+), 305 deletions(-) create mode 100644 cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.cpp create mode 100644 cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.h create mode 100644 cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentCommon.h create mode 100644 cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.cpp create mode 100644 cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.h diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index a88b771..bb50f40 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -121,8 +121,13 @@ add_library(sgtlearn_core STATIC src/Estimators/RegressionShapeGeneralizedTree.h src/Estimators/RegressionShapeGeneralizedTree.cpp src/BranchAssignmentObjectives/BranchAssignment.h + src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentCommon.h src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp + src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.h + src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.cpp + src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.h + src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.cpp src/BranchAssignmentObjectives/MaeBranchConfig.h src/BranchAssignmentObjectives/LeafAggregateProcessor.h src/BranchAssignmentObjectives/LeafAggregationBranchAssignment.h diff --git a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp index ad585df..5235161 100644 --- a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp +++ b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.cpp @@ -1,57 +1,17 @@ /** * @file AbsoluteErrorBranchAssignment.cpp - * @brief MAE objectives: BST-backed (default) and sort-based (reference). + * @brief Default MAE branch assignment (sorted merge / filter). */ -#include #include "AbsoluteErrorBranchAssignment.h" +#include "AbsoluteErrorBranchAssignmentCommon.h" #include "Criterion.h" #include +#include #include -namespace { - -void validateAbsoluteErrorInputs( - const std::vector &assignments, size_t numPartitions, - const std::vector>> &leafYs, - const std::vector> &leafWs, - const std::vector &leafWeights, - const std::vector &leafSampleCounts, size_t &nOutputs) { - if (assignments.size() != leafYs.size() || leafYs.size() != leafWs.size() || - leafYs.size() != leafWeights.size()) - throw std::runtime_error( - "assignments, leafYs, leafWs, and leafWeights must have the same length"); - if (leafSampleCounts.size() != leafYs.size()) - throw std::runtime_error( - "leafSampleCounts must have the same length as bin statistics"); - - nOutputs = 0; - for (const auto &binOutputs : leafYs) { - if (!binOutputs.empty()) { - nOutputs = binOutputs.size(); - break; - } - } - - for (size_t i = 0; i < leafYs.size(); ++i) { - if (assignments[i] >= numPartitions) - throw std::runtime_error("assignments[i] must be a valid partition index"); - for (const auto &outputYs : leafYs[i]) { - if (outputYs.size() != leafWs[i].size()) - throw std::runtime_error( - "leafYs[i][o] and leafWs[i] must have the same length"); - } - } -} - -} // namespace - -// --------------------------------------------------------------------------- -// BST-backed AbsoluteErrorBranchAssignment -// --------------------------------------------------------------------------- - AbsoluteErrorBranchAssignment::AbsoluteErrorBranchAssignment( std::vector &assignments, size_t numPartitions, std::vector>> &leafYs, @@ -60,22 +20,29 @@ AbsoluteErrorBranchAssignment::AbsoluteErrorBranchAssignment( : BranchAssignment(assignments, numPartitions, leafSampleCounts), leafYs_(leafYs), leafWs_(leafWs), leafWeights_(leafWeights) { - validateAbsoluteErrorInputs(assignments, numPartitions, leafYs, leafWs, - leafWeights, leafSampleCounts, nOutputs_); + absolute_error_branch::validateInputs(assignments, numPartitions, leafYs, + leafWs, leafWeights, leafSampleCounts, + nOutputs_); + + const size_t numLeaves = assignments.size(); + binYsSorted_.assign(numLeaves, {}); + binWsSorted_.assign(numLeaves, {}); + for (size_t b = 0; b < numLeaves; ++b) + buildSortedBin(b); partitionWeight_.assign(numPartitions, 0.0); partitionLoss_.assign(numPartitions, 0.0); - trees_.resize(numPartitions); - for (size_t p = 0; p < numPartitions; ++p) - trees_[p].resize(nOutputs_); + partYs_.assign(numPartitions, std::vector>(nOutputs_)); + partWs_.assign(numPartitions, std::vector>(nOutputs_)); + partSrcBin_.assign(numPartitions, + std::vector>(nOutputs_)); - const size_t numLeaves = assignments.size(); for (size_t b = 0; b < numLeaves; ++b) { if (assignments[b] >= numPartitions) continue; partitionWeight_[assignments[b]] += leafWeights_[b]; partitionSampleCount_[assignments[b]] += leafSampleCounts_[b]; - insertLeafIntoPartition(b, assignments[b]); + mergeLeafIntoPartition(b, assignments[b]); } for (size_t p = 0; p < numPartitions; ++p) { @@ -85,130 +52,139 @@ AbsoluteErrorBranchAssignment::AbsoluteErrorBranchAssignment( } } -double AbsoluteErrorBranchAssignment::objective() { - return sumNumberOfSamples_ > 0.0 - ? weightedSumLoss_ / static_cast(sumNumberOfSamples_) - : 0.0; -} - -void AbsoluteErrorBranchAssignment::insertLeafIntoPartition(size_t leaf, - size_t partition) { - if (nOutputs_ == 0) - return; +void AbsoluteErrorBranchAssignment::buildSortedBin(size_t leaf) { + binYsSorted_[leaf].assign(nOutputs_, {}); + binWsSorted_[leaf].assign(nOutputs_, {}); const auto &ws = leafWs_[leaf]; for (size_t o = 0; o < nOutputs_; ++o) { if (o >= leafYs_[leaf].size()) continue; - trees_[partition][o].insert_batch(leafYs_[leaf][o], ws); + const auto &ys = leafYs_[leaf][o]; + const size_t n = ys.size(); + std::vector order(n); + for (size_t i = 0; i < n; ++i) + order[i] = i; + std::sort(order.begin(), order.end(), + [&ys](size_t a, size_t b) { return ys[a] < ys[b]; }); + auto &ysOut = binYsSorted_[leaf][o]; + auto &wsOut = binWsSorted_[leaf][o]; + ysOut.resize(n); + wsOut.resize(n); + for (size_t i = 0; i < n; ++i) { + ysOut[i] = ys[order[i]]; + wsOut[i] = ws[order[i]]; + } } } -void AbsoluteErrorBranchAssignment::eraseLeafFromPartition(size_t leaf, +void AbsoluteErrorBranchAssignment::mergeLeafIntoPartition(size_t leaf, size_t partition) { - if (nOutputs_ == 0) - return; - const auto &ws = leafWs_[leaf]; for (size_t o = 0; o < nOutputs_; ++o) { - if (o >= leafYs_[leaf].size()) - continue; - trees_[partition][o].remove_batch(leafYs_[leaf][o], ws); - } -} - -void AbsoluteErrorBranchAssignment::addLeaf(size_t leaf, size_t partition) { - weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; - - partitionWeight_[partition] += leafWeights_[leaf]; - partitionSampleCount_[partition] += leafSampleCounts_[leaf]; - sumNumberOfSamples_ += leafWeights_[leaf]; - - insertLeafIntoPartition(leaf, partition); - - partitionLoss_[partition] = computePartitionMae(partition); - weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; - - assignments[leaf] = partition; -} - -void AbsoluteErrorBranchAssignment::removeLeaf(size_t leaf) { - const size_t partition = assignments[leaf]; - if (partition >= numPartitions) - throw std::runtime_error( - "removeLeaf: leaf is not assigned to a valid partition"); - - weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; - sumNumberOfSamples_ -= leafWeights_[leaf]; - partitionWeight_[partition] -= leafWeights_[leaf]; - partitionSampleCount_[partition] -= leafSampleCounts_[leaf]; - - eraseLeafFromPartition(leaf, partition); + const auto &binY = binYsSorted_[leaf][o]; + const auto &binW = binWsSorted_[leaf][o]; + auto &partY = partYs_[partition][o]; + auto &partW = partWs_[partition][o]; + auto &partSrc = partSrcBin_[partition][o]; - partitionLoss_[partition] = computePartitionMae(partition); - weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; - - assignments[leaf] = kUnassignedPartition(numPartitions); -} + if (binY.empty()) + continue; + if (partY.empty()) { + partY = binY; + partW = binW; + partSrc.assign(binY.size(), leaf); + continue; + } -double -AbsoluteErrorBranchAssignment::computePartitionMae(size_t partition) const { - double total = 0.0; - for (size_t o = 0; o < nOutputs_; ++o) - total += trees_[partition][o].mae(); - return total; + std::vector outY; + std::vector outW; + std::vector outSrc; + outY.reserve(partY.size() + binY.size()); + outW.reserve(partW.size() + binW.size()); + outSrc.reserve(partSrc.size() + binY.size()); + + size_t i = 0; + size_t j = 0; + while (i < partY.size() && j < binY.size()) { + if (partY[i] <= binY[j]) { + outY.push_back(partY[i]); + outW.push_back(partW[i]); + outSrc.push_back(partSrc[i]); + ++i; + } else { + outY.push_back(binY[j]); + outW.push_back(binW[j]); + outSrc.push_back(leaf); + ++j; + } + } + while (i < partY.size()) { + outY.push_back(partY[i]); + outW.push_back(partW[i]); + outSrc.push_back(partSrc[i]); + ++i; + } + while (j < binY.size()) { + outY.push_back(binY[j]); + outW.push_back(binW[j]); + outSrc.push_back(leaf); + ++j; + } + partY.swap(outY); + partW.swap(outW); + partSrc.swap(outSrc); + } } -// --------------------------------------------------------------------------- -// Sort-based reference (original algorithm) -// --------------------------------------------------------------------------- - -AbsoluteErrorBranchAssignmentSort::AbsoluteErrorBranchAssignmentSort( - std::vector &assignments, size_t numPartitions, - std::vector>> &leafYs, - std::vector> &leafWs, std::vector &leafWeights, - const std::vector &leafSampleCounts) - : BranchAssignment(assignments, numPartitions, leafSampleCounts), - leafYs_(leafYs), leafWs_(leafWs), leafWeights_(leafWeights) { - - validateAbsoluteErrorInputs(assignments, numPartitions, leafYs, leafWs, - leafWeights, leafSampleCounts, nOutputs_); - - partitionWeight_.assign(numPartitions, 0.0); - partitionLoss_.assign(numPartitions, 0.0); +void AbsoluteErrorBranchAssignment::filterLeafFromPartition(size_t leaf, + size_t partition) { + for (size_t o = 0; o < nOutputs_; ++o) { + auto &partY = partYs_[partition][o]; + auto &partW = partWs_[partition][o]; + auto &partSrc = partSrcBin_[partition][o]; + if (partY.empty()) + continue; - const size_t numLeaves = assignments.size(); - for (size_t b = 0; b < numLeaves; ++b) { - if (assignments[b] < numPartitions) { - partitionWeight_[assignments[b]] += leafWeights_[b]; - partitionSampleCount_[assignments[b]] += leafSampleCounts_[b]; + std::vector outY; + std::vector outW; + std::vector outSrc; + outY.reserve(partY.size()); + outW.reserve(partW.size()); + outSrc.reserve(partSrc.size()); + for (size_t i = 0; i < partY.size(); ++i) { + if (partSrc[i] == leaf) + continue; + outY.push_back(partY[i]); + outW.push_back(partW[i]); + outSrc.push_back(partSrc[i]); } - } - - for (size_t p = 0; p < numPartitions; ++p) { - sumNumberOfSamples_ += partitionWeight_[p]; - partitionLoss_[p] = computePartitionMae(p); - weightedSumLoss_ += partitionWeight_[p] * partitionLoss_[p]; + partY.swap(outY); + partW.swap(outW); + partSrc.swap(outSrc); } } -double AbsoluteErrorBranchAssignmentSort::objective() { +double AbsoluteErrorBranchAssignment::objective() { return sumNumberOfSamples_ > 0.0 ? weightedSumLoss_ / static_cast(sumNumberOfSamples_) : 0.0; } -void AbsoluteErrorBranchAssignmentSort::addLeaf(size_t leaf, size_t partition) { +void AbsoluteErrorBranchAssignment::addLeaf(size_t leaf, size_t partition) { weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; partitionWeight_[partition] += leafWeights_[leaf]; partitionSampleCount_[partition] += leafSampleCounts_[leaf]; sumNumberOfSamples_ += leafWeights_[leaf]; - assignments[leaf] = partition; + + mergeLeafIntoPartition(leaf, partition); partitionLoss_[partition] = computePartitionMae(partition); weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; + + assignments[leaf] = partition; } -void AbsoluteErrorBranchAssignmentSort::removeLeaf(size_t leaf) { +void AbsoluteErrorBranchAssignment::removeLeaf(size_t leaf) { const size_t partition = assignments[leaf]; if (partition >= numPartitions) throw std::runtime_error( @@ -218,51 +194,21 @@ void AbsoluteErrorBranchAssignmentSort::removeLeaf(size_t leaf) { sumNumberOfSamples_ -= leafWeights_[leaf]; partitionWeight_[partition] -= leafWeights_[leaf]; partitionSampleCount_[partition] -= leafSampleCounts_[leaf]; - assignments[leaf] = kUnassignedPartition(numPartitions); + + filterLeafFromPartition(leaf, partition); partitionLoss_[partition] = computePartitionMae(partition); weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; -} -void AbsoluteErrorBranchAssignmentSort::collectPartitionSamples( - size_t partition, size_t output, std::vector &ys, - std::vector &ws) const { - ys.clear(); - ws.clear(); - for (size_t b = 0; b < assignments.size(); ++b) { - if (assignments[b] != partition) - continue; - if (output < leafYs_[b].size()) - ys.insert(ys.end(), leafYs_[b][output].begin(), leafYs_[b][output].end()); - ws.insert(ws.end(), leafWs_[b].begin(), leafWs_[b].end()); - } + assignments[leaf] = absolute_error_branch::unassignedPartition(numPartitions); } double -AbsoluteErrorBranchAssignmentSort::computePartitionMae(size_t partition) const { +AbsoluteErrorBranchAssignment::computePartitionMae(size_t partition) const { double total = 0.0; - std::vector ys; - std::vector ws; - for (size_t o = 0; o < nOutputs_; ++o) { - collectPartitionSamples(partition, o, ys, ws); - if (ys.size() <= 1) { - total += Criterion::absoluteError(ys, ws).mae; - continue; - } - std::vector order(ys.size()); - for (size_t i = 0; i < order.size(); ++i) - order[i] = i; - std::sort(order.begin(), order.end(), - [&ys](size_t a, size_t b) { return ys[a] < ys[b]; }); - std::vector ysSorted; - std::vector wsSorted; - ysSorted.reserve(ys.size()); - wsSorted.reserve(ws.size()); - for (size_t idx : order) { - ysSorted.push_back(ys[idx]); - wsSorted.push_back(ws[idx]); - } - total += Criterion::absoluteError(ysSorted, wsSorted).mae; - } + for (size_t o = 0; o < nOutputs_; ++o) + total += Criterion::absoluteErrorPresorted(partYs_[partition][o], + partWs_[partition][o]) + .mae; return total; } diff --git a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h index 2141de3..8ca0fca 100644 --- a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h +++ b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignment.h @@ -2,33 +2,32 @@ /** * @file AbsoluteErrorBranchAssignment.h - * @brief MAE branch assignment with per-partition ``WeightedMAETree`` multisets. + * @brief Default MAE branch assignment: sorted bins + merge/filter partitions. + * + * Production AbsoluteError backend. Deprecated alternatives: + * ``AbsoluteErrorBranchAssignmentBst``, ``AbsoluteErrorBranchAssignmentSort``. + * Hot-swap via ``SGTLEARN_MAE_BACKEND`` (default ``merge``). */ #include #include "BranchAssignment.h" -#include "algorithms/WeightedMAETree.h" #include /** - * Multi-output MAE branch-assignment objective: per-partition loss is the SUM - * over outputs of the MAE about that output's median. Each partition/output - * owns a ``WeightedMAETree``; ``addLeaf`` / ``removeLeaf`` batch-insert or - * batch-erase that bin's ``(y, w)`` samples in ``O(K log N)``. + * Multi-output MAE using pre-sorted per-bin arrays and sorted partitions. + * Join: mergesort-style merge (``O(n + k)``). Leave: filter by source-bin id + * (``O(n)``). MAE uses ``Criterion::absoluteErrorPresorted``. */ class AbsoluteErrorBranchAssignment : public BranchAssignment { public: AbsoluteErrorBranchAssignment( std::vector &assignments, size_t numPartitions, std::vector>> &leafYs, - std::vector> &leafWs, - std::vector &leafWeights, + std::vector> &leafWs, std::vector &leafWeights, const std::vector &leafSampleCounts); double objective() override; - void addLeaf(size_t leaf, size_t partition) override; - void removeLeaf(size_t leaf) override; private: @@ -42,55 +41,21 @@ class AbsoluteErrorBranchAssignment : public BranchAssignment { std::vector partitionWeight_; std::vector partitionLoss_; - /** ``trees_[partition][output]``. */ - std::vector> trees_; - - double computePartitionMae(size_t partition) const; - - void insertLeafIntoPartition(size_t leaf, size_t partition); - void eraseLeafFromPartition(size_t leaf, size_t partition); - - /** Valid partitions are [0, numPartitions); this marks a leaf not in any partition. */ - static constexpr size_t kUnassignedPartition(size_t numPartitions) { - return numPartitions; - } -}; - -/** - * Reference MAE branch assignment that re-sorts each affected partition on every - * add/remove (original ``O(n log n)`` path). Kept for correctness / perf tests. - */ -class AbsoluteErrorBranchAssignmentSort : public BranchAssignment { -public: - AbsoluteErrorBranchAssignmentSort( - std::vector &assignments, size_t numPartitions, - std::vector>> &leafYs, - std::vector> &leafWs, - std::vector &leafWeights, - const std::vector &leafSampleCounts); - - double objective() override; - void addLeaf(size_t leaf, size_t partition) override; - void removeLeaf(size_t leaf) override; -private: - std::vector>> &leafYs_; - std::vector> &leafWs_; - std::vector &leafWeights_; - size_t nOutputs_ = 0; + std::vector>> binYsSorted_; + std::vector>> binWsSorted_; - double weightedSumLoss_ = 0; - double sumNumberOfSamples_ = 0; + std::vector>> partYs_; + std::vector>> partWs_; + std::vector>> partSrcBin_; - std::vector partitionWeight_; - std::vector partitionLoss_; - - void collectPartitionSamples(size_t partition, size_t output, - std::vector &ys, - std::vector &ws) const; + void buildSortedBin(size_t leaf); + void mergeLeafIntoPartition(size_t leaf, size_t partition); + void filterLeafFromPartition(size_t leaf, size_t partition); double computePartitionMae(size_t partition) const; - - static constexpr size_t kUnassignedPartition(size_t numPartitions) { - return numPartitions; - } }; + +/** @deprecated Prefer ``AbsoluteErrorBranchAssignment`` (merge is default). */ +using AbsoluteErrorBranchAssignmentMerge [[deprecated( + "Use AbsoluteErrorBranchAssignment; merge is the default backend")]] = + AbsoluteErrorBranchAssignment; diff --git a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.cpp b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.cpp new file mode 100644 index 0000000..79b43c6 --- /dev/null +++ b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.cpp @@ -0,0 +1,125 @@ +/** + * @file AbsoluteErrorBranchAssignmentBst.cpp + * @brief Deprecated AVL/order-statistic MAE branch assignment. + */ + +#include "AbsoluteErrorBranchAssignmentBst.h" + +#include "AbsoluteErrorBranchAssignmentCommon.h" + +#include +#include + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + +AbsoluteErrorBranchAssignmentBst::AbsoluteErrorBranchAssignmentBst( + std::vector &assignments, size_t numPartitions, + std::vector>> &leafYs, + std::vector> &leafWs, std::vector &leafWeights, + const std::vector &leafSampleCounts) + : BranchAssignment(assignments, numPartitions, leafSampleCounts), + leafYs_(leafYs), leafWs_(leafWs), leafWeights_(leafWeights) { + + absolute_error_branch::validateInputs(assignments, numPartitions, leafYs, + leafWs, leafWeights, leafSampleCounts, + nOutputs_); + + partitionWeight_.assign(numPartitions, 0.0); + partitionLoss_.assign(numPartitions, 0.0); + trees_.resize(numPartitions); + for (size_t p = 0; p < numPartitions; ++p) + trees_[p].resize(nOutputs_); + + const size_t numLeaves = assignments.size(); + for (size_t b = 0; b < numLeaves; ++b) { + if (assignments[b] >= numPartitions) + continue; + partitionWeight_[assignments[b]] += leafWeights_[b]; + partitionSampleCount_[assignments[b]] += leafSampleCounts_[b]; + insertLeafIntoPartition(b, assignments[b]); + } + + for (size_t p = 0; p < numPartitions; ++p) { + sumNumberOfSamples_ += partitionWeight_[p]; + partitionLoss_[p] = computePartitionMae(p); + weightedSumLoss_ += partitionWeight_[p] * partitionLoss_[p]; + } +} + +double AbsoluteErrorBranchAssignmentBst::objective() { + return sumNumberOfSamples_ > 0.0 + ? weightedSumLoss_ / static_cast(sumNumberOfSamples_) + : 0.0; +} + +void AbsoluteErrorBranchAssignmentBst::insertLeafIntoPartition( + size_t leaf, size_t partition) { + if (nOutputs_ == 0) + return; + const auto &ws = leafWs_[leaf]; + for (size_t o = 0; o < nOutputs_; ++o) { + if (o >= leafYs_[leaf].size()) + continue; + trees_[partition][o].insert_batch(leafYs_[leaf][o], ws); + } +} + +void AbsoluteErrorBranchAssignmentBst::eraseLeafFromPartition( + size_t leaf, size_t partition) { + if (nOutputs_ == 0) + return; + const auto &ws = leafWs_[leaf]; + for (size_t o = 0; o < nOutputs_; ++o) { + if (o >= leafYs_[leaf].size()) + continue; + trees_[partition][o].remove_batch(leafYs_[leaf][o], ws); + } +} + +void AbsoluteErrorBranchAssignmentBst::addLeaf(size_t leaf, size_t partition) { + weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; + + partitionWeight_[partition] += leafWeights_[leaf]; + partitionSampleCount_[partition] += leafSampleCounts_[leaf]; + sumNumberOfSamples_ += leafWeights_[leaf]; + + insertLeafIntoPartition(leaf, partition); + + partitionLoss_[partition] = computePartitionMae(partition); + weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; + + assignments[leaf] = partition; +} + +void AbsoluteErrorBranchAssignmentBst::removeLeaf(size_t leaf) { + const size_t partition = assignments[leaf]; + if (partition >= numPartitions) + throw std::runtime_error( + "removeLeaf: leaf is not assigned to a valid partition"); + + weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; + sumNumberOfSamples_ -= leafWeights_[leaf]; + partitionWeight_[partition] -= leafWeights_[leaf]; + partitionSampleCount_[partition] -= leafSampleCounts_[leaf]; + + eraseLeafFromPartition(leaf, partition); + + partitionLoss_[partition] = computePartitionMae(partition); + weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; + + assignments[leaf] = absolute_error_branch::unassignedPartition(numPartitions); +} + +double +AbsoluteErrorBranchAssignmentBst::computePartitionMae(size_t partition) const { + double total = 0.0; + for (size_t o = 0; o < nOutputs_; ++o) + total += trees_[partition][o].mae(); + return total; +} + +#pragma GCC diagnostic pop +#pragma clang diagnostic pop diff --git a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.h b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.h new file mode 100644 index 0000000..7f71fa1 --- /dev/null +++ b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentBst.h @@ -0,0 +1,50 @@ +#pragma once + +/** + * @file AbsoluteErrorBranchAssignmentBst.h + * @brief Deprecated MAE branch assignment using per-partition WeightedMAETree. + */ + +#include +#include "BranchAssignment.h" +#include "algorithms/WeightedMAETree.h" +#include + +/** + * Multi-output MAE with per-partition ``WeightedMAETree`` multisets. + * + * @deprecated Prefer ``AbsoluteErrorBranchAssignment`` (merge/filter). Kept for + * benchmarks and A/B via ``SGTLEARN_MAE_BACKEND=bst``. + */ +class [[deprecated( + "Use AbsoluteErrorBranchAssignment (merge/filter); set " + "SGTLEARN_MAE_BACKEND=bst only for benchmarks")]] AbsoluteErrorBranchAssignmentBst + : public BranchAssignment { +public: + AbsoluteErrorBranchAssignmentBst( + std::vector &assignments, size_t numPartitions, + std::vector>> &leafYs, + std::vector> &leafWs, std::vector &leafWeights, + const std::vector &leafSampleCounts); + + double objective() override; + void addLeaf(size_t leaf, size_t partition) override; + void removeLeaf(size_t leaf) override; + +private: + std::vector>> &leafYs_; + std::vector> &leafWs_; + std::vector &leafWeights_; + size_t nOutputs_ = 0; + + double weightedSumLoss_ = 0; + double sumNumberOfSamples_ = 0; + + std::vector partitionWeight_; + std::vector partitionLoss_; + std::vector> trees_; + + double computePartitionMae(size_t partition) const; + void insertLeafIntoPartition(size_t leaf, size_t partition); + void eraseLeafFromPartition(size_t leaf, size_t partition); +}; diff --git a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentCommon.h b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentCommon.h new file mode 100644 index 0000000..8a3bc8a --- /dev/null +++ b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentCommon.h @@ -0,0 +1,51 @@ +#pragma once + +/** + * @file AbsoluteErrorBranchAssignmentCommon.h + * @brief Shared validation helpers for AbsoluteError branch-assignment backends. + */ + +#include +#include +#include + +namespace absolute_error_branch { + +inline constexpr size_t unassignedPartition(size_t numPartitions) { + return numPartitions; +} + +inline void validateInputs( + const std::vector &assignments, size_t numPartitions, + const std::vector>> &leafYs, + const std::vector> &leafWs, + const std::vector &leafWeights, + const std::vector &leafSampleCounts, size_t &nOutputs) { + if (assignments.size() != leafYs.size() || leafYs.size() != leafWs.size() || + leafYs.size() != leafWeights.size()) + throw std::runtime_error( + "assignments, leafYs, leafWs, and leafWeights must have the same length"); + if (leafSampleCounts.size() != leafYs.size()) + throw std::runtime_error( + "leafSampleCounts must have the same length as bin statistics"); + + nOutputs = 0; + for (const auto &binOutputs : leafYs) { + if (!binOutputs.empty()) { + nOutputs = binOutputs.size(); + break; + } + } + + for (size_t i = 0; i < leafYs.size(); ++i) { + if (assignments[i] >= numPartitions) + throw std::runtime_error("assignments[i] must be a valid partition index"); + for (const auto &outputYs : leafYs[i]) { + if (outputYs.size() != leafWs[i].size()) + throw std::runtime_error( + "leafYs[i][o] and leafWs[i] must have the same length"); + } + } +} + +} // namespace absolute_error_branch diff --git a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.cpp b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.cpp new file mode 100644 index 0000000..5d9d6a5 --- /dev/null +++ b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.cpp @@ -0,0 +1,128 @@ +/** + * @file AbsoluteErrorBranchAssignmentSort.cpp + * @brief Deprecated full re-sort MAE branch assignment. + */ + +#include "AbsoluteErrorBranchAssignmentSort.h" + +#include "AbsoluteErrorBranchAssignmentCommon.h" +#include "Criterion.h" + +#include +#include +#include + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + +AbsoluteErrorBranchAssignmentSort::AbsoluteErrorBranchAssignmentSort( + std::vector &assignments, size_t numPartitions, + std::vector>> &leafYs, + std::vector> &leafWs, std::vector &leafWeights, + const std::vector &leafSampleCounts) + : BranchAssignment(assignments, numPartitions, leafSampleCounts), + leafYs_(leafYs), leafWs_(leafWs), leafWeights_(leafWeights) { + + absolute_error_branch::validateInputs(assignments, numPartitions, leafYs, + leafWs, leafWeights, leafSampleCounts, + nOutputs_); + + partitionWeight_.assign(numPartitions, 0.0); + partitionLoss_.assign(numPartitions, 0.0); + + const size_t numLeaves = assignments.size(); + for (size_t b = 0; b < numLeaves; ++b) { + if (assignments[b] < numPartitions) { + partitionWeight_[assignments[b]] += leafWeights_[b]; + partitionSampleCount_[assignments[b]] += leafSampleCounts_[b]; + } + } + + for (size_t p = 0; p < numPartitions; ++p) { + sumNumberOfSamples_ += partitionWeight_[p]; + partitionLoss_[p] = computePartitionMae(p); + weightedSumLoss_ += partitionWeight_[p] * partitionLoss_[p]; + } +} + +double AbsoluteErrorBranchAssignmentSort::objective() { + return sumNumberOfSamples_ > 0.0 + ? weightedSumLoss_ / static_cast(sumNumberOfSamples_) + : 0.0; +} + +void AbsoluteErrorBranchAssignmentSort::addLeaf(size_t leaf, size_t partition) { + weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; + + partitionWeight_[partition] += leafWeights_[leaf]; + partitionSampleCount_[partition] += leafSampleCounts_[leaf]; + sumNumberOfSamples_ += leafWeights_[leaf]; + assignments[leaf] = partition; + + partitionLoss_[partition] = computePartitionMae(partition); + weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; +} + +void AbsoluteErrorBranchAssignmentSort::removeLeaf(size_t leaf) { + const size_t partition = assignments[leaf]; + if (partition >= numPartitions) + throw std::runtime_error( + "removeLeaf: leaf is not assigned to a valid partition"); + + weightedSumLoss_ -= partitionWeight_[partition] * partitionLoss_[partition]; + sumNumberOfSamples_ -= leafWeights_[leaf]; + partitionWeight_[partition] -= leafWeights_[leaf]; + partitionSampleCount_[partition] -= leafSampleCounts_[leaf]; + assignments[leaf] = absolute_error_branch::unassignedPartition(numPartitions); + + partitionLoss_[partition] = computePartitionMae(partition); + weightedSumLoss_ += partitionWeight_[partition] * partitionLoss_[partition]; +} + +void AbsoluteErrorBranchAssignmentSort::collectPartitionSamples( + size_t partition, size_t output, std::vector &ys, + std::vector &ws) const { + ys.clear(); + ws.clear(); + for (size_t b = 0; b < assignments.size(); ++b) { + if (assignments[b] != partition) + continue; + if (output < leafYs_[b].size()) + ys.insert(ys.end(), leafYs_[b][output].begin(), leafYs_[b][output].end()); + ws.insert(ws.end(), leafWs_[b].begin(), leafWs_[b].end()); + } +} + +double +AbsoluteErrorBranchAssignmentSort::computePartitionMae(size_t partition) const { + double total = 0.0; + std::vector ys; + std::vector ws; + for (size_t o = 0; o < nOutputs_; ++o) { + collectPartitionSamples(partition, o, ys, ws); + if (ys.size() <= 1) { + total += Criterion::absoluteError(ys, ws).mae; + continue; + } + std::vector order(ys.size()); + for (size_t i = 0; i < order.size(); ++i) + order[i] = i; + std::sort(order.begin(), order.end(), + [&ys](size_t a, size_t b) { return ys[a] < ys[b]; }); + std::vector ysSorted; + std::vector wsSorted; + ysSorted.reserve(ys.size()); + wsSorted.reserve(ws.size()); + for (size_t idx : order) { + ysSorted.push_back(ys[idx]); + wsSorted.push_back(ws[idx]); + } + total += Criterion::absoluteErrorPresorted(ysSorted, wsSorted).mae; + } + return total; +} + +#pragma GCC diagnostic pop +#pragma clang diagnostic pop diff --git a/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.h b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.h new file mode 100644 index 0000000..065b643 --- /dev/null +++ b/cpp/src/BranchAssignmentObjectives/AbsoluteErrorBranchAssignmentSort.h @@ -0,0 +1,49 @@ +#pragma once + +/** + * @file AbsoluteErrorBranchAssignmentSort.h + * @brief Deprecated MAE branch assignment that re-sorts partitions on each move. + */ + +#include +#include "BranchAssignment.h" +#include + +/** + * Reference MAE path: collect partition samples and re-sort on every add/remove. + * + * @deprecated Prefer ``AbsoluteErrorBranchAssignment`` (merge/filter). Kept for + * benchmarks and A/B via ``SGTLEARN_MAE_BACKEND=sort``. + */ +class [[deprecated( + "Use AbsoluteErrorBranchAssignment (merge/filter); set " + "SGTLEARN_MAE_BACKEND=sort only for benchmarks")]] AbsoluteErrorBranchAssignmentSort + : public BranchAssignment { +public: + AbsoluteErrorBranchAssignmentSort( + std::vector &assignments, size_t numPartitions, + std::vector>> &leafYs, + std::vector> &leafWs, std::vector &leafWeights, + const std::vector &leafSampleCounts); + + double objective() override; + void addLeaf(size_t leaf, size_t partition) override; + void removeLeaf(size_t leaf) override; + +private: + std::vector>> &leafYs_; + std::vector> &leafWs_; + std::vector &leafWeights_; + size_t nOutputs_ = 0; + + double weightedSumLoss_ = 0; + double sumNumberOfSamples_ = 0; + + std::vector partitionWeight_; + std::vector partitionLoss_; + + void collectPartitionSamples(size_t partition, size_t output, + std::vector &ys, + std::vector &ws) const; + double computePartitionMae(size_t partition) const; +}; diff --git a/cpp/src/BranchAssignmentObjectives/BranchAssignmentFactory.cpp b/cpp/src/BranchAssignmentObjectives/BranchAssignmentFactory.cpp index 4b57653..cb374bc 100644 --- a/cpp/src/BranchAssignmentObjectives/BranchAssignmentFactory.cpp +++ b/cpp/src/BranchAssignmentObjectives/BranchAssignmentFactory.cpp @@ -3,16 +3,23 @@ * @brief Factory implementation for ``BranchAssignment`` objects. */ -#include #include +#include #include "BranchAssignmentFactory.h" #include "AbsoluteErrorBranchAssignment.h" +#include "AbsoluteErrorBranchAssignmentBst.h" +#include "AbsoluteErrorBranchAssignmentSort.h" #include "BranchAssignmentVariants.h" #include "MaeBranchConfig.h" #include +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + std::unique_ptr makeBranchAssignment( LearningCriterion criterion, std::vector &assignments, size_t numPartitions, std::vector> &leafStats, @@ -27,13 +34,21 @@ std::unique_ptr makeBranchAssignment( throw std::invalid_argument( "makeBranchAssignment(AbsoluteError): maeLeafYs and maeLeafWs " "required"); - if (mae_branch_config::backend() == mae_branch_config::Backend::Sort) + switch (mae_branch_config::backend()) { + case mae_branch_config::Backend::Sort: return std::make_unique( assignments, numPartitions, *maeLeafYs, *maeLeafWs, leafWeights, leafSampleCounts); - return std::make_unique( - assignments, numPartitions, *maeLeafYs, *maeLeafWs, leafWeights, - leafSampleCounts); + case mae_branch_config::Backend::Bst: + return std::make_unique( + assignments, numPartitions, *maeLeafYs, *maeLeafWs, leafWeights, + leafSampleCounts); + case mae_branch_config::Backend::Merge: + default: + return std::make_unique( + assignments, numPartitions, *maeLeafYs, *maeLeafWs, leafWeights, + leafSampleCounts); + } case LearningCriterion::SquaredError: case LearningCriterion::Entropy: case LearningCriterion::Gini: @@ -46,6 +61,9 @@ std::unique_ptr makeBranchAssignment( } } +#pragma GCC diagnostic pop +#pragma clang diagnostic pop + std::unique_ptr makeBranchAssignment( LearningCriterion criterion, std::vector &assignments, size_t numPartitions, diff --git a/cpp/src/BranchAssignmentObjectives/BranchAssignmentVariants.h b/cpp/src/BranchAssignmentObjectives/BranchAssignmentVariants.h index 339d86c..c85cf04 100644 --- a/cpp/src/BranchAssignmentObjectives/BranchAssignmentVariants.h +++ b/cpp/src/BranchAssignmentObjectives/BranchAssignmentVariants.h @@ -7,6 +7,8 @@ #include #include "AbsoluteErrorBranchAssignment.h" +#include "AbsoluteErrorBranchAssignmentBst.h" +#include "AbsoluteErrorBranchAssignmentSort.h" #include "BranchAssignmentFactory.h" #include "LeafAggregationBranchAssignment.h" #include diff --git a/cpp/src/BranchAssignmentObjectives/MaeBranchConfig.h b/cpp/src/BranchAssignmentObjectives/MaeBranchConfig.h index 5e8f40a..8061647 100644 --- a/cpp/src/BranchAssignmentObjectives/MaeBranchConfig.h +++ b/cpp/src/BranchAssignmentObjectives/MaeBranchConfig.h @@ -5,7 +5,7 @@ * @brief Runtime toggles for AbsoluteError branch assignment (benchmarking / experiments). * * Environment variables (read on each call): - * - ``SGTLEARN_MAE_BACKEND``: ``bst`` (default) or ``sort`` + * - ``SGTLEARN_MAE_BACKEND``: ``merge`` (default), ``bst``, or ``sort`` * - ``SGTLEARN_MAE_CD``: ``1`` / ``true`` enables coordinate descent for * ``absolute_error`` (default off for sklearn CART parity) */ @@ -15,13 +15,18 @@ namespace mae_branch_config { -enum class Backend { Bst, Sort }; +enum class Backend { Merge, Bst, Sort }; inline Backend backend() { const char *v = std::getenv("SGTLEARN_MAE_BACKEND"); - if (v != nullptr && (std::strcmp(v, "sort") == 0 || std::strcmp(v, "Sort") == 0)) + if (v == nullptr) + return Backend::Merge; + if (std::strcmp(v, "sort") == 0 || std::strcmp(v, "Sort") == 0) return Backend::Sort; - return Backend::Bst; + if (std::strcmp(v, "bst") == 0 || std::strcmp(v, "Bst") == 0) + return Backend::Bst; + // Explicit merge, unknown values, or empty → default merge. + return Backend::Merge; } inline bool coordinateDescentEnabled() { diff --git a/cpp/src/Criterion.cpp b/cpp/src/Criterion.cpp index 273b908..71e3a2a 100644 --- a/cpp/src/Criterion.cpp +++ b/cpp/src/Criterion.cpp @@ -69,40 +69,34 @@ double Criterion::squaredError( } Criterion::AbsoluteErrorStats -Criterion::absoluteError(const std::vector &ys, - const std::vector &weights) { +Criterion::absoluteErrorPresorted(const std::vector &ys, + const std::vector &weights) { AbsoluteErrorStats out; const size_t n = ys.size(); if (n == 0 || n != weights.size()) return out; - std::vector> pairs; - pairs.reserve(n); for (size_t i = 0; i < n; ++i) { const double w = static_cast(weights[i]); if (w < 0.0) - return out; - pairs.emplace_back(static_cast(ys[i]), w); + return AbsoluteErrorStats{}; out.totalWeight += w; } if (out.totalWeight <= 0.0) return out; - std::sort(pairs.begin(), pairs.end(), - [](const auto &a, const auto &b) { return a.first < b.first; }); - const double half = 0.5 * out.totalWeight; double wLeft = 0.0; double wyLeft = 0.0; double totalWy = 0.0; - for (const auto &[y, w] : pairs) - totalWy += w * y; + for (size_t i = 0; i < n; ++i) + totalWy += static_cast(weights[i]) * static_cast(ys[i]); - int medianRank = static_cast(pairs.size()) - 1; + int medianRank = static_cast(n) - 1; int medianPrevRank = medianRank > 0 ? medianRank - 1 : -1; bool found = false; - for (size_t rank = 0; rank < pairs.size(); ++rank) { - const double w = pairs[rank].second; + for (size_t rank = 0; rank < n; ++rank) { + const double w = static_cast(weights[rank]); if (wLeft + w > half) { medianRank = static_cast(rank); medianPrevRank = rank > 0 ? static_cast(rank - 1) : -1; @@ -110,18 +104,19 @@ Criterion::absoluteError(const std::vector &ys, break; } wLeft += w; - wyLeft += w * pairs[rank].first; + wyLeft += w * static_cast(ys[rank]); } if (!found) { - wLeft = out.totalWeight - pairs.back().second; - wyLeft = totalWy - pairs.back().second * pairs.back().first; + const double wLast = static_cast(weights.back()); + wLeft = out.totalWeight - wLast; + wyLeft = totalWy - wLast * static_cast(ys.back()); } if (medianPrevRank >= 0 && std::fabs(wLeft - half) <= 1e-12) { - out.median = 0.5 * (pairs[static_cast(medianPrevRank)].first + - pairs[static_cast(medianRank)].first); + out.median = 0.5 * (static_cast(ys[static_cast(medianPrevRank)]) + + static_cast(ys[static_cast(medianRank)])); } else { - out.median = pairs[static_cast(medianRank)].first; + out.median = static_cast(ys[static_cast(medianRank)]); } const double wRight = out.totalWeight - wLeft; @@ -132,6 +127,30 @@ Criterion::absoluteError(const std::vector &ys, return out; } +Criterion::AbsoluteErrorStats +Criterion::absoluteError(const std::vector &ys, + const std::vector &weights) { + AbsoluteErrorStats out; + const size_t n = ys.size(); + if (n == 0 || n != weights.size()) + return out; + + std::vector ysSorted; + std::vector wsSorted; + ysSorted.reserve(n); + wsSorted.reserve(n); + std::vector order(n); + for (size_t i = 0; i < n; ++i) + order[i] = i; + std::sort(order.begin(), order.end(), + [&ys](size_t a, size_t b) { return ys[a] < ys[b]; }); + for (size_t idx : order) { + ysSorted.push_back(ys[idx]); + wsSorted.push_back(weights[idx]); + } + return absoluteErrorPresorted(ysSorted, wsSorted); +} + double Criterion::gainAndHessian(const std::vector &derivatives, double lambda) { const double g = static_cast(derivatives[0]); diff --git a/cpp/src/Criterion.h b/cpp/src/Criterion.h index 54d27ee..50c483c 100644 --- a/cpp/src/Criterion.h +++ b/cpp/src/Criterion.h @@ -41,5 +41,12 @@ struct AbsoluteErrorStats { AbsoluteErrorStats absoluteError(const std::vector &ys, const std::vector &weights); +/** + * Same as ``absoluteError`` but assumes ``ys`` are already sorted ascending + * (weights aligned). Skips the internal sort — used by merge/filter MAE CD. + */ +AbsoluteErrorStats absoluteErrorPresorted(const std::vector &ys, + const std::vector &weights); + double gainAndHessian(const std::vector &derivatives, double lambda); } // namespace Criterion diff --git a/cpp/tests/bench_mae_branch_assignment.cpp b/cpp/tests/bench_mae_branch_assignment.cpp index 2ecaef3..d9a759b 100644 --- a/cpp/tests/bench_mae_branch_assignment.cpp +++ b/cpp/tests/bench_mae_branch_assignment.cpp @@ -1,22 +1,35 @@ /** * @file bench_mae_branch_assignment.cpp - * @brief Wall-time comparison: sort-based vs BST AbsoluteError branch assignment. + * @brief CD wall-time: sort vs BST vs merge AbsoluteError backends. + * + * Writes a concise colleague-facing CSV under ``benchmarks/results/``. */ #include +#include +#include #include #include #include #include +#include +#include +#include #include #include +#include #include using Catch::Matchers::WithinAbs; using clock_type = std::chrono::steady_clock; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + namespace { struct MaeScenario { @@ -67,14 +80,14 @@ struct BenchResult { double objective = 0.0; }; -BenchResult timeBstCd(MaeScenario &base, uint64_t seed, int repeats) { +template +BenchResult timeCd(MaeScenario &base, uint64_t seed, int repeats, + MakeObj &&makeObj) { double totalMs = 0.0; double lastObj = 0.0; for (int r = 0; r < repeats; ++r) { auto asg = base.assignments; - AbsoluteErrorBranchAssignment obj(asg, base.numPartitions, base.leafYs, - base.leafWs, base.leafWeights, - base.leafSampleCounts); + auto obj = makeObj(asg); std::mt19937_64 rng(seed + static_cast(r)); const auto t0 = clock_type::now(); lastObj = coordinateDescent(base.numPartitions, obj, rng, 8, 3); @@ -84,55 +97,64 @@ BenchResult timeBstCd(MaeScenario &base, uint64_t seed, int repeats) { return {totalMs / static_cast(repeats), lastObj}; } -BenchResult timeSortCd(MaeScenario &base, uint64_t seed, int repeats) { - double totalMs = 0.0; - double lastObj = 0.0; - for (int r = 0; r < repeats; ++r) { - auto asg = base.assignments; - AbsoluteErrorBranchAssignmentSort obj(asg, base.numPartitions, base.leafYs, - base.leafWs, base.leafWeights, - base.leafSampleCounts); - std::mt19937_64 rng(seed + static_cast(r)); - const auto t0 = clock_type::now(); - lastObj = coordinateDescent(base.numPartitions, obj, rng, 8, 3); - const auto t1 = clock_type::now(); - totalMs += std::chrono::duration(t1 - t0).count(); +std::filesystem::path resultsDir() { + namespace fs = std::filesystem; + const fs::path candidates[] = { + fs::path("benchmarks") / "results", + fs::path("..") / "benchmarks" / "results", + fs::path("..") / ".." / "benchmarks" / "results", + }; + for (const auto &p : candidates) { + std::error_code ec; + if (fs::exists(p.parent_path(), ec)) + return p; } - return {totalMs / static_cast(repeats), lastObj}; + return fs::path("benchmarks") / "results"; } } // namespace -TEST_CASE("AbsoluteError BST and sort objectives match under CD", +TEST_CASE("AbsoluteError BST/sort/merge objectives match under CD", "[branch_assignment][absolute_error][correctness]") { auto scenario = makeScenario(/*numBins=*/32, /*samplesPerBin=*/40, /*numPartitions=*/4, /*nOutputs=*/1, /*seed=*/99); auto asgBst = scenario.assignments; auto asgSort = scenario.assignments; + auto asgMerge = scenario.assignments; - AbsoluteErrorBranchAssignment bst(asgBst, scenario.numPartitions, - scenario.leafYs, scenario.leafWs, - scenario.leafWeights, - scenario.leafSampleCounts); + AbsoluteErrorBranchAssignmentBst bst(asgBst, scenario.numPartitions, + scenario.leafYs, scenario.leafWs, + scenario.leafWeights, + scenario.leafSampleCounts); AbsoluteErrorBranchAssignmentSort sortObj(asgSort, scenario.numPartitions, scenario.leafYs, scenario.leafWs, scenario.leafWeights, scenario.leafSampleCounts); + AbsoluteErrorBranchAssignment mergeObj(asgMerge, scenario.numPartitions, + scenario.leafYs, scenario.leafWs, + scenario.leafWeights, + scenario.leafSampleCounts); REQUIRE_THAT(bst.objective(), WithinAbs(sortObj.objective(), 1e-6)); + REQUIRE_THAT(mergeObj.objective(), WithinAbs(sortObj.objective(), 1e-6)); std::mt19937_64 rngBst(123); std::mt19937_64 rngSort(123); + std::mt19937_64 rngMerge(123); const double bstFinal = coordinateDescent(scenario.numPartitions, bst, rngBst, 8, 3); const double sortFinal = coordinateDescent(scenario.numPartitions, sortObj, rngSort, 8, 3); + const double mergeFinal = + coordinateDescent(scenario.numPartitions, mergeObj, rngMerge, 8, 3); REQUIRE_THAT(bstFinal, WithinAbs(sortFinal, 1e-5)); + REQUIRE_THAT(mergeFinal, WithinAbs(sortFinal, 1e-5)); REQUIRE(asgBst == asgSort); + REQUIRE(asgMerge == asgSort); } -TEST_CASE("Bench AbsoluteError branch assignment: sort vs BST", +TEST_CASE("Bench AbsoluteError branch assignment: sort vs BST vs merge", "[.benchmark]") { struct Case { const char *name; @@ -144,30 +166,74 @@ TEST_CASE("Bench AbsoluteError branch assignment: sort vs BST", }; const Case cases[] = { - {"small 64 bins x 20", 64, 20, 4, 1, 5}, - {"medium 128 bins x 50", 128, 50, 4, 1, 3}, - {"large 256 bins x 100", 256, 100, 8, 1, 2}, - {"multi-out 128 x 40 x 3", 128, 40, 4, 3, 3}, + {"small_64x20", 64, 20, 4, 1, 5}, + {"medium_128x50", 128, 50, 4, 1, 3}, + {"large_256x100", 256, 100, 8, 1, 2}, + {"multiout_128x40x3", 128, 40, 4, 3, 3}, }; + const auto outDir = resultsDir(); + std::filesystem::create_directories(outDir); + const auto csvPath = outDir / "mae_branch_cd_comparison.csv"; + std::ofstream csv(csvPath); + csv << "case,n_bins,samples_per_bin,n_partitions,n_outputs," + "sort_ms,bst_ms,merge_ms," + "bst_speedup_vs_sort,merge_speedup_vs_sort," + "sort_obj,bst_obj,merge_obj\n"; + std::cout << '\n' - << "AbsoluteError CD bench (sort = re-sort partition; bst = " - "WeightedMAETree)\n"; - std::cout << "--------------------------------------------------------------" + << "AbsoluteError CD bench: sort | bst | merge (default)\n" + << "CSV -> " << csvPath << '\n' + << "--------------------------------------------------------------" "--------\n"; for (const Case &c : cases) { auto scenario = makeScenario(c.bins, c.samplesPerBin, c.parts, c.outputs, 2026); - const auto sortRes = timeSortCd(scenario, 7, c.repeats); - const auto bstRes = timeBstCd(scenario, 7, c.repeats); - const double speedup = sortRes.ms > 0.0 ? (sortRes.ms / bstRes.ms) : 0.0; - std::cout << c.name << ": sort " << sortRes.ms << " ms, bst " << bstRes.ms - << " ms, speedup " << speedup << "x (obj sort=" << sortRes.objective - << " bst=" << bstRes.objective << ")\n"; - REQUIRE(bstRes.ms > 0.0); + const auto sortRes = timeCd(scenario, 7, c.repeats, [&](auto &asg) { + return AbsoluteErrorBranchAssignmentSort( + asg, scenario.numPartitions, scenario.leafYs, scenario.leafWs, + scenario.leafWeights, scenario.leafSampleCounts); + }); + const auto bstRes = timeCd(scenario, 7, c.repeats, [&](auto &asg) { + return AbsoluteErrorBranchAssignmentBst( + asg, scenario.numPartitions, scenario.leafYs, scenario.leafWs, + scenario.leafWeights, scenario.leafSampleCounts); + }); + const auto mergeRes = timeCd(scenario, 7, c.repeats, [&](auto &asg) { + return AbsoluteErrorBranchAssignment( + asg, scenario.numPartitions, scenario.leafYs, scenario.leafWs, + scenario.leafWeights, scenario.leafSampleCounts); + }); + + const double bstSpeedup = + bstRes.ms > 0.0 ? (sortRes.ms / bstRes.ms) : 0.0; + const double mergeSpeedup = + mergeRes.ms > 0.0 ? (sortRes.ms / mergeRes.ms) : 0.0; + + std::cout << std::fixed << std::setprecision(2) << c.name << ": sort " + << sortRes.ms << " ms | bst " << bstRes.ms << " ms (" + << bstSpeedup << "x) | merge " << mergeRes.ms << " ms (" + << mergeSpeedup << "x)\n"; + + csv << std::fixed << std::setprecision(3) << c.name << ',' << c.bins << ',' + << c.samplesPerBin << ',' << c.parts << ',' << c.outputs << ',' + << sortRes.ms << ',' << bstRes.ms << ',' << mergeRes.ms << ',' + << std::setprecision(3) << bstSpeedup << ',' << mergeSpeedup << ',' + << std::setprecision(6) << sortRes.objective << ',' << bstRes.objective + << ',' << mergeRes.objective << '\n'; + REQUIRE(sortRes.ms > 0.0); + REQUIRE(bstRes.ms > 0.0); + REQUIRE(mergeRes.ms > 0.0); + REQUIRE_THAT(bstRes.objective, WithinAbs(sortRes.objective, 1e-4)); + REQUIRE_THAT(mergeRes.objective, WithinAbs(sortRes.objective, 1e-4)); } - std::cout << std::flush; + + csv.flush(); + std::cout << "Wrote " << csvPath << std::endl; } + +#pragma GCC diagnostic pop +#pragma clang diagnostic pop From 488bff6d59945302f220a1ed689d9e8028fea8ae Mon Sep 17 00:00:00 2001 From: Joshua Lee Date: Sat, 22 Aug 2026 17:34:18 -0400 Subject: [PATCH 3/4] fixed linting checks --- sgtlearn/__init__.py | 16 ++-- sgtlearn/_export.py | 34 ++++----- sgtlearn/_features.py | 3 +- sgtlearn/_multioutput.py | 15 ++-- sgtlearn/_weights.py | 20 ++--- sgtlearn/base.py | 73 +++++++++---------- sgtlearn/datasets.py | 4 +- sgtlearn/ensemble/__init__.py | 4 +- sgtlearn/ensemble/_random_sgforest.py | 73 +++++++++---------- ...ifier.py => random_sgforest_classifier.py} | 23 +++--- ...ressor.py => random_sgforest_regressor.py} | 16 ++-- sgtlearn/tao.py | 14 ++-- 12 files changed, 146 insertions(+), 149 deletions(-) rename sgtlearn/ensemble/{RandomSGForestClassifier.py => random_sgforest_classifier.py} (94%) rename sgtlearn/ensemble/{RandomSGForestRegressor.py => random_sgforest_regressor.py} (95%) diff --git a/sgtlearn/__init__.py b/sgtlearn/__init__.py index 065eccf..e3ef573 100644 --- a/sgtlearn/__init__.py +++ b/sgtlearn/__init__.py @@ -4,29 +4,29 @@ ``Discretizers``). Import ``SGTClassifier`` from this package for the sklearn-style API. """ +from sgtlearn import tao +from sgtlearn._export import export_graphviz, export_text, plot_tree from sgtlearn.base import ( BaseShapeCART, + ProcessedFeatures, SGTClassifier, SGTRegressor, - ProcessedFeatures, configure_feature_dict, ) -from sgtlearn.ensemble import RandomSGForestClassifier, RandomSGForestRegressor -from sgtlearn._export import export_graphviz, export_text, plot_tree from sgtlearn.datasets import make_plus -from sgtlearn import tao +from sgtlearn.ensemble import RandomSGForestClassifier, RandomSGForestRegressor __all__ = [ "BaseShapeCART", - "SGTClassifier", - "SGTRegressor", "ProcessedFeatures", - "configure_feature_dict", "RandomSGForestClassifier", "RandomSGForestRegressor", + "SGTClassifier", + "SGTRegressor", + "configure_feature_dict", "export_graphviz", "export_text", - "plot_tree", "make_plus", + "plot_tree", "tao", ] diff --git a/sgtlearn/_export.py b/sgtlearn/_export.py index 41545fd..66d59cd 100644 --- a/sgtlearn/_export.py +++ b/sgtlearn/_export.py @@ -9,11 +9,13 @@ from __future__ import annotations -from typing import Any, Optional, Sequence, Union +from collections.abc import Sequence +from typing import Any + import numpy as np from matplotlib.patches import FancyArrowPatch -__all__ = ["plot_tree", "export_graphviz", "export_text"] +__all__ = ["export_graphviz", "export_text", "plot_tree"] import matplotlib.pyplot as plt from sklearn.utils.validation import check_is_fitted @@ -198,7 +200,7 @@ def _merge_routing_regions( return regions -def _route_samples(tree: dict, X) -> "dict[int, Any]": +def _route_samples(tree: dict, X) -> dict[int, Any]: """Route ``X`` through the tree; return ``{node_id: column-indices}``. The returned array for each node lists the row indices of ``X`` that @@ -273,7 +275,7 @@ def _route_samples(tree: dict, X) -> "dict[int, Any]": def _compute_layout_leafcounter( - tree: dict, max_depth: Optional[int] + tree: dict, max_depth: int | None ) -> dict[int, tuple[float, float]]: """Leaf-counter layout in axes coords [0, 1]. @@ -290,9 +292,7 @@ def is_draw_leaf(nid: int, depth: int) -> bool: n = nodes_by_id[nid] if n["is_leaf"]: return True - if max_depth is not None and depth >= max_depth: - return True - return False + return bool(max_depth is not None and depth >= max_depth) x_int: dict[int, float] = {} counter = [0] @@ -388,10 +388,10 @@ def _draw_leaf_text( node: dict, *, is_classifier: bool, - class_names: Optional[list[str]], + class_names: list[str] | None, criterion: str, precision: int, - fontsize: Optional[int], + fontsize: int | None, color, label: str, impurity: bool, @@ -458,7 +458,7 @@ def _draw_internal_panel_categorical( palette, feat_names: list[str], X_rows: np.ndarray | None, - fontsize: Optional[int], + fontsize: int | None, label: str, ) -> list: cx, cy = center @@ -551,7 +551,7 @@ def _draw_internal_panel( feat_names: list[str], n_hist_bins: int, precision: int, - fontsize: Optional[int], + fontsize: int | None, label: str, ) -> list: """Render a single internal node panel: slabs + optional fine histogram.""" @@ -663,16 +663,16 @@ def plot_tree( estimator: Any, *, X=None, - max_depth: Optional[int] = None, - feature_names: Optional[list[str]] = None, - class_names: Union[list[str], bool, None] = None, + max_depth: int | None = None, + feature_names: list[str] | None = None, + class_names: list[str] | bool | None = None, label: str = "feature", impurity: bool = False, proportion: bool = False, precision: int = 2, cmap: Any = _DEFAULT_PALETTE_COLORS, - ax: Optional[plt.Axes] = None, - fontsize: Optional[int] = None, + ax: plt.Axes | None = None, + fontsize: int | None = None, node_aspect_ratio: float = 2.5, n_hist_bins: int = 20, ) -> list[Any]: @@ -719,7 +719,7 @@ def plot_tree( palette = _build_palette(cmap, tree["num_partitions"]) is_classifier = isinstance(estimator, SGTClassifier) - resolved_class_names: Optional[list[str]] + resolved_class_names: list[str] | None if not is_classifier: resolved_class_names = None elif class_names is True: diff --git a/sgtlearn/_features.py b/sgtlearn/_features.py index d66a99f..543654a 100644 --- a/sgtlearn/_features.py +++ b/sgtlearn/_features.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping, MutableMapping, Sequence from dataclasses import dataclass -from typing import Any, Mapping, MutableMapping, Sequence +from typing import Any FeatureInfoDict = dict[str, Any] FeatureDict = Mapping[int | str, Sequence[int | str]] diff --git a/sgtlearn/_multioutput.py b/sgtlearn/_multioutput.py index 831d8ca..10246d3 100644 --- a/sgtlearn/_multioutput.py +++ b/sgtlearn/_multioutput.py @@ -7,7 +7,8 @@ from __future__ import annotations -from typing import Any, Sequence, Union +from collections.abc import Sequence +from typing import Any import numpy as np from sklearn.preprocessing import LabelEncoder @@ -15,10 +16,10 @@ __all__ = [ "as_output_matrix", "encode_classification_targets", - "unwrap_classifier_public_attrs", "label_encoders_as_list", "native_y_array", "squeeze_outputs", + "unwrap_classifier_public_attrs", ] @@ -44,7 +45,7 @@ def as_output_matrix(y: Any) -> tuple[np.ndarray, int]: def encode_classification_targets( y: Any, *, - encoders: Union[None, LabelEncoder, Sequence[Any]] = None, + encoders: None | LabelEncoder | Sequence[Any] = None, ) -> tuple[np.ndarray, list[Any], list[np.ndarray], list[int]]: """Encode labels with one encoder per output. @@ -75,13 +76,13 @@ def encode_classification_targets( cols.append(le.fit_transform(y2[:, o])) fitted.append(le) classes_list.append(np.asarray(le.classes_)) - n_classes_list.append(int(len(le.classes_))) + n_classes_list.append(len(le.classes_)) return np.column_stack(cols), fitted, classes_list, n_classes_list enc_list = label_encoders_as_list(encoders, n_outputs) cols = [enc_list[o].transform(y2[:, o]) for o in range(n_outputs)] classes_list = [np.asarray(enc_list[o].classes_) for o in range(n_outputs)] - n_classes_list = [int(len(c)) for c in classes_list] + n_classes_list = [len(c) for c in classes_list] return np.column_stack(cols), enc_list, classes_list, n_classes_list @@ -112,9 +113,7 @@ def unwrap_classifier_public_attrs( ) -def label_encoders_as_list( - encoders: Union[Any, Sequence[Any]], n_outputs: int -) -> list[Any]: +def label_encoders_as_list(encoders: Any | Sequence[Any], n_outputs: int) -> list[Any]: """Normalize a scalar encoder or sequence to length ``n_outputs``.""" if isinstance(encoders, (list, tuple)): enc_list = list(encoders) diff --git a/sgtlearn/_weights.py b/sgtlearn/_weights.py index 8b2f5b4..13665ac 100644 --- a/sgtlearn/_weights.py +++ b/sgtlearn/_weights.py @@ -2,16 +2,18 @@ from __future__ import annotations -from collections.abc import Mapping as ABCMapping, Sequence as ABCSequence -from typing import Any, Mapping, Optional, Sequence, Union +from collections.abc import Mapping, Sequence +from collections.abc import Mapping as ABCMapping +from collections.abc import Sequence as ABCSequence +from typing import Any import numpy as np from sgtlearn._multioutput import as_output_matrix __all__ = [ - "normalize_sample_weight", "effective_sample_weight_classification", + "normalize_sample_weight", ] @@ -28,8 +30,8 @@ def _validate_sample_weight_array(sw: np.ndarray, n_samples: int) -> None: def normalize_sample_weight( - sample_weight: Optional[np.ndarray], n_samples: int -) -> Optional[np.ndarray]: + sample_weight: np.ndarray | None, n_samples: int +) -> np.ndarray | None: """Validated float64 weights for tree ``fit``, or ``None`` for uniform weighting.""" if sample_weight is None: return None @@ -60,10 +62,10 @@ def _per_class_multiplier( def effective_sample_weight_classification( - sample_weight: Optional[np.ndarray], + sample_weight: np.ndarray | None, y_enc: np.ndarray, - class_weight: Union[Mapping[Any, float], Sequence[Mapping[Any, float]]], - classes_: Union[np.ndarray, Sequence[np.ndarray]], + class_weight: Mapping[Any, float] | Sequence[Mapping[Any, float]], + classes_: np.ndarray | Sequence[np.ndarray], ) -> np.ndarray: """``sample_weight * class_weight[y]`` in encoded label space. @@ -94,7 +96,7 @@ def effective_sample_weight_classification( f"output ({n_outputs}); got {len(cw_list)}" ) else: - raise ValueError("class_weight must be a mapping or a sequence of mappings") + raise TypeError("class_weight must be a mapping or a sequence of mappings") n = y2.shape[0] if sample_weight is None: diff --git a/sgtlearn/base.py b/sgtlearn/base.py index dd08305..c3d1639 100644 --- a/sgtlearn/base.py +++ b/sgtlearn/base.py @@ -2,11 +2,17 @@ from __future__ import annotations -from typing import Any, Mapping, Optional, Sequence, Union +from collections.abc import Mapping, Sequence +from typing import Any import numpy as np +from ShapeGeneralizedTrees import ( + ClassificationShapeGeneralizedTree, + RegressionShapeGeneralizedTree, +) from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin from sklearn.exceptions import NotFittedError +from sklearn.preprocessing import LabelEncoder from sklearn.utils.validation import check_array, check_is_fitted, check_X_y from sgtlearn._features import ProcessedFeatures, configure_feature_dict @@ -19,20 +25,15 @@ unwrap_classifier_public_attrs, ) from sgtlearn._weights import ( - normalize_sample_weight, effective_sample_weight_classification, + normalize_sample_weight, ) -from ShapeGeneralizedTrees import ( - ClassificationShapeGeneralizedTree, - RegressionShapeGeneralizedTree, -) -from sklearn.preprocessing import LabelEncoder __all__ = [ "BaseShapeCART", + "ProcessedFeatures", "SGTClassifier", "SGTRegressor", - "ProcessedFeatures", "configure_feature_dict", ] @@ -73,7 +74,7 @@ class _IdentityLabelEncoder(LabelEncoder): def __init__(self, classes_: np.ndarray) -> None: self.classes_ = np.asarray(classes_) - def fit(self, y: np.ndarray) -> "_IdentityLabelEncoder": + def fit(self, y: np.ndarray) -> _IdentityLabelEncoder: raise NotImplementedError( "_IdentityLabelEncoder is built with preset classes_; " "fit the enclosing meta-estimator instead." @@ -293,8 +294,8 @@ def __init__( *, criterion: str = "gini", num_partitions: int = 2, - max_depth: Optional[int] = None, - max_leaf_nodes: Optional[int] = None, + max_depth: int | None = None, + max_leaf_nodes: int | None = None, min_samples_leaf: int = 1, min_impurity_decrease: float = 0.0, inner_max_depth: int = 3, @@ -304,11 +305,9 @@ def __init__( coordinate_descent_max_iters: int = 20, coordinate_descent_patience: int = 5, coordinate_descent_smart_init: bool = True, - random_state: Optional[int] = 42, - max_features: Optional[Union[int, float, str]] = None, - class_weight: Optional[ - Union[Mapping[Any, float], Sequence[Mapping[Any, float]]] - ] = None, + random_state: int | None = 42, + max_features: float | str | None = None, + class_weight: Mapping[Any, float] | Sequence[Mapping[Any, float]] | None = None, tao_n_runs: int = 10, tao_lambda: float = 0.0, ) -> None: @@ -334,22 +333,22 @@ def __init__( self.tao_lambda = tao_lambda self._est: Any = None self._le: Any = None - self.classes_: Optional[Any] = None - self.n_classes_: Optional[Any] = None + self.classes_: Any | None = None + self.n_classes_: Any | None = None self.n_outputs_: int = 1 - self.n_features_in_: Optional[int] = None - self.feature_names_in_: Optional[np.ndarray] = None + self.n_features_in_: int | None = None + self.feature_names_in_: np.ndarray | None = None def fit( self, X: np.ndarray, y: np.ndarray, - sample_weight: Optional[np.ndarray] = None, + sample_weight: np.ndarray | None = None, *, - feature_dict: Optional[Mapping[int | str, Sequence[int | str]]] = None, - processed_features: Optional[ProcessedFeatures] = None, + feature_dict: Mapping[int | str, Sequence[int | str]] | None = None, + processed_features: ProcessedFeatures | None = None, check_input: bool = True, - ) -> "SGTClassifier": + ) -> SGTClassifier: """Fit the tree on ``X`` and class labels ``y``. Parameters @@ -438,7 +437,7 @@ def fit( if y_enc.shape[0] != X.shape[0]: raise ValueError("X and y must have the same number of samples.") - sw: Optional[np.ndarray] = None + sw: np.ndarray | None = None if self.class_weight is not None: sw = effective_sample_weight_classification( sample_weight, y_enc, self.class_weight, self.classes_ @@ -449,7 +448,7 @@ def fit( self.n_features_in_ = X.shape[1] if column_names is None: column_names = _column_names_from_X(X) - self.feature_names_in_: Optional[np.ndarray] = ( + self.feature_names_in_: np.ndarray | None = ( np.asarray(column_names, dtype=object) if column_names is not None else None ) @@ -682,8 +681,8 @@ def __init__( *, criterion: str = "squared_error", num_partitions: int = 2, - max_depth: Optional[int] = None, - max_leaf_nodes: Optional[int] = None, + max_depth: int | None = None, + max_leaf_nodes: int | None = None, min_samples_leaf: int = 1, min_impurity_decrease: float = 0.0, inner_max_depth: int = 3, @@ -693,8 +692,8 @@ def __init__( coordinate_descent_max_iters: int = 20, coordinate_descent_patience: int = 5, coordinate_descent_smart_init: bool = True, - random_state: Optional[int] = 42, - max_features: Optional[Union[int, float, str]] = None, + random_state: int | None = 42, + max_features: float | str | None = None, tao_n_runs: int = 10, tao_lambda: float = 0.0, ) -> None: @@ -717,19 +716,19 @@ def __init__( self.tao_lambda = tao_lambda self._est: Any = None self.n_outputs_: int = 1 - self.n_features_in_: Optional[int] = None - self.feature_names_in_: Optional[np.ndarray] = None + self.n_features_in_: int | None = None + self.feature_names_in_: np.ndarray | None = None def fit( self, X: np.ndarray, y: np.ndarray, - sample_weight: Optional[np.ndarray] = None, + sample_weight: np.ndarray | None = None, *, - feature_dict: Optional[Mapping[int | str, Sequence[int | str]]] = None, - processed_features: Optional[ProcessedFeatures] = None, + feature_dict: Mapping[int | str, Sequence[int | str]] | None = None, + processed_features: ProcessedFeatures | None = None, check_input: bool = True, - ) -> "SGTRegressor": + ) -> SGTRegressor: """Fit the tree on ``X`` and continuous targets ``y``. Parameters @@ -776,7 +775,7 @@ def fit( self.n_features_in_ = X.shape[1] if column_names is None: column_names = _column_names_from_X(X) - self.feature_names_in_: Optional[np.ndarray] = ( + self.feature_names_in_: np.ndarray | None = ( np.asarray(column_names, dtype=object) if column_names is not None else None ) diff --git a/sgtlearn/datasets.py b/sgtlearn/datasets.py index aed688b..55724c0 100644 --- a/sgtlearn/datasets.py +++ b/sgtlearn/datasets.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Optional - import numpy as np __all__ = ["make_plus"] @@ -14,7 +12,7 @@ def make_plus( *, grid: int = 3, margin: float = 0.05, - random_state: Optional[int] = None, + random_state: int | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Generate the "Plus Sign" dataset. diff --git a/sgtlearn/ensemble/__init__.py b/sgtlearn/ensemble/__init__.py index 7909437..a1e519c 100644 --- a/sgtlearn/ensemble/__init__.py +++ b/sgtlearn/ensemble/__init__.py @@ -1,4 +1,4 @@ -from sgtlearn.ensemble.RandomSGForestClassifier import RandomSGForestClassifier -from sgtlearn.ensemble.RandomSGForestRegressor import RandomSGForestRegressor +from sgtlearn.ensemble.random_sgforest_classifier import RandomSGForestClassifier +from sgtlearn.ensemble.random_sgforest_regressor import RandomSGForestRegressor __all__ = ["RandomSGForestClassifier", "RandomSGForestRegressor"] diff --git a/sgtlearn/ensemble/_random_sgforest.py b/sgtlearn/ensemble/_random_sgforest.py index 12598dc..27d2067 100644 --- a/sgtlearn/ensemble/_random_sgforest.py +++ b/sgtlearn/ensemble/_random_sgforest.py @@ -3,8 +3,9 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence from numbers import Integral -from typing import Any, Mapping, Optional, Sequence, Union +from typing import Any import numpy as np from joblib import Parallel, delayed, effective_n_jobs @@ -12,14 +13,12 @@ from sklearn.utils import check_random_state from sklearn.utils.validation import check_array, check_is_fitted -from sgtlearn.base import _column_names_from_X, _configure_processed_features from sgtlearn._features import ProcessedFeatures from sgtlearn._weights import normalize_sample_weight +from sgtlearn.base import _column_names_from_X, _configure_processed_features -def _n_samples_bootstrap( - n_samples: int, max_samples: Optional[Union[int, float]] -) -> int: +def _n_samples_bootstrap(n_samples: int, max_samples: float | None) -> int: if max_samples is None: return n_samples if isinstance(max_samples, Integral) and not isinstance(max_samples, bool): @@ -32,7 +31,7 @@ def _n_samples_bootstrap( m = float(max_samples) if not (0.0 < m <= 1.0): raise ValueError("max_samples as float must be in (0.0, 1.0].") - return max(1, int(round(m * n_samples))) + return max(1, round(m * n_samples)) def _parallel_fit_tree( @@ -42,10 +41,10 @@ def _parallel_fit_tree( n_bootstrap: int, X: np.ndarray, y: np.ndarray, - sample_weight: Optional[np.ndarray], + sample_weight: np.ndarray | None, tree_kw: dict[str, Any], tree_factory: Any, - processed_features: Optional[ProcessedFeatures], + processed_features: ProcessedFeatures | None, ) -> Any: """Fit one bootstrapped (or full) base tree; module-level for ``joblib`` workers.""" if bootstrap: @@ -85,8 +84,8 @@ def __init__( *, criterion: str, num_partitions: int = 2, - max_depth: Optional[int] = None, - max_leaf_nodes: Optional[int] = None, + max_depth: int | None = None, + max_leaf_nodes: int | None = None, min_samples_leaf: int = 1, min_impurity_decrease: float = 0.0, inner_max_depth: int = 3, @@ -96,13 +95,13 @@ def __init__( coordinate_descent_max_iters: int = 20, coordinate_descent_patience: int = 5, coordinate_descent_smart_init: bool = True, - max_features: Optional[Union[int, float, str]] = None, + max_features: float | str | None = None, bootstrap: bool = True, - max_samples: Optional[Union[int, float]] = None, - random_state: Optional[Union[int, np.random.RandomState]] = None, + max_samples: float | None = None, + random_state: int | np.random.RandomState | None = None, tao_n_runs: int = 10, tao_lambda: float = 0.0, - n_jobs: Optional[int] = None, + n_jobs: int | None = None, verbose: int = 0, ) -> None: self.n_estimators = int(n_estimators) @@ -129,24 +128,24 @@ def __init__( self.verbose = int(verbose) def _tree_kwargs(self) -> dict[str, Any]: - return dict( - criterion=self.criterion, - num_partitions=self.num_partitions, - max_depth=self.max_depth, - max_leaf_nodes=self.max_leaf_nodes, - min_samples_leaf=self.min_samples_leaf, - min_impurity_decrease=self.min_impurity_decrease, - inner_max_depth=self.inner_max_depth, - inner_max_leaf_nodes=self.inner_max_leaf_nodes, - inner_min_samples_leaf=self.inner_min_samples_leaf, - inner_min_impurity_decrease=self.inner_min_impurity_decrease, - coordinate_descent_max_iters=self.coordinate_descent_max_iters, - coordinate_descent_patience=self.coordinate_descent_patience, - coordinate_descent_smart_init=self.coordinate_descent_smart_init, - max_features=self.max_features, - tao_n_runs=self.tao_n_runs, - tao_lambda=self.tao_lambda, - ) + return { + "criterion": self.criterion, + "num_partitions": self.num_partitions, + "max_depth": self.max_depth, + "max_leaf_nodes": self.max_leaf_nodes, + "min_samples_leaf": self.min_samples_leaf, + "min_impurity_decrease": self.min_impurity_decrease, + "inner_max_depth": self.inner_max_depth, + "inner_max_leaf_nodes": self.inner_max_leaf_nodes, + "inner_min_samples_leaf": self.inner_min_samples_leaf, + "inner_min_impurity_decrease": self.inner_min_impurity_decrease, + "coordinate_descent_max_iters": self.coordinate_descent_max_iters, + "coordinate_descent_patience": self.coordinate_descent_patience, + "coordinate_descent_smart_init": self.coordinate_descent_smart_init, + "max_features": self.max_features, + "tao_n_runs": self.tao_n_runs, + "tao_lambda": self.tao_lambda, + } @abstractmethod def _check_X_y(self, X: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]: @@ -159,9 +158,9 @@ def _make_tree(self, tree_seed: int, tree_kw: dict[str, Any]) -> Any: def _prepare_sample_weight( self, y: np.ndarray, - sample_weight: Optional[np.ndarray], + sample_weight: np.ndarray | None, n_samples: int, - ) -> Optional[np.ndarray]: + ) -> np.ndarray | None: """Return per-sample weights for tree fitting (subclasses may apply class weights).""" return normalize_sample_weight(sample_weight, n_samples) @@ -169,10 +168,10 @@ def fit( self, X: np.ndarray, y: np.ndarray, - sample_weight: Optional[np.ndarray] = None, + sample_weight: np.ndarray | None = None, *, - feature_dict: Optional[Mapping[int | str, Sequence[int | str]]] = None, - processed_features: Optional[ProcessedFeatures] = None, + feature_dict: Mapping[int | str, Sequence[int | str]] | None = None, + processed_features: ProcessedFeatures | None = None, ) -> RandomSGForest: """Fit the forest on ``X`` and targets ``y``. diff --git a/sgtlearn/ensemble/RandomSGForestClassifier.py b/sgtlearn/ensemble/random_sgforest_classifier.py similarity index 94% rename from sgtlearn/ensemble/RandomSGForestClassifier.py rename to sgtlearn/ensemble/random_sgforest_classifier.py index 075d344..67f9618 100644 --- a/sgtlearn/ensemble/RandomSGForestClassifier.py +++ b/sgtlearn/ensemble/random_sgforest_classifier.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Mapping, Optional, Sequence, Union +from collections.abc import Mapping, Sequence +from typing import Any import numpy as np from sklearn.base import ClassifierMixin @@ -114,8 +115,8 @@ def __init__( *, criterion: str = "gini", num_partitions: int = 2, - max_depth: Optional[int] = None, - max_leaf_nodes: Optional[int] = None, + max_depth: int | None = None, + max_leaf_nodes: int | None = None, min_samples_leaf: int = 1, min_impurity_decrease: float = 0.0, inner_max_depth: int = 3, @@ -125,16 +126,14 @@ def __init__( coordinate_descent_max_iters: int = 20, coordinate_descent_patience: int = 5, coordinate_descent_smart_init: bool = True, - max_features: Optional[Union[int, float, str]] = "sqrt", + max_features: float | str | None = "sqrt", bootstrap: bool = True, - max_samples: Optional[Union[int, float]] = None, - random_state: Optional[Union[int, np.random.RandomState]] = None, - class_weight: Optional[ - Union[Mapping[Any, float], Sequence[Mapping[Any, float]]] - ] = None, + max_samples: float | None = None, + random_state: int | np.random.RandomState | None = None, + class_weight: Mapping[Any, float] | Sequence[Mapping[Any, float]] | None = None, tao_n_runs: int = 10, tao_lambda: float = 0.0, - n_jobs: Optional[int] = None, + n_jobs: int | None = None, verbose: int = 0, ) -> None: self.class_weight = class_weight @@ -192,9 +191,9 @@ def _check_X_y(self, X: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarr def _prepare_sample_weight( self, y: np.ndarray, - sample_weight: Optional[np.ndarray], + sample_weight: np.ndarray | None, n_samples: int, - ) -> Optional[np.ndarray]: + ) -> np.ndarray | None: if self.class_weight is None: return super()._prepare_sample_weight(y, sample_weight, n_samples) return effective_sample_weight_classification( diff --git a/sgtlearn/ensemble/RandomSGForestRegressor.py b/sgtlearn/ensemble/random_sgforest_regressor.py similarity index 95% rename from sgtlearn/ensemble/RandomSGForestRegressor.py rename to sgtlearn/ensemble/random_sgforest_regressor.py index 14f61a0..8daabee 100644 --- a/sgtlearn/ensemble/RandomSGForestRegressor.py +++ b/sgtlearn/ensemble/random_sgforest_regressor.py @@ -2,15 +2,15 @@ from __future__ import annotations -from typing import Any, Optional, Union +from typing import Any import numpy as np from sklearn.base import RegressorMixin from sklearn.utils.validation import check_X_y +from sgtlearn._multioutput import squeeze_outputs from sgtlearn.base import SGTRegressor from sgtlearn.ensemble._random_sgforest import RandomSGForest -from sgtlearn._multioutput import squeeze_outputs class RandomSGForestRegressor(RegressorMixin, RandomSGForest): @@ -102,8 +102,8 @@ def __init__( *, criterion: str = "squared_error", num_partitions: int = 2, - max_depth: Optional[int] = None, - max_leaf_nodes: Optional[int] = None, + max_depth: int | None = None, + max_leaf_nodes: int | None = None, min_samples_leaf: int = 1, min_impurity_decrease: float = 0.0, inner_max_depth: int = 3, @@ -113,13 +113,13 @@ def __init__( coordinate_descent_max_iters: int = 20, coordinate_descent_patience: int = 5, coordinate_descent_smart_init: bool = True, - max_features: Optional[Union[int, float, str]] = "sqrt", + max_features: float | str | None = "sqrt", bootstrap: bool = True, - max_samples: Optional[Union[int, float]] = None, - random_state: Optional[Union[int, np.random.RandomState]] = None, + max_samples: float | None = None, + random_state: int | np.random.RandomState | None = None, tao_n_runs: int = 10, tao_lambda: float = 0.0, - n_jobs: Optional[int] = None, + n_jobs: int | None = None, verbose: int = 0, ) -> None: super().__init__( diff --git a/sgtlearn/tao.py b/sgtlearn/tao.py index bdfa875..84c6b77 100644 --- a/sgtlearn/tao.py +++ b/sgtlearn/tao.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Optional, TypeVar, Union +from typing import TypeVar import numpy as np from joblib import Parallel, delayed, effective_n_jobs @@ -26,12 +26,12 @@ ) from sgtlearn.base import BaseShapeCART, SGTClassifier, SGTRegressor from sgtlearn.ensemble._random_sgforest import RandomSGForest -from sgtlearn.ensemble.RandomSGForestClassifier import RandomSGForestClassifier -from sgtlearn.ensemble.RandomSGForestRegressor import RandomSGForestRegressor +from sgtlearn.ensemble.random_sgforest_classifier import RandomSGForestClassifier +from sgtlearn.ensemble.random_sgforest_regressor import RandomSGForestRegressor __all__ = ["TAO_refine"] -TaoModel = TypeVar("TaoModel", bound=Union[BaseShapeCART, RandomSGForest]) +TaoModel = TypeVar("TaoModel", bound=BaseShapeCART | RandomSGForest) def _tao_targets(model: TaoModel) -> list[SGTClassifier | SGTRegressor]: @@ -110,7 +110,7 @@ def _prepare_tao_arrays( model: TaoModel, X: np.ndarray, y: np.ndarray, - sample_weight: Optional[np.ndarray], + sample_weight: np.ndarray | None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Build ``(X32, y_native, sample_weights)`` shared by all trees in ``model``.""" X32 = np.ascontiguousarray(X, dtype=np.float32) @@ -157,11 +157,11 @@ def TAO_refine( X: np.ndarray, y: np.ndarray, *, - sample_weight: Optional[np.ndarray] = None, + sample_weight: np.ndarray | None = None, n_runs: int = 10, lambda_: float = 0.0, check_input: bool = True, - n_jobs: Optional[int] = None, + n_jobs: int | None = None, ) -> TaoModel: """Refine a fitted shape-generalized tree or forest in place with TAO. From ce6af5ae2189ad4e57534fc988476e37defdd5b4 Mon Sep 17 00:00:00 2001 From: Joshua Lee Date: Sat, 22 Aug 2026 17:42:49 -0400 Subject: [PATCH 4/4] remove banchmarks folder --- benchmarks/README.md | 17 - benchmarks/bench_sgt_mae_fit.py | 333 ------------------ .../results/sgt_mae_fit_20260821T221517Z.csv | 7 - .../results/sgt_mae_fit_20260821T221517Z.json | 195 ---------- .../results/sgt_mae_fit_20260821T222009Z.csv | 7 - .../results/sgt_mae_fit_20260821T222009Z.json | 195 ---------- .../results/sgt_mae_fit_20260821T222425Z.csv | 7 - .../results/sgt_mae_fit_20260821T222425Z.json | 196 ----------- benchmarks/results/sgt_mae_fit_latest.json | 196 ----------- benchmarks/results/sgt_mae_fit_summary.json | 29 -- 10 files changed, 1182 deletions(-) delete mode 100644 benchmarks/README.md delete mode 100644 benchmarks/bench_sgt_mae_fit.py delete mode 100644 benchmarks/results/sgt_mae_fit_20260821T221517Z.csv delete mode 100644 benchmarks/results/sgt_mae_fit_20260821T221517Z.json delete mode 100644 benchmarks/results/sgt_mae_fit_20260821T222009Z.csv delete mode 100644 benchmarks/results/sgt_mae_fit_20260821T222009Z.json delete mode 100644 benchmarks/results/sgt_mae_fit_20260821T222425Z.csv delete mode 100644 benchmarks/results/sgt_mae_fit_20260821T222425Z.json delete mode 100644 benchmarks/results/sgt_mae_fit_latest.json delete mode 100644 benchmarks/results/sgt_mae_fit_summary.json diff --git a/benchmarks/README.md b/benchmarks/README.md deleted file mode 100644 index c5d4ac8..0000000 --- a/benchmarks/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# MAE / shape-tree fit benchmarks -# -# Run from the repo root after building the C++ extensions: -# -# cmake --build build -j -# # optional: copy *.so into the active venv site-packages -# MPLCONFIGDIR=/tmp/mpl PYTHONPATH=build python benchmarks/bench_sgt_mae_fit.py -# -# The script force-loads ``build/*.so`` when present so a stale venv copy is not used. -# -# Environment knobs used by the native AbsoluteError path: -# SGTLEARN_MAE_CD=1 enable MAE coordinate descent during fit -# SGTLEARN_MAE_BACKEND=sort|bst branch-assignment implementation -# -# The bench sets ``tao_n_runs=0`` so timings isolate tree growth / CD (not TAO). -# -# Results land in benchmarks/results/. diff --git a/benchmarks/bench_sgt_mae_fit.py b/benchmarks/bench_sgt_mae_fit.py deleted file mode 100644 index b5ba57e..0000000 --- a/benchmarks/bench_sgt_mae_fit.py +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark SGTRegressor(absolute_error) fit: sort vs BST branch assignment. - -Requires the native extension built with MAE CD / backend env hooks. -Coordinate descent for MAE is enabled via ``SGTLEARN_MAE_CD=1`` so the two -backends are exercised during ``fit`` (default product path still skips MAE CD). -``tao_n_runs=0`` so timings reflect tree growth / branch assignment, not TAO. - -Usage (from repo root, with build/ on PYTHONPATH or an editable install):: - - PYTHONPATH=build python benchmarks/bench_sgt_mae_fit.py - -Writes:: - - benchmarks/results/sgt_mae_fit_.json # raw runs - benchmarks/results/sgt_mae_fit_.csv # per-case rows - benchmarks/results/sgt_mae_fit_latest.json # copy of newest - benchmarks/results/sgt_mae_fit_summary.json # aggregates -""" - -from __future__ import annotations - -import argparse -import csv -import json -import os -import statistics -import sys -import time -from dataclasses import asdict, dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -import numpy as np - -ROOT = Path(__file__).resolve().parents[1] -RESULTS_DIR = Path(__file__).resolve().parent / "results" - - -@dataclass(frozen=True) -class Case: - name: str - n_samples: int - n_features: int - max_depth: int - inner_max_depth: int - num_partitions: int - repeats: int - - -CASES: list[Case] = [ - # Deeper inner trees + higher fan-out → more bins and CD moves (where BST wins). - Case("small_2k_x_8", 2_000, 8, 3, 3, 4, 3), - Case("medium_5k_x_12", 5_000, 12, 4, 4, 4, 3), - Case("large_8k_x_16", 8_000, 16, 4, 4, 6, 2), -] - - -def _make_data(n_samples: int, n_features: int, seed: int) -> tuple[np.ndarray, np.ndarray]: - rng = np.random.default_rng(seed) - X = rng.normal(size=(n_samples, n_features)).astype(np.float64) - # Nonlinear + heavy tails so absolute_error is a reasonable criterion. - y = ( - np.sin(X[:, 0]) - + 0.5 * X[:, 1] ** 2 - + 0.1 * rng.standard_t(df=3, size=n_samples) - ).astype(np.float64) - return X, y - - -def _safe_n_leaves(model: Any) -> int | None: - est = getattr(model, "_est", None) - if est is None: - return None - if hasattr(est, "num_leaves"): - try: - return int(est.num_leaves) - except Exception: - return None - return None - - -def run_backend( - backend: str, - cases: list[Case], - *, - seed: int, - warmup: bool, -) -> list[dict[str, Any]]: - from sgtlearn import SGTRegressor - - rows: list[dict[str, Any]] = [] - os.environ["SGTLEARN_MAE_CD"] = "1" - os.environ["SGTLEARN_MAE_BACKEND"] = backend - - if warmup: - Xw, yw = _make_data(400, 4, seed) - m = SGTRegressor( - criterion="absolute_error", - max_depth=2, - inner_max_depth=2, - num_partitions=2, - coordinate_descent_max_iters=5, - coordinate_descent_patience=2, - tao_n_runs=0, - random_state=seed, - ) - m.fit(Xw, yw) - - for case in cases: - times: list[float] = [] - n_nodes_last = None - n_leaves_last = None - for r in range(case.repeats): - os.environ["SGTLEARN_MAE_CD"] = "1" - os.environ["SGTLEARN_MAE_BACKEND"] = backend - Xr, yr = _make_data(case.n_samples, case.n_features, seed + r * 17) - model = SGTRegressor( - criterion="absolute_error", - max_depth=case.max_depth, - inner_max_depth=case.inner_max_depth, - num_partitions=case.num_partitions, - coordinate_descent_max_iters=15, - coordinate_descent_patience=5, - tao_n_runs=0, - random_state=seed + r, - ) - t0 = time.perf_counter() - model.fit(Xr, yr) - elapsed = time.perf_counter() - t0 - times.append(elapsed) - n_leaves_last = _safe_n_leaves(model) - est = getattr(model, "_est", None) - n_nodes_last = int(getattr(est, "num_nodes", -1)) if est is not None else None - print( - f" [{backend}] {case.name} rep={r + 1}/{case.repeats}: " - f"{elapsed:.3f}s", - flush=True, - ) - - rows.append( - { - "backend": backend, - "case": case.name, - "n_samples": case.n_samples, - "n_features": case.n_features, - "max_depth": case.max_depth, - "inner_max_depth": case.inner_max_depth, - "num_partitions": case.num_partitions, - "repeats": case.repeats, - "fit_seconds_mean": statistics.fmean(times), - "fit_seconds_std": statistics.stdev(times) if len(times) > 1 else 0.0, - "fit_seconds_min": min(times), - "fit_seconds_max": max(times), - "fit_seconds_all": times, - "n_leaves": n_leaves_last, - "n_nodes": n_nodes_last, - "mae_cd": True, - } - ) - return rows - - -def aggregate(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - by_case: dict[str, dict[str, dict[str, Any]]] = {} - for row in rows: - by_case.setdefault(row["case"], {})[row["backend"]] = row - - out: list[dict[str, Any]] = [] - for case_name, backends in by_case.items(): - sort_row = backends.get("sort") - bst_row = backends.get("bst") - if not sort_row or not bst_row: - continue - sort_t = float(sort_row["fit_seconds_mean"]) - bst_t = float(bst_row["fit_seconds_mean"]) - out.append( - { - "case": case_name, - "sort_fit_seconds_mean": sort_t, - "bst_fit_seconds_mean": bst_t, - "speedup_sort_over_bst": (sort_t / bst_t) if bst_t > 0 else None, - "n_samples": sort_row["n_samples"], - "n_features": sort_row["n_features"], - } - ) - return out - - -def _preload_native_extensions(build_dir: Path) -> None: - """Prefer freshly built ``*.so`` from cmake ``build/`` over a stale venv copy.""" - import importlib.util - - for name in ( - "ShapeGeneralizedTrees", - "Discretizers", - "TreeAlternatingOptimization", - ): - matches = sorted(build_dir.glob(f"{name}.cpython-*.so")) - if not matches: - continue - so_path = matches[-1] - sys.modules.pop(name, None) - spec = importlib.util.spec_from_file_location(name, so_path) - if spec is None or spec.loader is None: - continue - module = importlib.util.module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--no-warmup", action="store_true") - parser.add_argument( - "--cases", - nargs="*", - default=None, - help="Optional subset of case names to run", - ) - args = parser.parse_args() - - build_dir = ROOT / "build" - if build_dir.is_dir(): - sys.path.insert(0, str(build_dir)) - _preload_native_extensions(build_dir) - - try: - import ShapeGeneralizedTrees # noqa: F401 - from sgtlearn import SGTRegressor # noqa: F401 - - print(f"Using ShapeGeneralizedTrees from {ShapeGeneralizedTrees.__file__}") - except ImportError as exc: - print( - "Failed to import native ShapeGeneralizedTrees / sgtlearn.\n" - "Build the extension (e.g. cmake --build build) and set " - "PYTHONPATH=build, or pip install -e .", - file=sys.stderr, - ) - print(exc, file=sys.stderr) - return 1 - - cases = CASES - if args.cases: - wanted = set(args.cases) - cases = [c for c in CASES if c.name in wanted] - if not cases: - print(f"No matching cases for {args.cases}", file=sys.stderr) - return 1 - - RESULTS_DIR.mkdir(parents=True, exist_ok=True) - ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - - print("SGTRegressor absolute_error fit bench (MAE CD enabled)") - print(f"backends: sort, bst | cases: {[c.name for c in cases]}") - print("-" * 60) - - all_rows: list[dict[str, Any]] = [] - for backend in ("sort", "bst"): - print(f"\n=== backend={backend} ===", flush=True) - all_rows.extend( - run_backend( - backend, - cases, - seed=args.seed, - warmup=not args.no_warmup, - ) - ) - - summary = aggregate(all_rows) - payload = { - "timestamp_utc": ts, - "criterion": "absolute_error", - "mae_cd_env": "SGTLEARN_MAE_CD=1", - "backend_env": "SGTLEARN_MAE_BACKEND", - "tao_n_runs": 0, - "seed": args.seed, - "python": sys.version, - "cases": [asdict(c) for c in cases], - "runs": all_rows, - "aggregates": summary, - } - - json_path = RESULTS_DIR / f"sgt_mae_fit_{ts}.json" - latest_path = RESULTS_DIR / "sgt_mae_fit_latest.json" - summary_path = RESULTS_DIR / "sgt_mae_fit_summary.json" - csv_path = RESULTS_DIR / f"sgt_mae_fit_{ts}.csv" - - json_path.write_text(json.dumps(payload, indent=2) + "\n") - latest_path.write_text(json.dumps(payload, indent=2) + "\n") - summary_path.write_text(json.dumps({"timestamp_utc": ts, "aggregates": summary}, indent=2) + "\n") - - fieldnames = [ - "backend", - "case", - "n_samples", - "n_features", - "max_depth", - "inner_max_depth", - "num_partitions", - "repeats", - "fit_seconds_mean", - "fit_seconds_std", - "fit_seconds_min", - "fit_seconds_max", - "n_leaves", - "n_nodes", - "mae_cd", - ] - with csv_path.open("w", newline="") as f: - writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") - writer.writeheader() - for row in all_rows: - writer.writerow(row) - - print("\n=== aggregates (sort / bst) ===") - for agg in summary: - print( - f"{agg['case']}: sort={agg['sort_fit_seconds_mean']:.3f}s " - f"bst={agg['bst_fit_seconds_mean']:.3f}s " - f"speedup={agg['speedup_sort_over_bst']:.2f}x" - ) - print(f"\nWrote {json_path}") - print(f"Wrote {csv_path}") - print(f"Wrote {summary_path}") - print(f"Wrote {latest_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/benchmarks/results/sgt_mae_fit_20260821T221517Z.csv b/benchmarks/results/sgt_mae_fit_20260821T221517Z.csv deleted file mode 100644 index 73e906a..0000000 --- a/benchmarks/results/sgt_mae_fit_20260821T221517Z.csv +++ /dev/null @@ -1,7 +0,0 @@ -backend,case,n_samples,n_features,max_depth,inner_max_depth,num_partitions,repeats,fit_seconds_mean,fit_seconds_std,fit_seconds_min,fit_seconds_max,n_leaves,n_nodes,mae_cd -sort,small_2k_x_8,2000,8,3,2,2,3,0.7170423776600122,0.04532743109798002,0.6671643839945318,0.7557187439961126,8,15,True -sort,medium_5k_x_16,5000,16,4,3,2,3,9.294718123996669,1.6331239565537465,7.69252811500337,10.957111124997027,16,31,True -sort,large_10k_x_20,10000,20,5,3,4,2,23.86484242300503,7.531790040347504,18.53906261100201,29.19062223500805,663,910,True -bst,small_2k_x_8,2000,8,3,2,2,3,0.8411415899997033,0.21200585146752188,0.7171799460047623,1.085938204996637,8,15,True -bst,medium_5k_x_16,5000,16,4,3,2,3,6.549038820662342,0.6524301063140052,5.811855870997533,7.052114560996415,16,31,True -bst,large_10k_x_20,10000,20,5,3,4,2,24.472663106498658,7.562380600445612,19.12525250200997,29.820073710987344,663,910,True diff --git a/benchmarks/results/sgt_mae_fit_20260821T221517Z.json b/benchmarks/results/sgt_mae_fit_20260821T221517Z.json deleted file mode 100644 index e0c8032..0000000 --- a/benchmarks/results/sgt_mae_fit_20260821T221517Z.json +++ /dev/null @@ -1,195 +0,0 @@ -{ - "timestamp_utc": "20260821T221517Z", - "criterion": "absolute_error", - "mae_cd_env": "SGTLEARN_MAE_CD=1", - "backend_env": "SGTLEARN_MAE_BACKEND", - "seed": 42, - "python": "3.14.2 (v3.14.2:df793163d58, Dec 5 2025, 12:18:06) [Clang 16.0.0 (clang-1600.0.26.6)]", - "cases": [ - { - "name": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 2, - "num_partitions": 2, - "repeats": 3 - }, - { - "name": "medium_5k_x_16", - "n_samples": 5000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 3, - "num_partitions": 2, - "repeats": 3 - }, - { - "name": "large_10k_x_20", - "n_samples": 10000, - "n_features": 20, - "max_depth": 5, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 2 - } - ], - "runs": [ - { - "backend": "sort", - "case": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 2, - "num_partitions": 2, - "repeats": 3, - "fit_seconds_mean": 0.7170423776600122, - "fit_seconds_std": 0.04532743109798002, - "fit_seconds_min": 0.6671643839945318, - "fit_seconds_max": 0.7557187439961126, - "fit_seconds_all": [ - 0.7557187439961126, - 0.7282440049893921, - 0.6671643839945318 - ], - "n_leaves": 8, - "n_nodes": 15, - "mae_cd": true - }, - { - "backend": "sort", - "case": "medium_5k_x_16", - "n_samples": 5000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 3, - "num_partitions": 2, - "repeats": 3, - "fit_seconds_mean": 9.294718123996669, - "fit_seconds_std": 1.6331239565537465, - "fit_seconds_min": 7.69252811500337, - "fit_seconds_max": 10.957111124997027, - "fit_seconds_all": [ - 9.23451513198961, - 10.957111124997027, - 7.69252811500337 - ], - "n_leaves": 16, - "n_nodes": 31, - "mae_cd": true - }, - { - "backend": "sort", - "case": "large_10k_x_20", - "n_samples": 10000, - "n_features": 20, - "max_depth": 5, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 2, - "fit_seconds_mean": 23.86484242300503, - "fit_seconds_std": 7.531790040347504, - "fit_seconds_min": 18.53906261100201, - "fit_seconds_max": 29.19062223500805, - "fit_seconds_all": [ - 29.19062223500805, - 18.53906261100201 - ], - "n_leaves": 663, - "n_nodes": 910, - "mae_cd": true - }, - { - "backend": "bst", - "case": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 2, - "num_partitions": 2, - "repeats": 3, - "fit_seconds_mean": 0.8411415899997033, - "fit_seconds_std": 0.21200585146752188, - "fit_seconds_min": 0.7171799460047623, - "fit_seconds_max": 1.085938204996637, - "fit_seconds_all": [ - 0.7203066189977108, - 0.7171799460047623, - 1.085938204996637 - ], - "n_leaves": 8, - "n_nodes": 15, - "mae_cd": true - }, - { - "backend": "bst", - "case": "medium_5k_x_16", - "n_samples": 5000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 3, - "num_partitions": 2, - "repeats": 3, - "fit_seconds_mean": 6.549038820662342, - "fit_seconds_std": 0.6524301063140052, - "fit_seconds_min": 5.811855870997533, - "fit_seconds_max": 7.052114560996415, - "fit_seconds_all": [ - 6.783146029993077, - 7.052114560996415, - 5.811855870997533 - ], - "n_leaves": 16, - "n_nodes": 31, - "mae_cd": true - }, - { - "backend": "bst", - "case": "large_10k_x_20", - "n_samples": 10000, - "n_features": 20, - "max_depth": 5, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 2, - "fit_seconds_mean": 24.472663106498658, - "fit_seconds_std": 7.562380600445612, - "fit_seconds_min": 19.12525250200997, - "fit_seconds_max": 29.820073710987344, - "fit_seconds_all": [ - 29.820073710987344, - 19.12525250200997 - ], - "n_leaves": 663, - "n_nodes": 910, - "mae_cd": true - } - ], - "aggregates": [ - { - "case": "small_2k_x_8", - "sort_fit_seconds_mean": 0.7170423776600122, - "bst_fit_seconds_mean": 0.8411415899997033, - "speedup_sort_over_bst": 0.852463350029173, - "n_samples": 2000, - "n_features": 8 - }, - { - "case": "medium_5k_x_16", - "sort_fit_seconds_mean": 9.294718123996669, - "bst_fit_seconds_mean": 6.549038820662342, - "speedup_sort_over_bst": 1.4192492025962125, - "n_samples": 5000, - "n_features": 16 - }, - { - "case": "large_10k_x_20", - "sort_fit_seconds_mean": 23.86484242300503, - "bst_fit_seconds_mean": 24.472663106498658, - "speedup_sort_over_bst": 0.9751632799075217, - "n_samples": 10000, - "n_features": 20 - } - ] -} diff --git a/benchmarks/results/sgt_mae_fit_20260821T222009Z.csv b/benchmarks/results/sgt_mae_fit_20260821T222009Z.csv deleted file mode 100644 index 5557035..0000000 --- a/benchmarks/results/sgt_mae_fit_20260821T222009Z.csv +++ /dev/null @@ -1,7 +0,0 @@ -backend,case,n_samples,n_features,max_depth,inner_max_depth,num_partitions,repeats,fit_seconds_mean,fit_seconds_std,fit_seconds_min,fit_seconds_max,n_leaves,n_nodes,mae_cd -sort,small_2k_x_8,2000,8,3,3,4,3,0.6652767063351348,0.016650878603842782,0.6462120420037536,0.6769667430053232,58,78,True -sort,medium_5k_x_12,5000,12,4,4,4,3,5.746949554998234,1.700660853702942,4.7060362929914845,7.709495650997269,226,308,True -sort,large_8k_x_16,8000,16,4,4,6,2,8.542716497504443,1.0543101937854475,7.79720661000465,9.288226385004236,950,1163,True -bst,small_2k_x_8,2000,8,3,3,4,3,0.6441729456710164,0.06147398203224151,0.573212355011492,0.681233240000438,58,78,True -bst,medium_5k_x_12,5000,12,4,4,4,3,6.021192238995961,2.0487426220132887,4.622615806001704,8.37285278098716,226,308,True -bst,large_8k_x_16,8000,16,4,4,6,2,9.011616504001722,0.926524601985064,8.356464675001916,9.666768333001528,950,1163,True diff --git a/benchmarks/results/sgt_mae_fit_20260821T222009Z.json b/benchmarks/results/sgt_mae_fit_20260821T222009Z.json deleted file mode 100644 index 058b0ee..0000000 --- a/benchmarks/results/sgt_mae_fit_20260821T222009Z.json +++ /dev/null @@ -1,195 +0,0 @@ -{ - "timestamp_utc": "20260821T222009Z", - "criterion": "absolute_error", - "mae_cd_env": "SGTLEARN_MAE_CD=1", - "backend_env": "SGTLEARN_MAE_BACKEND", - "seed": 42, - "python": "3.14.2 (v3.14.2:df793163d58, Dec 5 2025, 12:18:06) [Clang 16.0.0 (clang-1600.0.26.6)]", - "cases": [ - { - "name": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 3 - }, - { - "name": "medium_5k_x_12", - "n_samples": 5000, - "n_features": 12, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 4, - "repeats": 3 - }, - { - "name": "large_8k_x_16", - "n_samples": 8000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 6, - "repeats": 2 - } - ], - "runs": [ - { - "backend": "sort", - "case": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 0.6652767063351348, - "fit_seconds_std": 0.016650878603842782, - "fit_seconds_min": 0.6462120420037536, - "fit_seconds_max": 0.6769667430053232, - "fit_seconds_all": [ - 0.6462120420037536, - 0.6769667430053232, - 0.6726513339963276 - ], - "n_leaves": 58, - "n_nodes": 78, - "mae_cd": true - }, - { - "backend": "sort", - "case": "medium_5k_x_12", - "n_samples": 5000, - "n_features": 12, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 5.746949554998234, - "fit_seconds_std": 1.700660853702942, - "fit_seconds_min": 4.7060362929914845, - "fit_seconds_max": 7.709495650997269, - "fit_seconds_all": [ - 7.709495650997269, - 4.7060362929914845, - 4.825316721005947 - ], - "n_leaves": 226, - "n_nodes": 308, - "mae_cd": true - }, - { - "backend": "sort", - "case": "large_8k_x_16", - "n_samples": 8000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 6, - "repeats": 2, - "fit_seconds_mean": 8.542716497504443, - "fit_seconds_std": 1.0543101937854475, - "fit_seconds_min": 7.79720661000465, - "fit_seconds_max": 9.288226385004236, - "fit_seconds_all": [ - 9.288226385004236, - 7.79720661000465 - ], - "n_leaves": 950, - "n_nodes": 1163, - "mae_cd": true - }, - { - "backend": "bst", - "case": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 0.6441729456710164, - "fit_seconds_std": 0.06147398203224151, - "fit_seconds_min": 0.573212355011492, - "fit_seconds_max": 0.681233240000438, - "fit_seconds_all": [ - 0.573212355011492, - 0.681233240000438, - 0.6780732420011191 - ], - "n_leaves": 58, - "n_nodes": 78, - "mae_cd": true - }, - { - "backend": "bst", - "case": "medium_5k_x_12", - "n_samples": 5000, - "n_features": 12, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 6.021192238995961, - "fit_seconds_std": 2.0487426220132887, - "fit_seconds_min": 4.622615806001704, - "fit_seconds_max": 8.37285278098716, - "fit_seconds_all": [ - 8.37285278098716, - 5.068108129999018, - 4.622615806001704 - ], - "n_leaves": 226, - "n_nodes": 308, - "mae_cd": true - }, - { - "backend": "bst", - "case": "large_8k_x_16", - "n_samples": 8000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 6, - "repeats": 2, - "fit_seconds_mean": 9.011616504001722, - "fit_seconds_std": 0.926524601985064, - "fit_seconds_min": 8.356464675001916, - "fit_seconds_max": 9.666768333001528, - "fit_seconds_all": [ - 9.666768333001528, - 8.356464675001916 - ], - "n_leaves": 950, - "n_nodes": 1163, - "mae_cd": true - } - ], - "aggregates": [ - { - "case": "small_2k_x_8", - "sort_fit_seconds_mean": 0.6652767063351348, - "bst_fit_seconds_mean": 0.6441729456710164, - "speedup_sort_over_bst": 1.0327610167516974, - "n_samples": 2000, - "n_features": 8 - }, - { - "case": "medium_5k_x_12", - "sort_fit_seconds_mean": 5.746949554998234, - "bst_fit_seconds_mean": 6.021192238995961, - "speedup_sort_over_bst": 0.954453757144373, - "n_samples": 5000, - "n_features": 12 - }, - { - "case": "large_8k_x_16", - "sort_fit_seconds_mean": 8.542716497504443, - "bst_fit_seconds_mean": 9.011616504001722, - "speedup_sort_over_bst": 0.9479671592450636, - "n_samples": 8000, - "n_features": 16 - } - ] -} diff --git a/benchmarks/results/sgt_mae_fit_20260821T222425Z.csv b/benchmarks/results/sgt_mae_fit_20260821T222425Z.csv deleted file mode 100644 index d5f889b..0000000 --- a/benchmarks/results/sgt_mae_fit_20260821T222425Z.csv +++ /dev/null @@ -1,7 +0,0 @@ -backend,case,n_samples,n_features,max_depth,inner_max_depth,num_partitions,repeats,fit_seconds_mean,fit_seconds_std,fit_seconds_min,fit_seconds_max,n_leaves,n_nodes,mae_cd -sort,small_2k_x_8,2000,8,3,3,4,3,11.64151620300739,0.5621140523917701,10.997175320007955,12.031442292005522,64,85,True -sort,medium_5k_x_12,5000,12,4,4,4,3,59.52303006566459,1.1807398659122517,58.50989505900361,60.81973345899314,251,335,True -sort,large_8k_x_16,8000,16,4,4,6,2,229.14787644099852,18.161574668327344,216.30570383599843,241.9900490459986,1032,1264,True -bst,small_2k_x_8,2000,8,3,3,4,3,7.713633298995167,0.20587623710384548,7.580647086986573,7.950775241988595,64,85,True -bst,medium_5k_x_12,5000,12,4,4,4,3,44.004765506334174,3.1475830078652334,41.09283641600632,47.344285339000635,251,335,True -bst,large_8k_x_16,8000,16,4,4,6,2,160.80786340999475,0.825977643737557,160.22380901699944,161.39191780299006,1032,1264,True diff --git a/benchmarks/results/sgt_mae_fit_20260821T222425Z.json b/benchmarks/results/sgt_mae_fit_20260821T222425Z.json deleted file mode 100644 index cb66347..0000000 --- a/benchmarks/results/sgt_mae_fit_20260821T222425Z.json +++ /dev/null @@ -1,196 +0,0 @@ -{ - "timestamp_utc": "20260821T222425Z", - "criterion": "absolute_error", - "mae_cd_env": "SGTLEARN_MAE_CD=1", - "backend_env": "SGTLEARN_MAE_BACKEND", - "tao_n_runs": 0, - "seed": 42, - "python": "3.14.2 (v3.14.2:df793163d58, Dec 5 2025, 12:18:06) [Clang 16.0.0 (clang-1600.0.26.6)]", - "cases": [ - { - "name": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 3 - }, - { - "name": "medium_5k_x_12", - "n_samples": 5000, - "n_features": 12, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 4, - "repeats": 3 - }, - { - "name": "large_8k_x_16", - "n_samples": 8000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 6, - "repeats": 2 - } - ], - "runs": [ - { - "backend": "sort", - "case": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 11.64151620300739, - "fit_seconds_std": 0.5621140523917701, - "fit_seconds_min": 10.997175320007955, - "fit_seconds_max": 12.031442292005522, - "fit_seconds_all": [ - 12.031442292005522, - 11.89593099700869, - 10.997175320007955 - ], - "n_leaves": 64, - "n_nodes": 85, - "mae_cd": true - }, - { - "backend": "sort", - "case": "medium_5k_x_12", - "n_samples": 5000, - "n_features": 12, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 59.52303006566459, - "fit_seconds_std": 1.1807398659122517, - "fit_seconds_min": 58.50989505900361, - "fit_seconds_max": 60.81973345899314, - "fit_seconds_all": [ - 60.81973345899314, - 58.50989505900361, - 59.23946167899703 - ], - "n_leaves": 251, - "n_nodes": 335, - "mae_cd": true - }, - { - "backend": "sort", - "case": "large_8k_x_16", - "n_samples": 8000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 6, - "repeats": 2, - "fit_seconds_mean": 229.14787644099852, - "fit_seconds_std": 18.161574668327344, - "fit_seconds_min": 216.30570383599843, - "fit_seconds_max": 241.9900490459986, - "fit_seconds_all": [ - 216.30570383599843, - 241.9900490459986 - ], - "n_leaves": 1032, - "n_nodes": 1264, - "mae_cd": true - }, - { - "backend": "bst", - "case": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 7.713633298995167, - "fit_seconds_std": 0.20587623710384548, - "fit_seconds_min": 7.580647086986573, - "fit_seconds_max": 7.950775241988595, - "fit_seconds_all": [ - 7.580647086986573, - 7.609477568010334, - 7.950775241988595 - ], - "n_leaves": 64, - "n_nodes": 85, - "mae_cd": true - }, - { - "backend": "bst", - "case": "medium_5k_x_12", - "n_samples": 5000, - "n_features": 12, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 44.004765506334174, - "fit_seconds_std": 3.1475830078652334, - "fit_seconds_min": 41.09283641600632, - "fit_seconds_max": 47.344285339000635, - "fit_seconds_all": [ - 41.09283641600632, - 47.344285339000635, - 43.57717476399557 - ], - "n_leaves": 251, - "n_nodes": 335, - "mae_cd": true - }, - { - "backend": "bst", - "case": "large_8k_x_16", - "n_samples": 8000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 6, - "repeats": 2, - "fit_seconds_mean": 160.80786340999475, - "fit_seconds_std": 0.825977643737557, - "fit_seconds_min": 160.22380901699944, - "fit_seconds_max": 161.39191780299006, - "fit_seconds_all": [ - 160.22380901699944, - 161.39191780299006 - ], - "n_leaves": 1032, - "n_nodes": 1264, - "mae_cd": true - } - ], - "aggregates": [ - { - "case": "small_2k_x_8", - "sort_fit_seconds_mean": 11.64151620300739, - "bst_fit_seconds_mean": 7.713633298995167, - "speedup_sort_over_bst": 1.5092130714230214, - "n_samples": 2000, - "n_features": 8 - }, - { - "case": "medium_5k_x_12", - "sort_fit_seconds_mean": 59.52303006566459, - "bst_fit_seconds_mean": 44.004765506334174, - "speedup_sort_over_bst": 1.3526496364830458, - "n_samples": 5000, - "n_features": 12 - }, - { - "case": "large_8k_x_16", - "sort_fit_seconds_mean": 229.14787644099852, - "bst_fit_seconds_mean": 160.80786340999475, - "speedup_sort_over_bst": 1.4249792987844412, - "n_samples": 8000, - "n_features": 16 - } - ] -} diff --git a/benchmarks/results/sgt_mae_fit_latest.json b/benchmarks/results/sgt_mae_fit_latest.json deleted file mode 100644 index cb66347..0000000 --- a/benchmarks/results/sgt_mae_fit_latest.json +++ /dev/null @@ -1,196 +0,0 @@ -{ - "timestamp_utc": "20260821T222425Z", - "criterion": "absolute_error", - "mae_cd_env": "SGTLEARN_MAE_CD=1", - "backend_env": "SGTLEARN_MAE_BACKEND", - "tao_n_runs": 0, - "seed": 42, - "python": "3.14.2 (v3.14.2:df793163d58, Dec 5 2025, 12:18:06) [Clang 16.0.0 (clang-1600.0.26.6)]", - "cases": [ - { - "name": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 3 - }, - { - "name": "medium_5k_x_12", - "n_samples": 5000, - "n_features": 12, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 4, - "repeats": 3 - }, - { - "name": "large_8k_x_16", - "n_samples": 8000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 6, - "repeats": 2 - } - ], - "runs": [ - { - "backend": "sort", - "case": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 11.64151620300739, - "fit_seconds_std": 0.5621140523917701, - "fit_seconds_min": 10.997175320007955, - "fit_seconds_max": 12.031442292005522, - "fit_seconds_all": [ - 12.031442292005522, - 11.89593099700869, - 10.997175320007955 - ], - "n_leaves": 64, - "n_nodes": 85, - "mae_cd": true - }, - { - "backend": "sort", - "case": "medium_5k_x_12", - "n_samples": 5000, - "n_features": 12, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 59.52303006566459, - "fit_seconds_std": 1.1807398659122517, - "fit_seconds_min": 58.50989505900361, - "fit_seconds_max": 60.81973345899314, - "fit_seconds_all": [ - 60.81973345899314, - 58.50989505900361, - 59.23946167899703 - ], - "n_leaves": 251, - "n_nodes": 335, - "mae_cd": true - }, - { - "backend": "sort", - "case": "large_8k_x_16", - "n_samples": 8000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 6, - "repeats": 2, - "fit_seconds_mean": 229.14787644099852, - "fit_seconds_std": 18.161574668327344, - "fit_seconds_min": 216.30570383599843, - "fit_seconds_max": 241.9900490459986, - "fit_seconds_all": [ - 216.30570383599843, - 241.9900490459986 - ], - "n_leaves": 1032, - "n_nodes": 1264, - "mae_cd": true - }, - { - "backend": "bst", - "case": "small_2k_x_8", - "n_samples": 2000, - "n_features": 8, - "max_depth": 3, - "inner_max_depth": 3, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 7.713633298995167, - "fit_seconds_std": 0.20587623710384548, - "fit_seconds_min": 7.580647086986573, - "fit_seconds_max": 7.950775241988595, - "fit_seconds_all": [ - 7.580647086986573, - 7.609477568010334, - 7.950775241988595 - ], - "n_leaves": 64, - "n_nodes": 85, - "mae_cd": true - }, - { - "backend": "bst", - "case": "medium_5k_x_12", - "n_samples": 5000, - "n_features": 12, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 4, - "repeats": 3, - "fit_seconds_mean": 44.004765506334174, - "fit_seconds_std": 3.1475830078652334, - "fit_seconds_min": 41.09283641600632, - "fit_seconds_max": 47.344285339000635, - "fit_seconds_all": [ - 41.09283641600632, - 47.344285339000635, - 43.57717476399557 - ], - "n_leaves": 251, - "n_nodes": 335, - "mae_cd": true - }, - { - "backend": "bst", - "case": "large_8k_x_16", - "n_samples": 8000, - "n_features": 16, - "max_depth": 4, - "inner_max_depth": 4, - "num_partitions": 6, - "repeats": 2, - "fit_seconds_mean": 160.80786340999475, - "fit_seconds_std": 0.825977643737557, - "fit_seconds_min": 160.22380901699944, - "fit_seconds_max": 161.39191780299006, - "fit_seconds_all": [ - 160.22380901699944, - 161.39191780299006 - ], - "n_leaves": 1032, - "n_nodes": 1264, - "mae_cd": true - } - ], - "aggregates": [ - { - "case": "small_2k_x_8", - "sort_fit_seconds_mean": 11.64151620300739, - "bst_fit_seconds_mean": 7.713633298995167, - "speedup_sort_over_bst": 1.5092130714230214, - "n_samples": 2000, - "n_features": 8 - }, - { - "case": "medium_5k_x_12", - "sort_fit_seconds_mean": 59.52303006566459, - "bst_fit_seconds_mean": 44.004765506334174, - "speedup_sort_over_bst": 1.3526496364830458, - "n_samples": 5000, - "n_features": 12 - }, - { - "case": "large_8k_x_16", - "sort_fit_seconds_mean": 229.14787644099852, - "bst_fit_seconds_mean": 160.80786340999475, - "speedup_sort_over_bst": 1.4249792987844412, - "n_samples": 8000, - "n_features": 16 - } - ] -} diff --git a/benchmarks/results/sgt_mae_fit_summary.json b/benchmarks/results/sgt_mae_fit_summary.json deleted file mode 100644 index 874d9fd..0000000 --- a/benchmarks/results/sgt_mae_fit_summary.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "timestamp_utc": "20260821T222425Z", - "aggregates": [ - { - "case": "small_2k_x_8", - "sort_fit_seconds_mean": 11.64151620300739, - "bst_fit_seconds_mean": 7.713633298995167, - "speedup_sort_over_bst": 1.5092130714230214, - "n_samples": 2000, - "n_features": 8 - }, - { - "case": "medium_5k_x_12", - "sort_fit_seconds_mean": 59.52303006566459, - "bst_fit_seconds_mean": 44.004765506334174, - "speedup_sort_over_bst": 1.3526496364830458, - "n_samples": 5000, - "n_features": 12 - }, - { - "case": "large_8k_x_16", - "sort_fit_seconds_mean": 229.14787644099852, - "bst_fit_seconds_mean": 160.80786340999475, - "speedup_sort_over_bst": 1.4249792987844412, - "n_samples": 8000, - "n_features": 16 - } - ] -}