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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ macboost