macboost 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- macboost/__init__.py +360 -0
- macboost/_core.py +226 -0
- macboost/_pyinfer.py +112 -0
- macboost/export.py +334 -0
- macboost/libmacboostc.dylib +0 -0
- macboost-0.2.0.dist-info/METADATA +23 -0
- macboost-0.2.0.dist-info/RECORD +9 -0
- macboost-0.2.0.dist-info/WHEEL +5 -0
- macboost-0.2.0.dist-info/top_level.txt +1 -0
macboost/__init__.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""macboost — gradient boosted trees on Apple-silicon GPUs (Metal).
|
|
2
|
+
|
|
3
|
+
Scikit-learn-compatible estimators backed by the Swift/Metal core:
|
|
4
|
+
|
|
5
|
+
from macboost import MacBoostRegressor
|
|
6
|
+
model = MacBoostRegressor(n_estimators=200, max_depth=6)
|
|
7
|
+
model.fit(X_train, y_train, eval_set=(X_valid, y_valid),
|
|
8
|
+
early_stopping_rounds=50)
|
|
9
|
+
preds = model.predict(X_test)
|
|
10
|
+
|
|
11
|
+
Parameter names follow the dialect LightGBM's and XGBoost's sklearn wrappers
|
|
12
|
+
share; the well-known native spellings (num_iterations, eta, gamma,
|
|
13
|
+
lambda_l2, ...) are accepted as aliases. get_params/set_params implement the
|
|
14
|
+
scikit-learn estimator protocol, so clone, Pipeline, GridSearchCV and
|
|
15
|
+
cross_val_score work.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from . import _core
|
|
21
|
+
from ._core import MacBoostError
|
|
22
|
+
|
|
23
|
+
# scikit-learn is optional: when present, inheriting its base classes gives
|
|
24
|
+
# full estimator-protocol compliance (__sklearn_tags__, clone, CV tooling).
|
|
25
|
+
try:
|
|
26
|
+
from sklearn.base import BaseEstimator as _SkBase
|
|
27
|
+
from sklearn.base import ClassifierMixin as _SkClassifier
|
|
28
|
+
from sklearn.base import RegressorMixin as _SkRegressor
|
|
29
|
+
except ImportError:
|
|
30
|
+
class _SkBase: pass
|
|
31
|
+
class _SkRegressor: pass
|
|
32
|
+
class _SkClassifier: pass
|
|
33
|
+
|
|
34
|
+
__all__ = ["MacBoostRegressor", "MacBoostClassifier", "MacBoostError"]
|
|
35
|
+
__version__ = "0.2.0"
|
|
36
|
+
|
|
37
|
+
# Native LightGBM / XGBoost spellings -> canonical macboost names.
|
|
38
|
+
_ALIASES = {
|
|
39
|
+
"num_trees": "n_estimators",
|
|
40
|
+
"num_iterations": "n_estimators",
|
|
41
|
+
"num_boost_round": "n_estimators",
|
|
42
|
+
"num_round": "n_estimators",
|
|
43
|
+
"eta": "learning_rate",
|
|
44
|
+
"shrinkage_rate": "learning_rate",
|
|
45
|
+
"lambda_l2": "reg_lambda",
|
|
46
|
+
"reg_lambda_l2": "reg_lambda",
|
|
47
|
+
"gamma": "min_split_gain",
|
|
48
|
+
"min_split_loss": "min_split_gain",
|
|
49
|
+
"min_gain_to_split": "min_split_gain",
|
|
50
|
+
"min_sum_hessian_in_leaf": "min_child_weight",
|
|
51
|
+
"max_bins": "max_bin",
|
|
52
|
+
"categorical_feature": "categorical_features",
|
|
53
|
+
"top_rate": "goss_top_rate",
|
|
54
|
+
"other_rate": "goss_other_rate",
|
|
55
|
+
"bagging_fraction": "subsample",
|
|
56
|
+
"feature_fraction": "colsample_bytree",
|
|
57
|
+
"colsample": "colsample_bytree",
|
|
58
|
+
"eval_metric": "metric",
|
|
59
|
+
"scale_pos_weight": "scale_pos_weight",
|
|
60
|
+
"monotone_constraints": "monotone_constraints",
|
|
61
|
+
"tweedie_variance_power": "tweedie_variance_power",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_PARAM_NAMES = (
|
|
65
|
+
"n_estimators", "max_depth", "learning_rate", "reg_lambda",
|
|
66
|
+
"min_child_weight", "min_split_gain", "max_bin", "categorical_features",
|
|
67
|
+
"cat_smooth", "early_stopping_rounds", "verbose",
|
|
68
|
+
"goss", "goss_top_rate", "goss_other_rate",
|
|
69
|
+
"objective", "alpha", "tweedie_variance_power", "scale_pos_weight",
|
|
70
|
+
"subsample", "colsample_bytree", "monotone_constraints", "metric",
|
|
71
|
+
"importance_type",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _canonical(name):
|
|
76
|
+
return _ALIASES.get(name, name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _BaseBooster:
|
|
80
|
+
_objective = "regression"
|
|
81
|
+
|
|
82
|
+
def __init__(self, n_estimators=100, max_depth=6, learning_rate=0.1,
|
|
83
|
+
reg_lambda=1.0, min_child_weight=1.0, min_split_gain=0.0,
|
|
84
|
+
max_bin=256, categorical_features=None, cat_smooth=10.0,
|
|
85
|
+
early_stopping_rounds=0, verbose=False,
|
|
86
|
+
goss=False, goss_top_rate=0.2, goss_other_rate=0.1,
|
|
87
|
+
objective=None, alpha=0.9, tweedie_variance_power=1.5,
|
|
88
|
+
scale_pos_weight=1.0, subsample=1.0, colsample_bytree=1.0,
|
|
89
|
+
monotone_constraints=None, metric="auto",
|
|
90
|
+
importance_type="gain", **aliases):
|
|
91
|
+
self.n_estimators = n_estimators
|
|
92
|
+
self.max_depth = max_depth
|
|
93
|
+
self.learning_rate = learning_rate
|
|
94
|
+
self.reg_lambda = reg_lambda
|
|
95
|
+
self.min_child_weight = min_child_weight
|
|
96
|
+
self.min_split_gain = min_split_gain
|
|
97
|
+
self.max_bin = max_bin
|
|
98
|
+
self.categorical_features = categorical_features
|
|
99
|
+
self.cat_smooth = cat_smooth
|
|
100
|
+
self.early_stopping_rounds = early_stopping_rounds
|
|
101
|
+
self.verbose = verbose
|
|
102
|
+
self.goss = goss
|
|
103
|
+
self.goss_top_rate = goss_top_rate
|
|
104
|
+
self.goss_other_rate = goss_other_rate
|
|
105
|
+
self.objective = objective
|
|
106
|
+
self.alpha = alpha
|
|
107
|
+
self.tweedie_variance_power = tweedie_variance_power
|
|
108
|
+
self.scale_pos_weight = scale_pos_weight
|
|
109
|
+
self.subsample = subsample
|
|
110
|
+
self.colsample_bytree = colsample_bytree
|
|
111
|
+
self.monotone_constraints = monotone_constraints
|
|
112
|
+
self.metric = metric
|
|
113
|
+
self.importance_type = importance_type
|
|
114
|
+
self._handle = None
|
|
115
|
+
self.classes_ = None
|
|
116
|
+
for key, value in aliases.items():
|
|
117
|
+
canon = _canonical(key)
|
|
118
|
+
if canon not in _PARAM_NAMES:
|
|
119
|
+
raise TypeError(f"unknown parameter '{key}'")
|
|
120
|
+
setattr(self, canon, value)
|
|
121
|
+
|
|
122
|
+
# -- scikit-learn estimator protocol ---------------------------------
|
|
123
|
+
def get_params(self, deep=True):
|
|
124
|
+
return {k: getattr(self, k) for k in _PARAM_NAMES}
|
|
125
|
+
|
|
126
|
+
def set_params(self, **params):
|
|
127
|
+
for key, value in params.items():
|
|
128
|
+
canon = _canonical(key)
|
|
129
|
+
if canon not in _PARAM_NAMES:
|
|
130
|
+
raise ValueError(f"unknown parameter '{key}' for {type(self).__name__}")
|
|
131
|
+
setattr(self, canon, value)
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------
|
|
135
|
+
def _config(self, early_stopping_rounds, eval_every):
|
|
136
|
+
objective = self.objective or self._objective
|
|
137
|
+
cfg = {
|
|
138
|
+
"num_trees": self.n_estimators,
|
|
139
|
+
"max_depth": self.max_depth,
|
|
140
|
+
"learning_rate": self.learning_rate,
|
|
141
|
+
"lambda": self.reg_lambda,
|
|
142
|
+
"min_child_weight": self.min_child_weight,
|
|
143
|
+
"min_split_gain": self.min_split_gain,
|
|
144
|
+
"num_bins": self.max_bin,
|
|
145
|
+
"objective": objective,
|
|
146
|
+
"cat_smooth": self.cat_smooth,
|
|
147
|
+
"early_stopping_rounds": early_stopping_rounds,
|
|
148
|
+
"eval_every": eval_every,
|
|
149
|
+
"verbose": self.verbose,
|
|
150
|
+
"goss": self.goss,
|
|
151
|
+
"goss_top_rate": self.goss_top_rate,
|
|
152
|
+
"goss_other_rate": self.goss_other_rate,
|
|
153
|
+
"alpha": self.alpha,
|
|
154
|
+
"tweedie_variance_power": self.tweedie_variance_power,
|
|
155
|
+
"scale_pos_weight": self.scale_pos_weight,
|
|
156
|
+
"subsample": self.subsample,
|
|
157
|
+
"feature_fraction": self.colsample_bytree,
|
|
158
|
+
}
|
|
159
|
+
if self.metric and self.metric != "auto":
|
|
160
|
+
cfg["metric"] = self.metric
|
|
161
|
+
if self.monotone_constraints:
|
|
162
|
+
cfg["monotone_constraints"] = list(self.monotone_constraints)
|
|
163
|
+
if self.categorical_features:
|
|
164
|
+
cfg["categorical_features"] = list(self.categorical_features)
|
|
165
|
+
return cfg
|
|
166
|
+
|
|
167
|
+
def fit(self, X, y, eval_set=None, early_stopping_rounds=None, eval_every=0,
|
|
168
|
+
sample_weight=None, init_model=None):
|
|
169
|
+
if not _core.native_available():
|
|
170
|
+
raise MacBoostError(
|
|
171
|
+
"training requires the native Metal core (Apple silicon, "
|
|
172
|
+
"macOS 15+). This install supports inference only — use "
|
|
173
|
+
"load_model() to score models trained on a Mac.")
|
|
174
|
+
"""Train. X: (n, f) array; NaN marks missing values. Columns listed
|
|
175
|
+
in categorical_features must hold integer category ids.
|
|
176
|
+
|
|
177
|
+
eval_set: (X_valid, y_valid) tuple, or [(X_valid, y_valid)] in the
|
|
178
|
+
LightGBM/XGBoost list style. early_stopping_rounds here overrides
|
|
179
|
+
the constructor value (both places accepted, like XGBoost)."""
|
|
180
|
+
if isinstance(eval_set, list):
|
|
181
|
+
if len(eval_set) != 1:
|
|
182
|
+
raise ValueError("only a single eval_set is supported")
|
|
183
|
+
eval_set = eval_set[0]
|
|
184
|
+
rounds = (self.early_stopping_rounds if early_stopping_rounds is None
|
|
185
|
+
else early_stopping_rounds)
|
|
186
|
+
if rounds and eval_set is None:
|
|
187
|
+
raise ValueError("early stopping requires an eval_set")
|
|
188
|
+
cfg = self._config(rounds, eval_every)
|
|
189
|
+
y = self._encode_labels(y)
|
|
190
|
+
if eval_set is not None and self.classes_ is not None and len(self.classes_) > 0:
|
|
191
|
+
ev = eval_set if not isinstance(eval_set, list) else eval_set[0]
|
|
192
|
+
eval_set = (ev[0], self._transform_labels(ev[1]))
|
|
193
|
+
if self.classes_ is not None and len(self.classes_) > 2:
|
|
194
|
+
cfg["objective"] = "multiclass"
|
|
195
|
+
cfg["num_class"] = len(self.classes_)
|
|
196
|
+
import tempfile as _tmp
|
|
197
|
+
init_path = None
|
|
198
|
+
if init_model is not None:
|
|
199
|
+
if isinstance(init_model, (str, bytes)) or hasattr(init_model, "__fspath__"):
|
|
200
|
+
cfg["init_model"] = str(init_model)
|
|
201
|
+
else:
|
|
202
|
+
fd = _tmp.NamedTemporaryFile(suffix=".json", delete=False)
|
|
203
|
+
init_path = fd.name
|
|
204
|
+
fd.close()
|
|
205
|
+
init_model.save_model(init_path)
|
|
206
|
+
cfg["init_model"] = init_path
|
|
207
|
+
try:
|
|
208
|
+
self._handle = _core.train(cfg, X, y, eval_set, sample_weight)
|
|
209
|
+
finally:
|
|
210
|
+
if init_path:
|
|
211
|
+
import os as _os
|
|
212
|
+
_os.unlink(init_path)
|
|
213
|
+
import warnings as _warnings
|
|
214
|
+
for w in _core.warnings_of(self._handle):
|
|
215
|
+
_warnings.warn(w, UserWarning, stacklevel=2)
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def _raw_predict(self, X):
|
|
219
|
+
if getattr(self, "_pymodel", None) is not None:
|
|
220
|
+
return self._pymodel.predict_raw(np.asarray(X, dtype=np.float32))
|
|
221
|
+
if self._handle is None:
|
|
222
|
+
raise MacBoostError("model is not fitted; call fit() or load_model()")
|
|
223
|
+
return _core.predict(self._handle, X)
|
|
224
|
+
|
|
225
|
+
def _encode_labels(self, y):
|
|
226
|
+
return np.asarray(y, dtype=np.float32).ravel()
|
|
227
|
+
|
|
228
|
+
def _transform_labels(self, y):
|
|
229
|
+
return y
|
|
230
|
+
|
|
231
|
+
def feature_importance(self, importance_type=None):
|
|
232
|
+
if getattr(self, "_pymodel", None) is not None:
|
|
233
|
+
return self._pymodel.feature_importance(
|
|
234
|
+
importance_type or self.importance_type)
|
|
235
|
+
if self._handle is None:
|
|
236
|
+
raise MacBoostError("model is not fitted")
|
|
237
|
+
return _core.feature_importance(
|
|
238
|
+
self._handle, importance_type or self.importance_type)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def feature_importances_(self):
|
|
242
|
+
return self.feature_importance()
|
|
243
|
+
|
|
244
|
+
def predict_contrib(self, X):
|
|
245
|
+
"""SHAP contributions, rows x (n_features + 1); the last column is
|
|
246
|
+
the expected value. Rows sum to the raw prediction."""
|
|
247
|
+
if getattr(self, "_pymodel", None) is not None:
|
|
248
|
+
raise MacBoostError(
|
|
249
|
+
"predict_contrib requires the native core; export the model "
|
|
250
|
+
"or score contributions on a Mac")
|
|
251
|
+
if self._handle is None:
|
|
252
|
+
raise MacBoostError("model is not fitted")
|
|
253
|
+
return _core.predict_contrib(self._handle, X)
|
|
254
|
+
|
|
255
|
+
def save_xgboost(self, path):
|
|
256
|
+
"""Export as an XGBoost JSON model for deployment anywhere xgboost
|
|
257
|
+
runs (identical predictions; categorical splits are expanded into
|
|
258
|
+
equivalent numeric chains)."""
|
|
259
|
+
from . import export
|
|
260
|
+
export.save_xgboost(self, path)
|
|
261
|
+
|
|
262
|
+
def save_onnx(self, path):
|
|
263
|
+
"""Export as an ONNX model (ai.onnx.ml TreeEnsemble) scoreable by
|
|
264
|
+
onnxruntime on any platform. Requires the `onnx` package."""
|
|
265
|
+
from . import export
|
|
266
|
+
export.save_onnx(self, path)
|
|
267
|
+
|
|
268
|
+
def save_model(self, path):
|
|
269
|
+
if getattr(self, "_pymodel", None) is not None:
|
|
270
|
+
return self._pymodel.save(path)
|
|
271
|
+
if self._handle is None:
|
|
272
|
+
raise MacBoostError("model is not fitted")
|
|
273
|
+
_core.save(self._handle, path)
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def load_model(cls, path):
|
|
277
|
+
model = cls()
|
|
278
|
+
if _core.native_available():
|
|
279
|
+
model._handle = _core.load(path)
|
|
280
|
+
else:
|
|
281
|
+
# Pure-Python inference backend: works on any platform.
|
|
282
|
+
from ._pyinfer import PyModel
|
|
283
|
+
model._pymodel = PyModel(path)
|
|
284
|
+
return model
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def n_trees_(self):
|
|
288
|
+
if getattr(self, "_pymodel", None) is not None:
|
|
289
|
+
return self._pymodel.num_trees
|
|
290
|
+
return _core.num_trees(self._handle) if self._handle else 0
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def n_features_in_(self):
|
|
294
|
+
if getattr(self, "_pymodel", None) is not None:
|
|
295
|
+
return self._pymodel.num_features
|
|
296
|
+
return _core.num_features(self._handle) if self._handle else 0
|
|
297
|
+
|
|
298
|
+
# Back-compat spelling.
|
|
299
|
+
n_features_ = n_features_in_
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def best_iteration_(self):
|
|
303
|
+
best = _core.best_iteration(self._handle) if self._handle else 0
|
|
304
|
+
return best or None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class MacBoostRegressor(_SkRegressor, _BaseBooster, _SkBase):
|
|
308
|
+
_objective = "regression"
|
|
309
|
+
_estimator_type = "regressor"
|
|
310
|
+
|
|
311
|
+
def predict(self, X):
|
|
312
|
+
if getattr(self, "_pymodel", None) is not None:
|
|
313
|
+
return self._pymodel.predict(np.asarray(X, dtype=np.float32))
|
|
314
|
+
return self._raw_predict(X)
|
|
315
|
+
|
|
316
|
+
def score(self, X, y):
|
|
317
|
+
"""R^2, per the scikit-learn regressor contract."""
|
|
318
|
+
y = np.asarray(y, dtype=np.float64).ravel()
|
|
319
|
+
pred = self.predict(X).astype(np.float64)
|
|
320
|
+
ss_res = float(np.sum((y - pred) ** 2))
|
|
321
|
+
ss_tot = float(np.sum((y - y.mean()) ** 2))
|
|
322
|
+
return 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class MacBoostClassifier(_SkClassifier, _BaseBooster, _SkBase):
|
|
326
|
+
_objective = "binary"
|
|
327
|
+
_estimator_type = "classifier"
|
|
328
|
+
|
|
329
|
+
def _encode_labels(self, y):
|
|
330
|
+
y = np.asarray(y).ravel()
|
|
331
|
+
self.classes_ = np.unique(y)
|
|
332
|
+
if len(self.classes_) < 2:
|
|
333
|
+
raise ValueError("classifier needs at least 2 classes")
|
|
334
|
+
if len(self.classes_) > 128:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"y has {len(self.classes_)} distinct values — is it continuous? "
|
|
337
|
+
"Use MacBoostRegressor for regression targets")
|
|
338
|
+
return self._transform_labels(y)
|
|
339
|
+
|
|
340
|
+
def _transform_labels(self, y):
|
|
341
|
+
y = np.asarray(y).ravel()
|
|
342
|
+
lookup = {c: i for i, c in enumerate(self.classes_)}
|
|
343
|
+
return np.asarray([lookup[v] for v in y], dtype=np.float32)
|
|
344
|
+
|
|
345
|
+
def predict_proba(self, X):
|
|
346
|
+
raw = self._raw_predict(X).astype(np.float64)
|
|
347
|
+
if raw.ndim == 2: # multiclass softmax
|
|
348
|
+
e = np.exp(raw - raw.max(axis=1, keepdims=True))
|
|
349
|
+
return e / e.sum(axis=1, keepdims=True)
|
|
350
|
+
p = 1.0 / (1.0 + np.exp(-raw))
|
|
351
|
+
return np.column_stack([1 - p, p])
|
|
352
|
+
|
|
353
|
+
def predict(self, X):
|
|
354
|
+
idx = np.argmax(self.predict_proba(X), axis=1)
|
|
355
|
+
classes = self.classes_ if self.classes_ is not None else np.array([0, 1])
|
|
356
|
+
return classes[idx]
|
|
357
|
+
|
|
358
|
+
def score(self, X, y):
|
|
359
|
+
"""Accuracy, per the scikit-learn classifier contract."""
|
|
360
|
+
return float(np.mean(self.predict(X) == np.asarray(y).ravel()))
|
macboost/_core.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""ctypes bridge to libmacboostc.dylib (the Swift core's C ABI)."""
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _find_library() -> str:
|
|
12
|
+
candidates = []
|
|
13
|
+
if env := os.environ.get("MACBOOST_LIB"):
|
|
14
|
+
candidates.append(Path(env))
|
|
15
|
+
pkg_dir = Path(__file__).resolve().parent
|
|
16
|
+
candidates.append(pkg_dir / "libmacboostc.dylib")
|
|
17
|
+
# Running from a source checkout: ../../.build/release relative to python/macboost.
|
|
18
|
+
repo = pkg_dir.parent.parent
|
|
19
|
+
candidates.append(repo / ".build" / "release" / "libmacboostc.dylib")
|
|
20
|
+
for c in candidates:
|
|
21
|
+
if c.is_file():
|
|
22
|
+
return str(c)
|
|
23
|
+
raise OSError(
|
|
24
|
+
"libmacboostc.dylib not found. Build it with `swift build -c release` "
|
|
25
|
+
"(or scripts/build_python.sh to bundle it), or set MACBOOST_LIB."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_c_char_pp = ctypes.POINTER(ctypes.c_char_p)
|
|
30
|
+
_f32_p = ctypes.POINTER(ctypes.c_float)
|
|
31
|
+
|
|
32
|
+
_lib_instance = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _LazyLib:
|
|
36
|
+
"""Defers dylib loading so `import macboost` works on any platform;
|
|
37
|
+
only training/native inference require the Metal core."""
|
|
38
|
+
|
|
39
|
+
def __getattr__(self, name):
|
|
40
|
+
global _lib_instance
|
|
41
|
+
if _lib_instance is None:
|
|
42
|
+
if os.environ.get("MACBOOST_FORCE_PYTHON"):
|
|
43
|
+
raise OSError("MACBOOST_FORCE_PYTHON is set")
|
|
44
|
+
_lib_instance = ctypes.CDLL(_find_library())
|
|
45
|
+
_configure(_lib_instance)
|
|
46
|
+
return getattr(_lib_instance, name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def native_available() -> bool:
|
|
50
|
+
try:
|
|
51
|
+
_lib.macboost_num_trees # noqa: B018 — probe the load
|
|
52
|
+
return True
|
|
53
|
+
except OSError:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_lib = _LazyLib()
|
|
58
|
+
|
|
59
|
+
def _configure(_lib):
|
|
60
|
+
_lib.macboost_train.restype = ctypes.c_void_p
|
|
61
|
+
_lib.macboost_train.argtypes = [
|
|
62
|
+
ctypes.c_char_p, _f32_p, ctypes.c_int64, ctypes.c_int64, _f32_p, _f32_p,
|
|
63
|
+
_f32_p, ctypes.c_int64, _f32_p, _c_char_pp,
|
|
64
|
+
]
|
|
65
|
+
_lib.macboost_predict_contrib.restype = ctypes.c_int32
|
|
66
|
+
_lib.macboost_predict_contrib.argtypes = [
|
|
67
|
+
ctypes.c_void_p, _f32_p, ctypes.c_int64, ctypes.c_int64, _f32_p, _c_char_pp,
|
|
68
|
+
]
|
|
69
|
+
_lib.macboost_feature_importance.restype = ctypes.c_int32
|
|
70
|
+
_lib.macboost_feature_importance.argtypes = [ctypes.c_void_p, ctypes.c_int32, _f32_p]
|
|
71
|
+
_lib.macboost_predict.restype = ctypes.c_int32
|
|
72
|
+
_lib.macboost_predict.argtypes = [
|
|
73
|
+
ctypes.c_void_p, _f32_p, ctypes.c_int64, ctypes.c_int64, _f32_p, _c_char_pp,
|
|
74
|
+
]
|
|
75
|
+
_lib.macboost_save.restype = ctypes.c_int32
|
|
76
|
+
_lib.macboost_save.argtypes = [ctypes.c_void_p, ctypes.c_char_p, _c_char_pp]
|
|
77
|
+
_lib.macboost_load.restype = ctypes.c_void_p
|
|
78
|
+
_lib.macboost_load.argtypes = [ctypes.c_char_p, _c_char_pp]
|
|
79
|
+
for name in ("macboost_num_trees", "macboost_num_features", "macboost_best_iteration",
|
|
80
|
+
"macboost_num_classes"):
|
|
81
|
+
fn = getattr(_lib, name)
|
|
82
|
+
fn.restype = ctypes.c_int64
|
|
83
|
+
fn.argtypes = [ctypes.c_void_p]
|
|
84
|
+
_lib.macboost_warnings.restype = ctypes.c_void_p # char* we must free ourselves
|
|
85
|
+
_lib.macboost_warnings.argtypes = [ctypes.c_void_p]
|
|
86
|
+
_lib.macboost_free.restype = None
|
|
87
|
+
_lib.macboost_free.argtypes = [ctypes.c_void_p]
|
|
88
|
+
_lib.macboost_free_string.restype = None
|
|
89
|
+
_lib.macboost_free_string.argtypes = [ctypes.c_char_p]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MacBoostError(RuntimeError):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _take_error(err: ctypes.c_char_p) -> str:
|
|
97
|
+
msg = err.value.decode() if err.value else "unknown error"
|
|
98
|
+
# err.value copies the bytes; free the C string.
|
|
99
|
+
_lib.macboost_free_string(err)
|
|
100
|
+
return msg
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _feature_major(X) -> tuple[np.ndarray, int, int]:
|
|
104
|
+
"""(n, f) array-like -> contiguous feature-major float32 + (rows, cols)."""
|
|
105
|
+
a = np.asarray(X, dtype=np.float32)
|
|
106
|
+
if a.ndim != 2:
|
|
107
|
+
raise ValueError(f"X must be 2-dimensional, got shape {a.shape}")
|
|
108
|
+
rows, cols = a.shape
|
|
109
|
+
return np.ascontiguousarray(a.T), rows, cols
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _f32(a: np.ndarray) -> "ctypes._Pointer":
|
|
113
|
+
return a.ctypes.data_as(_f32_p)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _Handle:
|
|
117
|
+
"""Owns a booster handle and frees it on GC."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, ptr: int):
|
|
120
|
+
self.ptr = ptr
|
|
121
|
+
|
|
122
|
+
def __del__(self):
|
|
123
|
+
if getattr(self, "ptr", None):
|
|
124
|
+
_lib.macboost_free(self.ptr)
|
|
125
|
+
self.ptr = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def train(config: dict, X, y, eval_set=None, sample_weight=None) -> _Handle:
|
|
129
|
+
Xf, rows, cols = _feature_major(X)
|
|
130
|
+
ya = np.ascontiguousarray(np.asarray(y, dtype=np.float32).ravel())
|
|
131
|
+
if ya.shape[0] != rows:
|
|
132
|
+
raise ValueError(f"y has {ya.shape[0]} rows, X has {rows}")
|
|
133
|
+
w_ptr = None
|
|
134
|
+
if sample_weight is not None:
|
|
135
|
+
wa = np.ascontiguousarray(np.asarray(sample_weight, dtype=np.float32).ravel())
|
|
136
|
+
if wa.shape[0] != rows:
|
|
137
|
+
raise ValueError(f"sample_weight has {wa.shape[0]} rows, X has {rows}")
|
|
138
|
+
w_ptr = _f32(wa)
|
|
139
|
+
|
|
140
|
+
xv_ptr, yv_ptr, vrows = None, None, 0
|
|
141
|
+
if eval_set is not None:
|
|
142
|
+
Xv, yv = eval_set
|
|
143
|
+
Xvf, vrows, vcols = _feature_major(Xv)
|
|
144
|
+
if vcols != cols:
|
|
145
|
+
raise ValueError("eval_set feature count differs from X")
|
|
146
|
+
yva = np.ascontiguousarray(np.asarray(yv, dtype=np.float32).ravel())
|
|
147
|
+
if yva.shape[0] != vrows:
|
|
148
|
+
raise ValueError("eval_set X/y row mismatch")
|
|
149
|
+
xv_ptr, yv_ptr = _f32(Xvf), _f32(yva)
|
|
150
|
+
|
|
151
|
+
err = ctypes.c_char_p()
|
|
152
|
+
ptr = _lib.macboost_train(
|
|
153
|
+
json.dumps(config).encode(), _f32(Xf), rows, cols, _f32(ya), w_ptr,
|
|
154
|
+
xv_ptr, vrows, yv_ptr, ctypes.byref(err),
|
|
155
|
+
)
|
|
156
|
+
if not ptr:
|
|
157
|
+
raise MacBoostError(_take_error(err))
|
|
158
|
+
return _Handle(ptr)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def predict(handle: _Handle, X) -> np.ndarray:
|
|
162
|
+
Xf, rows, cols = _feature_major(X)
|
|
163
|
+
k = _lib.macboost_num_classes(handle.ptr)
|
|
164
|
+
out = np.empty(rows * k, dtype=np.float32)
|
|
165
|
+
err = ctypes.c_char_p()
|
|
166
|
+
rc = _lib.macboost_predict(handle.ptr, _f32(Xf), rows, cols, _f32(out),
|
|
167
|
+
ctypes.byref(err))
|
|
168
|
+
if rc != 0:
|
|
169
|
+
raise MacBoostError(_take_error(err))
|
|
170
|
+
return out.reshape(rows, k) if k > 1 else out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def predict_contrib(handle: _Handle, X) -> np.ndarray:
|
|
174
|
+
Xf, rows, cols = _feature_major(X)
|
|
175
|
+
k = _lib.macboost_num_classes(handle.ptr)
|
|
176
|
+
out = np.empty(rows * k * (cols + 1), dtype=np.float32)
|
|
177
|
+
err = ctypes.c_char_p()
|
|
178
|
+
rc = _lib.macboost_predict_contrib(handle.ptr, _f32(Xf), rows, cols,
|
|
179
|
+
_f32(out), ctypes.byref(err))
|
|
180
|
+
if rc != 0:
|
|
181
|
+
raise MacBoostError(_take_error(err))
|
|
182
|
+
return out.reshape(rows, k * (cols + 1))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def feature_importance(handle: _Handle, importance_type: str) -> np.ndarray:
|
|
186
|
+
n = _lib.macboost_num_features(handle.ptr)
|
|
187
|
+
out = np.empty(n, dtype=np.float32)
|
|
188
|
+
t = 1 if importance_type == "split" else 0
|
|
189
|
+
if _lib.macboost_feature_importance(handle.ptr, t, _f32(out)) != 0:
|
|
190
|
+
raise MacBoostError("feature_importance failed")
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def save(handle: _Handle, path: str) -> None:
|
|
195
|
+
err = ctypes.c_char_p()
|
|
196
|
+
if _lib.macboost_save(handle.ptr, str(path).encode(), ctypes.byref(err)) != 0:
|
|
197
|
+
raise MacBoostError(_take_error(err))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def load(path: str) -> _Handle:
|
|
201
|
+
err = ctypes.c_char_p()
|
|
202
|
+
ptr = _lib.macboost_load(str(path).encode(), ctypes.byref(err))
|
|
203
|
+
if not ptr:
|
|
204
|
+
raise MacBoostError(_take_error(err))
|
|
205
|
+
return _Handle(ptr)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def warnings_of(handle: _Handle) -> list[str]:
|
|
209
|
+
ptr = _lib.macboost_warnings(handle.ptr)
|
|
210
|
+
if not ptr:
|
|
211
|
+
return []
|
|
212
|
+
text = ctypes.string_at(ptr).decode()
|
|
213
|
+
_lib.macboost_free_string(ctypes.cast(ptr, ctypes.c_char_p))
|
|
214
|
+
return text.splitlines()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def num_trees(handle: _Handle) -> int:
|
|
218
|
+
return _lib.macboost_num_trees(handle.ptr)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def num_features(handle: _Handle) -> int:
|
|
222
|
+
return _lib.macboost_num_features(handle.ptr)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def best_iteration(handle: _Handle) -> int:
|
|
226
|
+
return _lib.macboost_best_iteration(handle.ptr)
|
macboost/_pyinfer.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Pure-Python (numpy) inference over MacBoost model.json files.
|
|
2
|
+
|
|
3
|
+
Training requires the Metal core (Apple silicon), but a trained model is
|
|
4
|
+
just trees — this backend scores them anywhere Python runs, so models
|
|
5
|
+
trained on a Mac deploy to Linux with nothing but `pip install macboost`.
|
|
6
|
+
Semantics match the native predictor exactly: `x <= threshold` goes left,
|
|
7
|
+
NaN follows the learned default direction, categorical splits test bitmask
|
|
8
|
+
membership (unseen/out-of-range categories route like missing), and
|
|
9
|
+
poisson/tweedie outputs are exp-transformed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
_LOG_LINK_OBJECTIVES = {5, 6} # poisson, tweedie
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PyModel:
|
|
20
|
+
"""Reads the JSON written by MacBooster.save / save_model."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path):
|
|
23
|
+
with open(path) as fh:
|
|
24
|
+
m = json.load(fh)
|
|
25
|
+
if m.get("version") != 1:
|
|
26
|
+
raise ValueError("unsupported model file version")
|
|
27
|
+
self.objective = int(m["objective"])
|
|
28
|
+
self.num_bins = int(m["numBins"])
|
|
29
|
+
self.base_score = np.float32(m["baseScore"])
|
|
30
|
+
self.num_features = int(m["numFeatures"])
|
|
31
|
+
self.num_classes = int(m.get("numClasses") or 1)
|
|
32
|
+
self.base_scores = np.asarray(
|
|
33
|
+
m.get("baseScores") or [m["baseScore"]], dtype=np.float32)
|
|
34
|
+
self.feature_names = m.get("featureNames")
|
|
35
|
+
self._raw = m
|
|
36
|
+
self.trees = []
|
|
37
|
+
for t in m["trees"]:
|
|
38
|
+
tree = {
|
|
39
|
+
"feature": np.asarray(t["feature"], dtype=np.int64),
|
|
40
|
+
"threshold": np.asarray(t["threshold"], dtype=np.float32),
|
|
41
|
+
"leaf": np.asarray(t["leaf"], dtype=np.float32),
|
|
42
|
+
"flags": np.asarray(t["flags"], dtype=np.uint8),
|
|
43
|
+
"gain": np.asarray(t.get("gain") or [], dtype=np.float32),
|
|
44
|
+
}
|
|
45
|
+
mask = np.asarray(t.get("catMask") or [], dtype=np.uint64)
|
|
46
|
+
tree["catMask"] = mask.reshape(-1, 8) if mask.size else None
|
|
47
|
+
self.trees.append(tree)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def num_trees(self):
|
|
51
|
+
return len(self.trees)
|
|
52
|
+
|
|
53
|
+
def predict_raw(self, X):
|
|
54
|
+
X = np.asarray(X, dtype=np.float32)
|
|
55
|
+
if X.ndim != 2 or X.shape[1] != self.num_features:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"X must be (n, {self.num_features}), got {X.shape}")
|
|
58
|
+
n = X.shape[0]
|
|
59
|
+
K = self.num_classes
|
|
60
|
+
out = np.tile(self.base_scores, (n, 1)).astype(np.float32) # (n, K)
|
|
61
|
+
for t, tree in enumerate(self.trees):
|
|
62
|
+
node = np.zeros(n, dtype=np.int64)
|
|
63
|
+
active = tree["feature"][node] >= 0
|
|
64
|
+
while active.any():
|
|
65
|
+
idx = np.nonzero(active)[0]
|
|
66
|
+
cur = node[idx]
|
|
67
|
+
v = X[idx, tree["feature"][cur]]
|
|
68
|
+
left = self._goes_left_at(tree, cur, v)
|
|
69
|
+
node[idx] = 2 * cur + 1 + (~left).astype(np.int64)
|
|
70
|
+
active[idx] = tree["feature"][node[idx]] >= 0
|
|
71
|
+
out[:, t % K] += tree["leaf"][node]
|
|
72
|
+
return out[:, 0] if K == 1 else out
|
|
73
|
+
|
|
74
|
+
def _goes_left_at(self, tree, node, v):
|
|
75
|
+
thr = tree["threshold"][node]
|
|
76
|
+
flags = tree["flags"][node]
|
|
77
|
+
left = v <= thr
|
|
78
|
+
nan = np.isnan(v)
|
|
79
|
+
if nan.any():
|
|
80
|
+
left = np.where(nan, (flags & 1) != 0, left)
|
|
81
|
+
cat_nodes = (flags & 2) != 0
|
|
82
|
+
if tree["catMask"] is not None and cat_nodes.any():
|
|
83
|
+
data_bins = self.num_bins - 1
|
|
84
|
+
cat = np.where(np.isfinite(v), np.rint(v), -1).astype(np.int64)
|
|
85
|
+
bad = (cat < 0) | (cat >= data_bins)
|
|
86
|
+
cat = np.where(bad, data_bins, cat)
|
|
87
|
+
words = tree["catMask"][node, cat >> 5]
|
|
88
|
+
member = ((words >> (cat & 31).astype(np.uint64)) & 1).astype(bool)
|
|
89
|
+
left = np.where(cat_nodes, member, left)
|
|
90
|
+
return left
|
|
91
|
+
|
|
92
|
+
def predict(self, X):
|
|
93
|
+
out = self.predict_raw(X)
|
|
94
|
+
if self.objective in _LOG_LINK_OBJECTIVES:
|
|
95
|
+
out = np.exp(out)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
def feature_importance(self, importance_type="gain"):
|
|
99
|
+
imp = np.zeros(self.num_features, dtype=np.float32)
|
|
100
|
+
for tree in self.trees:
|
|
101
|
+
splits = tree["feature"] >= 0
|
|
102
|
+
feats = tree["feature"][splits]
|
|
103
|
+
if importance_type == "split":
|
|
104
|
+
np.add.at(imp, feats, 1)
|
|
105
|
+
else:
|
|
106
|
+
np.add.at(imp, feats, tree["gain"][splits]
|
|
107
|
+
if tree["gain"].size else 0)
|
|
108
|
+
return imp
|
|
109
|
+
|
|
110
|
+
def save(self, path):
|
|
111
|
+
with open(path, "w") as fh:
|
|
112
|
+
json.dump(self._raw, fh)
|
macboost/export.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Export MacBoost models for deployment on non-Apple platforms.
|
|
2
|
+
|
|
3
|
+
- save_xgboost(model, path): writes an XGBoost JSON model that standard
|
|
4
|
+
`xgboost` loads and scores anywhere (Linux servers, containers, ...).
|
|
5
|
+
- to_onnx(model): builds an ONNX graph using the ai.onnx.ml TreeEnsemble
|
|
6
|
+
operators for onnxruntime everywhere (requires the `onnx` package).
|
|
7
|
+
|
|
8
|
+
Semantics notes:
|
|
9
|
+
- MacBoost splits are `x <= t` (left); XGBoost/ONNX-LEQ conventions are
|
|
10
|
+
matched exactly (thresholds nudged by one float32 ulp where needed).
|
|
11
|
+
- Categorical subset splits have no direct equivalent in either target, so
|
|
12
|
+
they are rewritten into chains of numeric interval tests routing to
|
|
13
|
+
duplicated subtrees — identical predictions, larger trees. Unseen or
|
|
14
|
+
out-of-range categories route like missing values, as in MacBoost.
|
|
15
|
+
- Raw-score regression objectives (l2/mae/huber/quantile) export as plain
|
|
16
|
+
regression; logistic/poisson/tweedie/multiclass map to the target's
|
|
17
|
+
equivalent transform. Base scores are folded into the first tree's
|
|
18
|
+
leaves so no cross-library base-score semantics are involved.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import tempfile
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
_FLAG_DEFAULT_LEFT = 1
|
|
27
|
+
_FLAG_CATEGORICAL = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _model_dict(model):
|
|
31
|
+
"""Model JSON dict from an estimator or a PyModel."""
|
|
32
|
+
pymodel = getattr(model, "_pymodel", None)
|
|
33
|
+
if pymodel is not None:
|
|
34
|
+
return pymodel._raw
|
|
35
|
+
with tempfile.NamedTemporaryFile(suffix=".json") as fh:
|
|
36
|
+
model.save_model(fh.name)
|
|
37
|
+
with open(fh.name) as f:
|
|
38
|
+
return json.load(f)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Node:
|
|
42
|
+
__slots__ = ("feature", "threshold", "default_left", "left", "right", "value")
|
|
43
|
+
|
|
44
|
+
def __init__(self, feature=-1, threshold=0.0, default_left=False,
|
|
45
|
+
left=-1, right=-1, value=0.0):
|
|
46
|
+
self.feature = feature # -1 => leaf
|
|
47
|
+
self.threshold = threshold # "x <= threshold" goes left
|
|
48
|
+
self.default_left = default_left
|
|
49
|
+
self.left = left
|
|
50
|
+
self.right = right
|
|
51
|
+
self.value = value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _explicit_tree(tree, num_bins):
|
|
55
|
+
"""Heap-layout tree -> explicit numeric-only node list (categorical
|
|
56
|
+
splits expanded into interval chains with duplicated subtrees)."""
|
|
57
|
+
feature = tree["feature"]
|
|
58
|
+
threshold = tree["threshold"]
|
|
59
|
+
leaf = tree["leaf"]
|
|
60
|
+
flags = tree["flags"]
|
|
61
|
+
cat_mask = tree.get("catMask") or []
|
|
62
|
+
data_bins = num_bins - 1
|
|
63
|
+
nodes = []
|
|
64
|
+
|
|
65
|
+
def add(node):
|
|
66
|
+
nodes.append(node)
|
|
67
|
+
return len(nodes) - 1
|
|
68
|
+
|
|
69
|
+
def member(heap_idx, cat):
|
|
70
|
+
word = cat_mask[heap_idx * 8 + (cat >> 5)]
|
|
71
|
+
return (word >> (cat & 31)) & 1 == 1
|
|
72
|
+
|
|
73
|
+
def build(h):
|
|
74
|
+
f = int(feature[h])
|
|
75
|
+
if f < 0:
|
|
76
|
+
return add(_Node(value=float(leaf[h])))
|
|
77
|
+
fl = int(flags[h])
|
|
78
|
+
if fl & _FLAG_CATEGORICAL == 0:
|
|
79
|
+
n = add(_Node(feature=f, threshold=float(threshold[h]),
|
|
80
|
+
default_left=bool(fl & _FLAG_DEFAULT_LEFT)))
|
|
81
|
+
nodes[n].left = build(2 * h + 1)
|
|
82
|
+
nodes[n].right = build(2 * h + 2)
|
|
83
|
+
return n
|
|
84
|
+
|
|
85
|
+
# Categorical: membership of ints 0..data_bins-1 (missing bin =
|
|
86
|
+
# data_bins participates too). Compress into runs of equal side,
|
|
87
|
+
# then a right-deep chain of `x <= run_end + 0.5` tests. NaN and
|
|
88
|
+
# out-of-range categories route to the missing bin's side.
|
|
89
|
+
sides = [member(h, c) for c in range(data_bins)] # True => left
|
|
90
|
+
missing_left = member(h, data_bins)
|
|
91
|
+
runs = [] # (end_cat, side)
|
|
92
|
+
for c in range(data_bins):
|
|
93
|
+
if runs and runs[-1][1] == sides[c]:
|
|
94
|
+
runs[-1] = (c, sides[c])
|
|
95
|
+
else:
|
|
96
|
+
runs.append((c, sides[c]))
|
|
97
|
+
|
|
98
|
+
def side_subtree(left_side):
|
|
99
|
+
return build(2 * h + 1) if left_side else build(2 * h + 2)
|
|
100
|
+
|
|
101
|
+
# Trailing destination for x > last category: missing side.
|
|
102
|
+
chain = side_subtree(missing_left)
|
|
103
|
+
for end, side in reversed(runs):
|
|
104
|
+
n = add(_Node(feature=f, threshold=end + 0.5,
|
|
105
|
+
default_left=(side == missing_left)))
|
|
106
|
+
nodes[n].left = side_subtree(side)
|
|
107
|
+
nodes[n].right = chain
|
|
108
|
+
chain = n
|
|
109
|
+
# Leading guard: x < 0 (invalid category) -> missing side.
|
|
110
|
+
n = add(_Node(feature=f, threshold=-0.5, default_left=missing_left))
|
|
111
|
+
nodes[n].left = side_subtree(missing_left)
|
|
112
|
+
nodes[n].right = chain
|
|
113
|
+
return n
|
|
114
|
+
|
|
115
|
+
root = build(0)
|
|
116
|
+
# BFS relabel: XGBoost allocates parents before children and its JSON
|
|
117
|
+
# loader relies on that ordering, so match it (root at index 0).
|
|
118
|
+
order = [root]
|
|
119
|
+
for i in order:
|
|
120
|
+
if nodes[i].feature >= 0:
|
|
121
|
+
order.append(nodes[i].left)
|
|
122
|
+
order.append(nodes[i].right)
|
|
123
|
+
remap = {old: new for new, old in enumerate(order)}
|
|
124
|
+
reordered = [nodes[old] for old in order]
|
|
125
|
+
for node in reordered:
|
|
126
|
+
if node.feature >= 0:
|
|
127
|
+
node.left = remap[node.left]
|
|
128
|
+
node.right = remap[node.right]
|
|
129
|
+
return reordered
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_NEUTRAL_BASE = { # base_score whose margin is 0, per objective
|
|
133
|
+
"reg:squarederror": "0", "binary:logistic": "0.5",
|
|
134
|
+
"count:poisson": "1", "reg:tweedie": "1", "multi:softprob": "0.5",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _xgb_objective(m):
|
|
139
|
+
obj = int(m["objective"])
|
|
140
|
+
if obj == 1:
|
|
141
|
+
return "binary:logistic", {"reg_loss_param": {"scale_pos_weight": "1"}}
|
|
142
|
+
if obj == 5:
|
|
143
|
+
return "count:poisson", {"poisson_regression_param": {"max_delta_step": "0.7"}}
|
|
144
|
+
if obj == 6:
|
|
145
|
+
return "reg:tweedie", {"tweedie_regression_param": {"tweedie_variance_power": "1.5"}}
|
|
146
|
+
if obj == 7:
|
|
147
|
+
k = str(int(m.get("numClasses") or 1))
|
|
148
|
+
return "multi:softprob", {"softmax_multiclass_param": {"num_class": k}}
|
|
149
|
+
# l2 / mae / huber / quantile: raw scores, plain regression at inference.
|
|
150
|
+
return "reg:squarederror", {"reg_loss_param": {"scale_pos_weight": "1"}}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def to_xgboost_dict(model):
|
|
154
|
+
m = _model_dict(model)
|
|
155
|
+
num_features = int(m["numFeatures"])
|
|
156
|
+
num_bins = int(m["numBins"])
|
|
157
|
+
k = int(m.get("numClasses") or 1)
|
|
158
|
+
base_scores = m.get("baseScores") or [m["baseScore"]]
|
|
159
|
+
obj_name, obj_param = _xgb_objective(m)
|
|
160
|
+
|
|
161
|
+
trees_json = []
|
|
162
|
+
for t, tree in enumerate(m["trees"]):
|
|
163
|
+
nodes = _explicit_tree(tree, num_bins)
|
|
164
|
+
n = len(nodes)
|
|
165
|
+
left = [node.left for node in nodes]
|
|
166
|
+
right = [node.right for node in nodes]
|
|
167
|
+
parents = [2147483647] * n
|
|
168
|
+
for i, node in enumerate(nodes):
|
|
169
|
+
if node.feature >= 0:
|
|
170
|
+
parents[node.left] = i
|
|
171
|
+
parents[node.right] = i
|
|
172
|
+
conditions, indices, defaults, weights = [], [], [], []
|
|
173
|
+
for node in nodes:
|
|
174
|
+
if node.feature >= 0:
|
|
175
|
+
# xgb: x < condition goes left; ours: x <= t. Nudge up 1 ulp.
|
|
176
|
+
thr = float(np.nextafter(np.float32(node.threshold),
|
|
177
|
+
np.float32(np.inf)))
|
|
178
|
+
conditions.append(thr)
|
|
179
|
+
indices.append(node.feature)
|
|
180
|
+
defaults.append(1 if node.default_left else 0)
|
|
181
|
+
weights.append(0.0)
|
|
182
|
+
else:
|
|
183
|
+
value = node.value
|
|
184
|
+
# Fold per-class base scores into the first tree of each class.
|
|
185
|
+
if t < k:
|
|
186
|
+
value += float(base_scores[t % k])
|
|
187
|
+
conditions.append(value)
|
|
188
|
+
indices.append(0)
|
|
189
|
+
defaults.append(0)
|
|
190
|
+
weights.append(value)
|
|
191
|
+
trees_json.append({
|
|
192
|
+
"base_weights": weights,
|
|
193
|
+
"categories": [], "categories_nodes": [],
|
|
194
|
+
"categories_segments": [], "categories_sizes": [],
|
|
195
|
+
"default_left": defaults,
|
|
196
|
+
"id": t,
|
|
197
|
+
"left_children": left,
|
|
198
|
+
"loss_changes": [0.0] * n,
|
|
199
|
+
"parents": parents,
|
|
200
|
+
"right_children": right,
|
|
201
|
+
"split_conditions": conditions,
|
|
202
|
+
"split_indices": indices,
|
|
203
|
+
"split_type": [0] * n,
|
|
204
|
+
"sum_hessian": [1.0] * n,
|
|
205
|
+
"tree_param": {
|
|
206
|
+
"num_deleted": "0",
|
|
207
|
+
"num_feature": str(num_features),
|
|
208
|
+
"num_nodes": str(n),
|
|
209
|
+
"size_leaf_vector": "1",
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
num_trees = len(trees_json)
|
|
214
|
+
rounds = num_trees // k if k else num_trees
|
|
215
|
+
return {
|
|
216
|
+
"learner": {
|
|
217
|
+
"attributes": {},
|
|
218
|
+
"feature_names": [],
|
|
219
|
+
"feature_types": [],
|
|
220
|
+
"gradient_booster": {
|
|
221
|
+
"model": {
|
|
222
|
+
"gbtree_model_param": {
|
|
223
|
+
"num_parallel_tree": "1",
|
|
224
|
+
"num_trees": str(num_trees),
|
|
225
|
+
},
|
|
226
|
+
"iteration_indptr": [i * k for i in range(rounds + 1)],
|
|
227
|
+
"tree_info": [t % k for t in range(num_trees)],
|
|
228
|
+
"trees": trees_json,
|
|
229
|
+
},
|
|
230
|
+
"name": "gbtree",
|
|
231
|
+
},
|
|
232
|
+
"learner_model_param": {
|
|
233
|
+
"base_score": _NEUTRAL_BASE[obj_name],
|
|
234
|
+
"boost_from_average": "1",
|
|
235
|
+
"num_class": str(k if k > 1 else 0),
|
|
236
|
+
"num_feature": str(num_features),
|
|
237
|
+
"num_target": "1",
|
|
238
|
+
},
|
|
239
|
+
"objective": {"name": obj_name, **obj_param},
|
|
240
|
+
},
|
|
241
|
+
"version": [2, 1, 0],
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def save_xgboost(model, path):
|
|
246
|
+
"""Write an XGBoost-format JSON model loadable by xgb.Booster()."""
|
|
247
|
+
with open(path, "w") as fh:
|
|
248
|
+
json.dump(to_xgboost_dict(model), fh)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def to_onnx(model):
|
|
252
|
+
"""Build an ONNX ModelProto (ai.onnx.ml TreeEnsembleRegressor).
|
|
253
|
+
|
|
254
|
+
The graph takes float input `(n, num_features)` and produces
|
|
255
|
+
`prediction`: raw scores for regression objectives, probabilities for
|
|
256
|
+
logistic (n, 1) / multiclass (n, K), and exp-transformed means for
|
|
257
|
+
poisson/tweedie. Requires the `onnx` package.
|
|
258
|
+
"""
|
|
259
|
+
try:
|
|
260
|
+
from onnx import TensorProto, helper
|
|
261
|
+
except ImportError as e:
|
|
262
|
+
raise ImportError(
|
|
263
|
+
"ONNX export requires the `onnx` package: pip install onnx") from e
|
|
264
|
+
|
|
265
|
+
m = _model_dict(model)
|
|
266
|
+
num_features = int(m["numFeatures"])
|
|
267
|
+
num_bins = int(m["numBins"])
|
|
268
|
+
k = int(m.get("numClasses") or 1)
|
|
269
|
+
obj = int(m["objective"])
|
|
270
|
+
base_scores = [float(b) for b in (m.get("baseScores") or [m["baseScore"]])]
|
|
271
|
+
|
|
272
|
+
attrs = {
|
|
273
|
+
"nodes_treeids": [], "nodes_nodeids": [], "nodes_featureids": [],
|
|
274
|
+
"nodes_modes": [], "nodes_values": [], "nodes_truenodeids": [],
|
|
275
|
+
"nodes_falsenodeids": [], "nodes_missing_value_tracks_true": [],
|
|
276
|
+
"target_treeids": [], "target_nodeids": [], "target_ids": [],
|
|
277
|
+
"target_weights": [],
|
|
278
|
+
}
|
|
279
|
+
for t, tree in enumerate(m["trees"]):
|
|
280
|
+
for i, node in enumerate(_explicit_tree(tree, num_bins)):
|
|
281
|
+
attrs["nodes_treeids"].append(t)
|
|
282
|
+
attrs["nodes_nodeids"].append(i)
|
|
283
|
+
if node.feature >= 0:
|
|
284
|
+
attrs["nodes_featureids"].append(node.feature)
|
|
285
|
+
attrs["nodes_modes"].append("BRANCH_LEQ") # v <= t: exact match
|
|
286
|
+
attrs["nodes_values"].append(node.threshold)
|
|
287
|
+
attrs["nodes_truenodeids"].append(node.left)
|
|
288
|
+
attrs["nodes_falsenodeids"].append(node.right)
|
|
289
|
+
attrs["nodes_missing_value_tracks_true"].append(
|
|
290
|
+
1 if node.default_left else 0)
|
|
291
|
+
else:
|
|
292
|
+
attrs["nodes_featureids"].append(0)
|
|
293
|
+
attrs["nodes_modes"].append("LEAF")
|
|
294
|
+
attrs["nodes_values"].append(0.0)
|
|
295
|
+
attrs["nodes_truenodeids"].append(0)
|
|
296
|
+
attrs["nodes_falsenodeids"].append(0)
|
|
297
|
+
attrs["nodes_missing_value_tracks_true"].append(0)
|
|
298
|
+
attrs["target_treeids"].append(t)
|
|
299
|
+
attrs["target_nodeids"].append(i)
|
|
300
|
+
attrs["target_ids"].append(t % k)
|
|
301
|
+
attrs["target_weights"].append(node.value)
|
|
302
|
+
|
|
303
|
+
ensemble = helper.make_node(
|
|
304
|
+
"TreeEnsembleRegressor", ["input"], ["margin"],
|
|
305
|
+
domain="ai.onnx.ml", n_targets=k, base_values=base_scores,
|
|
306
|
+
post_transform="NONE", **attrs)
|
|
307
|
+
nodes = [ensemble]
|
|
308
|
+
if obj == 1: # logistic -> probability
|
|
309
|
+
nodes.append(helper.make_node("Sigmoid", ["margin"], ["prediction"]))
|
|
310
|
+
elif obj == 7: # multiclass -> probabilities
|
|
311
|
+
nodes.append(helper.make_node("Softmax", ["margin"], ["prediction"], axis=1))
|
|
312
|
+
elif obj in (5, 6): # poisson/tweedie -> mean
|
|
313
|
+
nodes.append(helper.make_node("Exp", ["margin"], ["prediction"]))
|
|
314
|
+
else:
|
|
315
|
+
nodes.append(helper.make_node("Identity", ["margin"], ["prediction"]))
|
|
316
|
+
|
|
317
|
+
graph = helper.make_graph(
|
|
318
|
+
nodes, "macboost",
|
|
319
|
+
[helper.make_tensor_value_info("input", TensorProto.FLOAT,
|
|
320
|
+
[None, num_features])],
|
|
321
|
+
[helper.make_tensor_value_info("prediction", TensorProto.FLOAT,
|
|
322
|
+
[None, k])],
|
|
323
|
+
)
|
|
324
|
+
onnx_model = helper.make_model(
|
|
325
|
+
graph, opset_imports=[helper.make_opsetid("ai.onnx.ml", 1),
|
|
326
|
+
helper.make_opsetid("", 13)],
|
|
327
|
+
producer_name="macboost", ir_version=7)
|
|
328
|
+
return onnx_model
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def save_onnx(model, path):
|
|
332
|
+
"""Write an ONNX model scoreable by onnxruntime on any platform."""
|
|
333
|
+
with open(path, "wb") as fh:
|
|
334
|
+
fh.write(to_onnx(model).SerializeToString())
|
|
Binary file
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: macboost
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Gradient boosted trees on Apple-silicon GPUs (Metal), sklearn-style API
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: numpy>=1.24
|
|
9
|
+
|
|
10
|
+
# macboost (Python)
|
|
11
|
+
|
|
12
|
+
Python bindings for [MacBoost](../README.md) — gradient boosted trees on
|
|
13
|
+
Apple-silicon GPUs, with a scikit-learn-style API (`MacBoostRegressor`,
|
|
14
|
+
`MacBoostClassifier`).
|
|
15
|
+
|
|
16
|
+
Build the native core first, then install:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
swift build -c release && ../scripts/build_python.sh # bundles the dylib
|
|
20
|
+
pip install .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
See the repository README for usage and benchmarks.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
macboost/__init__.py,sha256=GQZ_VjEvKduYMYCQsuTw8uJWV57yx3wRk5U2CVQXvkg,14586
|
|
2
|
+
macboost/_core.py,sha256=aOKoUK_4hizN1Yx8AcTwwJPtxPUDF-ey1g6JW1fiSOA,7729
|
|
3
|
+
macboost/_pyinfer.py,sha256=YBC5h77m2tMFadUghVlZduFBZOYbYsI8XpWSZcx0k8s,4470
|
|
4
|
+
macboost/export.py,sha256=Ua7DxFw6n4GakECdYm3-SH4wlaZlmtBYI_qfzFunE6w,13346
|
|
5
|
+
macboost/libmacboostc.dylib,sha256=wdnlEXR44vxqTnUW3QrjRxNO92S3QEJ9tMLassrHdPw,456288
|
|
6
|
+
macboost-0.2.0.dist-info/METADATA,sha256=15VZhkSRh2_yRpvumTr9NBVR3vBis2i_b81TvkIUFIA,628
|
|
7
|
+
macboost-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
macboost-0.2.0.dist-info/top_level.txt,sha256=7ER5jA9JUzdGk8Z9DkYnkCsbwLCQNH0PXCDmT1yKc2s,9
|
|
9
|
+
macboost-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
macboost
|