anchorboosting 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/.github/workflows/ci.yml +22 -0
- anchorboosting-0.3.0/CHANGELOG.rst +24 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/PKG-INFO +11 -4
- anchorboosting-0.3.0/README.md +14 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting/models.py +107 -20
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting/simulate.py +10 -1
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/PKG-INFO +11 -4
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/SOURCES.txt +8 -3
- anchorboosting-0.3.0/asv.conf.json +18 -0
- anchorboosting-0.3.0/benchmarks/__init__.py +0 -0
- anchorboosting-0.3.0/benchmarks/benchmark.py +77 -0
- anchorboosting-0.3.0/benchmarks/data/.gitignore +1 -0
- anchorboosting-0.3.0/benchmarks/environment.yml +14 -0
- anchorboosting-0.3.0/benchmarks/load.py +110 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/index.rst +1 -1
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/environment.yml +1 -1
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/pyproject.toml +1 -1
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/tests/test_anchor_boosting.py +80 -16
- anchorboosting-0.3.0/tests/test_proj.py +67 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/tests/test_refit.py +3 -2
- anchorboosting-0.2.0/CHANGELOG.rst +0 -13
- anchorboosting-0.2.0/README.md +0 -7
- anchorboosting-0.2.0/anchorboosting/utils.py +0 -84
- anchorboosting-0.2.0/tests/test_utils.py +0 -74
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/.github/workflows/build.yaml +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/.gitignore +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/.pre-commit-config.yaml +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/LICENSE +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting/__init__.py +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/dependency_links.txt +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/requires.txt +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/top_level.txt +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/Makefile +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/bib.bib +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/changelog.rst +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/conf.py +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/readthedocs.yaml +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/setup.cfg +0 -0
- {anchorboosting-0.2.0 → anchorboosting-0.3.0}/tests/test_derivatives.py +0 -0
|
@@ -8,6 +8,28 @@ name: CI
|
|
|
8
8
|
|
|
9
9
|
jobs:
|
|
10
10
|
|
|
11
|
+
asv-bench:
|
|
12
|
+
name: ASV Benchmarks
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0
|
|
18
|
+
- name: Set up micromamba
|
|
19
|
+
uses: mamba-org/setup-micromamba@d05808540d968a55ca33c798e0661fb98f533c73
|
|
20
|
+
with:
|
|
21
|
+
micromamba-version: 1.5.10-0
|
|
22
|
+
environment-file: benchmarks/environment.yml
|
|
23
|
+
|
|
24
|
+
- name: Install repository
|
|
25
|
+
run: python -m pip install --no-build-isolation --no-deps --disable-pip-version-check -e .
|
|
26
|
+
- name: Run benchmarks
|
|
27
|
+
shell: bash -l {0}
|
|
28
|
+
run: |
|
|
29
|
+
asv machine --machine github --yes
|
|
30
|
+
# asv run --machine github --python=same
|
|
31
|
+
asv continuous origin/main HEAD -e --factor 1.05 --machine github --python=same
|
|
32
|
+
|
|
11
33
|
test:
|
|
12
34
|
name: Unit tests - ${{ matrix.PYTHON_VERSION }}
|
|
13
35
|
runs-on: ubuntu-latest
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Changelog
|
|
2
|
+
=========
|
|
3
|
+
|
|
4
|
+
0.3.0 (2025-09-15)
|
|
5
|
+
------------------
|
|
6
|
+
|
|
7
|
+
- Fixed handling of feature names for numpy input in `AnchorBooster`.
|
|
8
|
+
- Added support for pandas DataFrame input in `AnchorBooster`.
|
|
9
|
+
|
|
10
|
+
0.2.1 (2025-07-01)
|
|
11
|
+
------------------
|
|
12
|
+
|
|
13
|
+
- `AnchorBooster.refit` no longer raises an error if the model stopped boosting early during initial training.
|
|
14
|
+
|
|
15
|
+
0.2.0 (2025-06-12)
|
|
16
|
+
------------------
|
|
17
|
+
|
|
18
|
+
- Sped up boosting.
|
|
19
|
+
|
|
20
|
+
0.1.0 (2025-05-29)
|
|
21
|
+
------------------
|
|
22
|
+
|
|
23
|
+
- Initial release of the `anchorboosting` package.
|
|
24
|
+
- Implemented `AnchorBooster` class for anchor regression and anchor probit classification.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: anchorboosting
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Efficient tree-based nonlinear anchor regression and classification
|
|
5
5
|
Author-email: Malte Londschien <malte@londschien.ch>
|
|
6
6
|
Project-URL: homepage, https://github.com/mlondschien/anchorboosting/
|
|
@@ -15,8 +15,15 @@ Dynamic: license-file
|
|
|
15
15
|
|
|
16
16
|
# Anchor Boosting
|
|
17
17
|
|
|
18
|
-
Efficient tree-based nonlinear anchor regression and classification for Python
|
|
18
|
+
Efficient tree-based nonlinear anchor regression and classification for Python.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
If you use this, please consider citing
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
```
|
|
23
|
+
article{londschien2025,
|
|
24
|
+
title={Domain Generalization and Adaptation in Intensive Care with Anchor Regression},
|
|
25
|
+
author={Londschien, Malte, Burger, Manuel, R{\"a}tsch, Gunnar and B{\"u}hlmann, Peter},
|
|
26
|
+
journal={arXiv preprint arXiv:2507.21783},
|
|
27
|
+
year={2025}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Anchor Boosting
|
|
2
|
+
|
|
3
|
+
Efficient tree-based nonlinear anchor regression and classification for Python.
|
|
4
|
+
|
|
5
|
+
If you use this, please consider citing
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
article{londschien2025,
|
|
9
|
+
title={Domain Generalization and Adaptation in Intensive Care with Anchor Regression},
|
|
10
|
+
author={Londschien, Malte, Burger, Manuel, R{\"a}tsch, Gunnar and B{\"u}hlmann, Peter},
|
|
11
|
+
journal={arXiv preprint arXiv:2507.21783},
|
|
12
|
+
year={2025}
|
|
13
|
+
}
|
|
14
|
+
```
|
|
@@ -161,9 +161,6 @@ class AnchorBooster:
|
|
|
161
161
|
if self.objective != "regression" and not np.isin(y, [0, 1]).all():
|
|
162
162
|
raise ValueError("For binary classification, y values must be in {0, 1}.")
|
|
163
163
|
|
|
164
|
-
if Z is None:
|
|
165
|
-
Z = np.zeros(shape=(len(y), 0))
|
|
166
|
-
|
|
167
164
|
y = y.flatten()
|
|
168
165
|
|
|
169
166
|
if self.objective == "regression":
|
|
@@ -177,7 +174,9 @@ class AnchorBooster:
|
|
|
177
174
|
)
|
|
178
175
|
|
|
179
176
|
if hasattr(X, "columns"):
|
|
180
|
-
feature_name = X.columns
|
|
177
|
+
feature_name = list(X.columns)
|
|
178
|
+
else:
|
|
179
|
+
feature_name = None
|
|
181
180
|
|
|
182
181
|
if _POLARS_INSTALLED and isinstance(X, pl.DataFrame):
|
|
183
182
|
X = X.to_arrow()
|
|
@@ -198,7 +197,7 @@ class AnchorBooster:
|
|
|
198
197
|
f = np.ones(len(y), dtype=np.float64) * self.init_score_
|
|
199
198
|
booster_preds = f.copy()
|
|
200
199
|
|
|
201
|
-
|
|
200
|
+
proj = Proj(Z)
|
|
202
201
|
|
|
203
202
|
if self.objective == "binary":
|
|
204
203
|
y_tilde = np.where(y == 1, 1, -1).astype(np.float64)
|
|
@@ -231,7 +230,7 @@ class AnchorBooster:
|
|
|
231
230
|
dr = -f * r + r**2 # d^2/df^2 loss(f, y)
|
|
232
231
|
ddr = (f**2 - 1) * r - 3 * f * r**2 + 2 * r**3 # d^3/df^3 loss
|
|
233
232
|
|
|
234
|
-
r_proj =
|
|
233
|
+
r_proj = proj(r)
|
|
235
234
|
grad = r + (self.gamma - 1) * r_proj * dr
|
|
236
235
|
|
|
237
236
|
# We wish to fit one additional tree. Intuitively, one would use
|
|
@@ -348,19 +347,8 @@ class AnchorBooster:
|
|
|
348
347
|
counts += self.params.get("lambda_l2", 0)
|
|
349
348
|
counts[counts == 0] = 1 # counts==0 should never happen.
|
|
350
349
|
|
|
351
|
-
#
|
|
352
|
-
|
|
353
|
-
# version using a sparse matrix is faster.
|
|
354
|
-
Mdr = scipy.sparse.csr_matrix(
|
|
355
|
-
(
|
|
356
|
-
dr,
|
|
357
|
-
(np.arange(len(leaves)), leaves),
|
|
358
|
-
),
|
|
359
|
-
shape=(len(leaves), num_leaves),
|
|
360
|
-
dtype=np.float64,
|
|
361
|
-
)
|
|
362
|
-
B = Mdr.T.dot(Q)
|
|
363
|
-
H = (self.gamma - 1) * B @ B.T
|
|
350
|
+
# (dr * M)^T P_Z (dr * M)
|
|
351
|
+
H = (self.gamma - 1) * proj.sandwich(leaves, num_leaves, weights=dr)
|
|
364
352
|
H += np.diag(counts)
|
|
365
353
|
|
|
366
354
|
# Compute the 2nd order update
|
|
@@ -449,7 +437,8 @@ class AnchorBooster:
|
|
|
449
437
|
|
|
450
438
|
f = np.full(len(y), self.init_score_, dtype="float64")
|
|
451
439
|
|
|
452
|
-
|
|
440
|
+
# The model might have stopped boosting prematurely.
|
|
441
|
+
for idx in range(leaves.shape[1]):
|
|
453
442
|
n_obs = np.bincount(leaves[:, idx], minlength=num_leaves[idx])
|
|
454
443
|
|
|
455
444
|
if self.objective == "regression":
|
|
@@ -505,3 +494,101 @@ def _suppress_stderr():
|
|
|
505
494
|
os.dup2(old_stderr_fd, stderr_fd) # put the old stderr back
|
|
506
495
|
os.close(old_stderr_fd)
|
|
507
496
|
os.close(devnull_fd)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class Proj:
|
|
500
|
+
"""
|
|
501
|
+
Cache the projection onto the subspace spanned by Z.
|
|
502
|
+
|
|
503
|
+
Parameters
|
|
504
|
+
----------
|
|
505
|
+
Z: np.ndarray of dimension (n, d_Z) or (n,), optional, default=None
|
|
506
|
+
The `Z` matrix or 1d array of integers.
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
def __init__(self, Z):
|
|
510
|
+
if Z is None:
|
|
511
|
+
self._type = "none"
|
|
512
|
+
|
|
513
|
+
elif len(Z.shape) == 1 and np.issubdtype(Z.dtype, np.integer):
|
|
514
|
+
self._type = "categorical"
|
|
515
|
+
self._Z = Z
|
|
516
|
+
self._n_categories = np.max(Z) + 1
|
|
517
|
+
counts = np.bincount(Z, minlength=self._n_categories)
|
|
518
|
+
counts[counts == 0] = 1.0 # Avoid division by zero
|
|
519
|
+
self._one_over_counts = 1 / counts
|
|
520
|
+
|
|
521
|
+
elif len(Z.shape) == 2 and np.issubdtype(Z.dtype, np.floating):
|
|
522
|
+
self._type = "linear"
|
|
523
|
+
self._Q, _ = np.linalg.qr(Z, mode="reduced")
|
|
524
|
+
else:
|
|
525
|
+
raise ValueError(
|
|
526
|
+
"Z should be either a 1d array of integers or a 2d array of floats. "
|
|
527
|
+
f"Got shape {Z.shape} and dtype {Z.dtype}."
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def __call__(self, f):
|
|
531
|
+
"""
|
|
532
|
+
Project the input array `f` onto the subspace spanned by `Z`.
|
|
533
|
+
|
|
534
|
+
Parameters
|
|
535
|
+
----------
|
|
536
|
+
f: np.ndarray of shape (n,)
|
|
537
|
+
The input array to project.
|
|
538
|
+
|
|
539
|
+
Returns
|
|
540
|
+
-------
|
|
541
|
+
np.ndarray of shape (n,)
|
|
542
|
+
The projected array.
|
|
543
|
+
"""
|
|
544
|
+
if self._type == "none":
|
|
545
|
+
return np.zeros_like(f)
|
|
546
|
+
|
|
547
|
+
elif self._type == "categorical":
|
|
548
|
+
sums = np.bincount(self._Z, weights=f, minlength=self._n_categories)
|
|
549
|
+
means = sums * self._one_over_counts
|
|
550
|
+
return means[self._Z]
|
|
551
|
+
|
|
552
|
+
elif self._type == "linear":
|
|
553
|
+
return self._Q @ (self._Q.T @ f)
|
|
554
|
+
|
|
555
|
+
def sandwich(self, leaves, num_leaves, weights):
|
|
556
|
+
"""
|
|
557
|
+
For M = weights * one_hot(leaves), return proj(Z, M).T @ proj(Z, M).
|
|
558
|
+
|
|
559
|
+
Parameters
|
|
560
|
+
----------
|
|
561
|
+
leaves: np.ndarray of shape (n,)
|
|
562
|
+
The leaf indices for each sample in `f`. Integers in [0, num_leaves).
|
|
563
|
+
num_leaves: int
|
|
564
|
+
The number of leaves in the decision tree.
|
|
565
|
+
weights: np.ndarray of shape (n,)
|
|
566
|
+
The input array to project.
|
|
567
|
+
|
|
568
|
+
Returns
|
|
569
|
+
-------
|
|
570
|
+
np.ndarray of shape (d, d)
|
|
571
|
+
The sandwich product.
|
|
572
|
+
"""
|
|
573
|
+
if self._type == "none":
|
|
574
|
+
return np.zeros((num_leaves, num_leaves), dtype=weights.dtype)
|
|
575
|
+
|
|
576
|
+
elif self._type == "categorical":
|
|
577
|
+
S = np.zeros((self._n_categories, num_leaves), dtype=weights.dtype)
|
|
578
|
+
np.add.at(S, (self._Z, leaves), weights)
|
|
579
|
+
return (S * self._one_over_counts[:, np.newaxis]).T @ S
|
|
580
|
+
|
|
581
|
+
elif self._type == "linear":
|
|
582
|
+
# M^T P_Z M = (M^T Q) @ (M^T Q)^T
|
|
583
|
+
# One could also compute this using bincount, but it appears this
|
|
584
|
+
# version using a sparse matrix is faster.
|
|
585
|
+
M = scipy.sparse.csr_matrix(
|
|
586
|
+
(
|
|
587
|
+
weights,
|
|
588
|
+
(np.arange(len(leaves)), leaves),
|
|
589
|
+
),
|
|
590
|
+
shape=(len(leaves), num_leaves),
|
|
591
|
+
dtype=weights.dtype,
|
|
592
|
+
)
|
|
593
|
+
B = M.T @ self._Q
|
|
594
|
+
return B @ B.T
|
|
@@ -2,7 +2,7 @@ import numpy as np
|
|
|
2
2
|
import polars as pl
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
def simulate(f, n=100, shift=0, seed=0):
|
|
5
|
+
def simulate(f, n=100, shift=0, seed=0, return_dtype="polars"):
|
|
6
6
|
rng = np.random.RandomState(seed)
|
|
7
7
|
|
|
8
8
|
p = 3
|
|
@@ -23,6 +23,15 @@ def simulate(f, n=100, shift=0, seed=0):
|
|
|
23
23
|
schema={"x1": pl.Float64, "x2": pl.Float64, "x3": pl.Int64},
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
+
if return_dtype == "numpy":
|
|
27
|
+
df = df.to_numpy()
|
|
28
|
+
a = a.astype(np.float32)
|
|
29
|
+
y = y.astype(np.float32)
|
|
30
|
+
elif return_dtype == "pandas":
|
|
31
|
+
df = df.to_pandas()
|
|
32
|
+
a = a.astype(np.float32)
|
|
33
|
+
y = y.astype(np.float32)
|
|
34
|
+
|
|
26
35
|
return df, y, a
|
|
27
36
|
|
|
28
37
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: anchorboosting
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Efficient tree-based nonlinear anchor regression and classification
|
|
5
5
|
Author-email: Malte Londschien <malte@londschien.ch>
|
|
6
6
|
Project-URL: homepage, https://github.com/mlondschien/anchorboosting/
|
|
@@ -15,8 +15,15 @@ Dynamic: license-file
|
|
|
15
15
|
|
|
16
16
|
# Anchor Boosting
|
|
17
17
|
|
|
18
|
-
Efficient tree-based nonlinear anchor regression and classification for Python
|
|
18
|
+
Efficient tree-based nonlinear anchor regression and classification for Python.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
If you use this, please consider citing
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
```
|
|
23
|
+
article{londschien2025,
|
|
24
|
+
title={Domain Generalization and Adaptation in Intensive Care with Anchor Regression},
|
|
25
|
+
author={Londschien, Malte, Burger, Manuel, R{\"a}tsch, Gunnar and B{\"u}hlmann, Peter},
|
|
26
|
+
journal={arXiv preprint arXiv:2507.21783},
|
|
27
|
+
year={2025}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
CHANGELOG.rst
|
|
4
4
|
LICENSE
|
|
5
5
|
README.md
|
|
6
|
+
asv.conf.json
|
|
6
7
|
environment.yml
|
|
7
8
|
pyproject.toml
|
|
8
9
|
readthedocs.yaml
|
|
@@ -11,12 +12,16 @@ readthedocs.yaml
|
|
|
11
12
|
anchorboosting/__init__.py
|
|
12
13
|
anchorboosting/models.py
|
|
13
14
|
anchorboosting/simulate.py
|
|
14
|
-
anchorboosting/utils.py
|
|
15
15
|
anchorboosting.egg-info/PKG-INFO
|
|
16
16
|
anchorboosting.egg-info/SOURCES.txt
|
|
17
17
|
anchorboosting.egg-info/dependency_links.txt
|
|
18
18
|
anchorboosting.egg-info/requires.txt
|
|
19
19
|
anchorboosting.egg-info/top_level.txt
|
|
20
|
+
benchmarks/__init__.py
|
|
21
|
+
benchmarks/benchmark.py
|
|
22
|
+
benchmarks/environment.yml
|
|
23
|
+
benchmarks/load.py
|
|
24
|
+
benchmarks/data/.gitignore
|
|
20
25
|
docs/Makefile
|
|
21
26
|
docs/bib.bib
|
|
22
27
|
docs/changelog.rst
|
|
@@ -24,5 +29,5 @@ docs/conf.py
|
|
|
24
29
|
docs/index.rst
|
|
25
30
|
tests/test_anchor_boosting.py
|
|
26
31
|
tests/test_derivatives.py
|
|
27
|
-
tests/
|
|
28
|
-
tests/
|
|
32
|
+
tests/test_proj.py
|
|
33
|
+
tests/test_refit.py
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"branches": ["main"],
|
|
4
|
+
"project": "anchorboosting",
|
|
5
|
+
"project_url": "https://github.com/mlondschien/anchorboosting",
|
|
6
|
+
"repo": "..",
|
|
7
|
+
"environment_type": "conda",
|
|
8
|
+
"show_commit_url": "https://github.com/mlondschien/anchorboosting/commit/",
|
|
9
|
+
"pythons": ["3.12"],
|
|
10
|
+
"conda_channels": ["conda-forge"],
|
|
11
|
+
"build_command": "",
|
|
12
|
+
"install_command": "python -mpip install --no-deps .",
|
|
13
|
+
"conda_environment_file": "benchmarks/environment.yml",
|
|
14
|
+
"benchmark_dir": "benchmarks",
|
|
15
|
+
"env_dir": ".asv/env",
|
|
16
|
+
"results_dir": ".asv/results",
|
|
17
|
+
"html_dir": ".asv/html"
|
|
18
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import timeit
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from lightgbm import LGBMModel
|
|
6
|
+
|
|
7
|
+
from anchorboosting import AnchorBooster
|
|
8
|
+
|
|
9
|
+
from .load import load_nyc_taxi
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Benchmark(ABC):
|
|
13
|
+
# Measure wall time instead of CPU usage
|
|
14
|
+
timer = timeit.default_timer
|
|
15
|
+
|
|
16
|
+
name = ""
|
|
17
|
+
|
|
18
|
+
params: Any
|
|
19
|
+
param_names: Any
|
|
20
|
+
|
|
21
|
+
def setup(self, *params):
|
|
22
|
+
self._setup_data(params)
|
|
23
|
+
self._setup_model(params)
|
|
24
|
+
|
|
25
|
+
def _setup_data(self, params):
|
|
26
|
+
idx = self.param_names.index("n")
|
|
27
|
+
n = params[idx]
|
|
28
|
+
self.X, self.Z, self.y, self.y_binary = load_nyc_taxi(n)
|
|
29
|
+
|
|
30
|
+
@abstractmethod # Required for asv to skip.
|
|
31
|
+
def _setup_model(self, params):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AnchorBoosterBenchmark(Benchmark):
|
|
36
|
+
def __init__(self, n_jobs=2):
|
|
37
|
+
self.n_jobs = n_jobs # GitHub CI has two nodes.
|
|
38
|
+
|
|
39
|
+
name = "AnchorBooster"
|
|
40
|
+
param_names = ["objective", "n", "num_boost_round", "max_depth"]
|
|
41
|
+
params = (["regression", "binary"], [100_000], [100], [3])
|
|
42
|
+
|
|
43
|
+
def _setup_model(self, params):
|
|
44
|
+
objective, _, num_boost_round, max_depth = params
|
|
45
|
+
self.model = AnchorBooster(
|
|
46
|
+
gamma=2,
|
|
47
|
+
num_boost_round=num_boost_round,
|
|
48
|
+
max_depth=max_depth,
|
|
49
|
+
n_jobs=self.n_jobs,
|
|
50
|
+
objective=objective,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def time_fit(self, *args):
|
|
54
|
+
y = self.y_binary if self.model.objective == "binary" else self.y
|
|
55
|
+
self.model.fit(self.X, y, self.Z)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LGBMBenchmark(Benchmark):
|
|
59
|
+
def __init__(self, n_jobs=2):
|
|
60
|
+
self.n_jobs = n_jobs # GitHub CI has two nodes.
|
|
61
|
+
|
|
62
|
+
name = "LGBM Booster"
|
|
63
|
+
param_names = ["objective", "n", "num_boost_round", "max_depth"]
|
|
64
|
+
params = (["regression", "binary"], [100_000], [100], [3])
|
|
65
|
+
|
|
66
|
+
def _setup_model(self, params):
|
|
67
|
+
objective, _, num_boost_round, max_depth = params
|
|
68
|
+
self.model = LGBMModel(
|
|
69
|
+
num_boost_round=num_boost_round,
|
|
70
|
+
max_depth=max_depth,
|
|
71
|
+
objective=objective,
|
|
72
|
+
n_jobs=self.n_jobs,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def time_fit(self, *args):
|
|
76
|
+
y = self.y_binary if self.model.objective == "binary" else self.y
|
|
77
|
+
self.model.fit(self.X, y)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*.parquet
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: benchmarks
|
|
2
|
+
channels:
|
|
3
|
+
- conda-forge
|
|
4
|
+
- nodefaults
|
|
5
|
+
dependencies:
|
|
6
|
+
- python =3.12
|
|
7
|
+
- numpy
|
|
8
|
+
- lightgbm
|
|
9
|
+
- asv
|
|
10
|
+
- scikit-learn <=1.5.2 # https://github.com/microsoft/LightGBM/issues/6849
|
|
11
|
+
- polars
|
|
12
|
+
- pyarrow
|
|
13
|
+
- cffi # https://github.com/microsoft/LightGBM/pull/6785
|
|
14
|
+
- libmambapy
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# From https://github.com/xhochy/nyc-taxi-fare-prediction-deployment-example
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import polars as pl
|
|
5
|
+
from sklearn.compose import ColumnTransformer
|
|
6
|
+
from sklearn.preprocessing import OrdinalEncoder
|
|
7
|
+
|
|
8
|
+
DATA_PATH = Path(__file__).resolve().parent / "data"
|
|
9
|
+
NYC_TAXI_DATASET_PATH = DATA_PATH / "nyc_taxi_data.parquet"
|
|
10
|
+
NYC_TAXI_DATASET_URL = (
|
|
11
|
+
"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2019-01.parquet"
|
|
12
|
+
)
|
|
13
|
+
NYC_TAXI_BURROWS_PATH = DATA_PATH / "nyc_taxi_burrows.parquet"
|
|
14
|
+
NYC_TAXI_BURROWS_URL = "https://d37ci6vzurychx.cloudfront.net/misc/taxi_zone_lookup.csv"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_nyc_taxi_raw():
|
|
18
|
+
if NYC_TAXI_DATASET_PATH.exists():
|
|
19
|
+
return pl.read_parquet(NYC_TAXI_DATASET_PATH)
|
|
20
|
+
else:
|
|
21
|
+
df = pl.read_parquet(NYC_TAXI_DATASET_URL)
|
|
22
|
+
df.write_parquet(NYC_TAXI_DATASET_PATH)
|
|
23
|
+
return df
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_nyc_taxi_burrows():
|
|
27
|
+
if NYC_TAXI_BURROWS_PATH.exists():
|
|
28
|
+
return pl.read_parquet(NYC_TAXI_BURROWS_PATH)
|
|
29
|
+
else:
|
|
30
|
+
df = pl.read_csv(NYC_TAXI_BURROWS_URL)
|
|
31
|
+
df.write_parquet(NYC_TAXI_BURROWS_PATH)
|
|
32
|
+
return df
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_nyc_taxi(n):
|
|
36
|
+
df = load_nyc_taxi_raw().head(n)
|
|
37
|
+
|
|
38
|
+
burrows = load_nyc_taxi_burrows()
|
|
39
|
+
|
|
40
|
+
df = (
|
|
41
|
+
df.filter(pl.col("total_amount").is_not_null() & pl.col("total_amount").gt(0))
|
|
42
|
+
.join(
|
|
43
|
+
burrows.with_columns(pl.col("Borough").alias("pickup_borough")),
|
|
44
|
+
left_on="PULocationID",
|
|
45
|
+
right_on="LocationID",
|
|
46
|
+
how="left",
|
|
47
|
+
)
|
|
48
|
+
.join(
|
|
49
|
+
burrows.with_columns(pl.col("Borough").alias("dropoff_borough")),
|
|
50
|
+
left_on="DOLocationID",
|
|
51
|
+
right_on="LocationID",
|
|
52
|
+
how="left",
|
|
53
|
+
)
|
|
54
|
+
.with_columns(
|
|
55
|
+
pl.col("total_amount").log().alias("total_amount_log"),
|
|
56
|
+
pl.col("tpep_pickup_datetime").dt.weekday().alias("pickup_weekday"),
|
|
57
|
+
pl.col("tpep_pickup_datetime").dt.hour().alias("pickup_hour"),
|
|
58
|
+
pl.col("tpep_pickup_datetime").dt.minute().alias("pickup_minute"),
|
|
59
|
+
pl.col("pickup_borough").fill_null("Unknown").alias("pickup_borough"),
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
continuous_columns = [
|
|
64
|
+
"passenger_count",
|
|
65
|
+
"trip_distance",
|
|
66
|
+
"extra",
|
|
67
|
+
"mta_tax",
|
|
68
|
+
"tip_amount",
|
|
69
|
+
"tolls_amount",
|
|
70
|
+
"improvement_surcharge",
|
|
71
|
+
"congestion_surcharge",
|
|
72
|
+
"pickup_hour",
|
|
73
|
+
"pickup_minute",
|
|
74
|
+
]
|
|
75
|
+
df = df.with_columns(pl.col(c).cast(pl.Float64) for c in continuous_columns)
|
|
76
|
+
|
|
77
|
+
ordinal_columns = [
|
|
78
|
+
"VendorID",
|
|
79
|
+
"RatecodeID",
|
|
80
|
+
"store_and_fwd_flag",
|
|
81
|
+
"payment_type",
|
|
82
|
+
"service_zone",
|
|
83
|
+
"Borough",
|
|
84
|
+
"Zone",
|
|
85
|
+
"pickup_weekday",
|
|
86
|
+
"pickup_borough",
|
|
87
|
+
"dropoff_borough",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
preprocessor = ColumnTransformer(
|
|
91
|
+
transformers=[
|
|
92
|
+
("continuous", "passthrough", continuous_columns),
|
|
93
|
+
(
|
|
94
|
+
"categorical",
|
|
95
|
+
OrdinalEncoder(
|
|
96
|
+
handle_unknown="use_encoded_value",
|
|
97
|
+
unknown_value=-1,
|
|
98
|
+
),
|
|
99
|
+
ordinal_columns,
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
).set_output(transform="polars")
|
|
103
|
+
|
|
104
|
+
y = df.select(pl.col("total_amount_log")).to_numpy().ravel()
|
|
105
|
+
y_binary = (y > y.mean()).astype(int)
|
|
106
|
+
|
|
107
|
+
X = preprocessor.fit_transform(df)
|
|
108
|
+
Z = X.select("categorical__Zone").to_numpy().astype("int32").ravel()
|
|
109
|
+
|
|
110
|
+
return X.drop("categorical__Zone"), Z, y, y_binary
|
|
@@ -5,7 +5,7 @@ requires = ['setuptools', 'setuptools-scm', 'wheel']
|
|
|
5
5
|
name = "anchorboosting"
|
|
6
6
|
description = "Efficient tree-based nonlinear anchor regression and classification"
|
|
7
7
|
readme = "README.md"
|
|
8
|
-
version = "0.
|
|
8
|
+
version = "0.3.0"
|
|
9
9
|
requires-python = ">=3.9"
|
|
10
10
|
authors = [
|
|
11
11
|
{ name = "Malte Londschien", email = "malte@londschien.ch" }
|
|
@@ -3,19 +3,24 @@ import numpy as np
|
|
|
3
3
|
import pytest
|
|
4
4
|
import scipy
|
|
5
5
|
|
|
6
|
-
from anchorboosting import AnchorBooster
|
|
6
|
+
from anchorboosting.models import AnchorBooster, Proj
|
|
7
7
|
from anchorboosting.simulate import f1, simulate
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@pytest.mark.parametrize("gamma", [1.0, 2.0, 100])
|
|
11
11
|
@pytest.mark.parametrize("objective", ["regression", "binary"])
|
|
12
|
-
|
|
12
|
+
@pytest.mark.parametrize("categorical_z", [True, False])
|
|
13
|
+
@pytest.mark.parametrize("input_dtype", ["polars", "numpy"])
|
|
14
|
+
def test_anchor_boosting_second_order(gamma, objective, categorical_z, input_dtype):
|
|
13
15
|
learning_rate = 0.1
|
|
14
16
|
num_leaves = 5
|
|
15
17
|
n = 200
|
|
16
18
|
num_boost_round = 10
|
|
17
19
|
|
|
18
|
-
x, y, a = simulate(f1, n=n, shift=0, seed=0)
|
|
20
|
+
x, y, a = simulate(f1, n=n, shift=0, seed=0, return_dtype=input_dtype)
|
|
21
|
+
if categorical_z:
|
|
22
|
+
a = np.digitize(a[:, 0], bins=[-1, 0, 1, 2, 3])
|
|
23
|
+
assert np.issubdtype(a.dtype, np.integer)
|
|
19
24
|
|
|
20
25
|
if objective == "binary":
|
|
21
26
|
y = (y > 0).astype(int)
|
|
@@ -32,15 +37,17 @@ def test_anchor_boosting_second_order(gamma, objective):
|
|
|
32
37
|
f = model.predict(x, num_iteration=num_boost_round - 1, raw_score=True)
|
|
33
38
|
|
|
34
39
|
leaves = model.booster_.predict(
|
|
35
|
-
x.to_arrow(),
|
|
40
|
+
x.to_arrow() if hasattr(x, "to_arrow") else x,
|
|
36
41
|
pred_leaf=True,
|
|
37
42
|
start_iteration=num_boost_round - 1,
|
|
38
43
|
num_iteration=1,
|
|
39
44
|
).flatten()
|
|
40
45
|
|
|
46
|
+
proj = Proj(a)
|
|
47
|
+
|
|
41
48
|
def regression_loss(leaf_values):
|
|
42
49
|
residuals = y - f - leaf_values[leaves]
|
|
43
|
-
Pa_residuals =
|
|
50
|
+
Pa_residuals = proj(residuals)
|
|
44
51
|
return np.sum(np.square(residuals)) + (gamma - 1) * residuals.T @ Pa_residuals
|
|
45
52
|
|
|
46
53
|
def probit_loss(leaf_values):
|
|
@@ -49,7 +56,7 @@ def test_anchor_boosting_second_order(gamma, objective):
|
|
|
49
56
|
dp = scipy.stats.norm.pdf(scores)
|
|
50
57
|
losses = -np.log(np.where(y == 1, p, 1 - p))
|
|
51
58
|
dl = np.where(y == 1, -dp / p, dp / (1 - p))
|
|
52
|
-
Pa_dl =
|
|
59
|
+
Pa_dl = proj(dl)
|
|
53
60
|
return np.sum(losses) + (gamma - 1) / 2 * dl.T @ Pa_dl
|
|
54
61
|
|
|
55
62
|
if objective == "regression":
|
|
@@ -138,8 +145,19 @@ def test_anchor_boosting_decreases_loss(gamma, objective):
|
|
|
138
145
|
{"lambda_l2": 0.1},
|
|
139
146
|
],
|
|
140
147
|
)
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
@pytest.mark.parametrize("input_dtype", ["polars", "numpy", "pandas"])
|
|
149
|
+
def test_compare_anchor_boosting_to_lgbm(parameters, input_dtype):
|
|
150
|
+
X, y, a = simulate(f1, shift=0, seed=0, return_dtype=input_dtype)
|
|
151
|
+
|
|
152
|
+
if input_dtype == "polars":
|
|
153
|
+
X_arrow = X.to_arrow()
|
|
154
|
+
categorical_feature = ["x3"]
|
|
155
|
+
elif input_dtype == "pandas":
|
|
156
|
+
X_arrow = X
|
|
157
|
+
categorical_feature = ["x3"]
|
|
158
|
+
elif input_dtype == "numpy":
|
|
159
|
+
X_arrow = X
|
|
160
|
+
categorical_feature = [2]
|
|
143
161
|
|
|
144
162
|
lgbm_model = lgb.train(
|
|
145
163
|
params={
|
|
@@ -147,7 +165,7 @@ def test_compare_anchor_boosting_to_lgbm(parameters):
|
|
|
147
165
|
"objective": "regression",
|
|
148
166
|
**parameters,
|
|
149
167
|
},
|
|
150
|
-
train_set=lgb.Dataset(
|
|
168
|
+
train_set=lgb.Dataset(X_arrow, y, categorical_feature=[2]),
|
|
151
169
|
num_boost_round=50,
|
|
152
170
|
)
|
|
153
171
|
|
|
@@ -157,7 +175,7 @@ def test_compare_anchor_boosting_to_lgbm(parameters):
|
|
|
157
175
|
objective="regression",
|
|
158
176
|
learning_rate=0.1,
|
|
159
177
|
**parameters,
|
|
160
|
-
).fit(X, y, Z=a, categorical_feature=
|
|
178
|
+
).fit(X, y, Z=a, categorical_feature=categorical_feature)
|
|
161
179
|
|
|
162
180
|
anchor_booster_noncat = AnchorBooster(
|
|
163
181
|
gamma=1,
|
|
@@ -167,7 +185,7 @@ def test_compare_anchor_boosting_to_lgbm(parameters):
|
|
|
167
185
|
**parameters,
|
|
168
186
|
).fit(X, y, Z=a)
|
|
169
187
|
|
|
170
|
-
lgbm_pred = lgbm_model.predict(
|
|
188
|
+
lgbm_pred = lgbm_model.predict(X_arrow)
|
|
171
189
|
anchor_booster_pred = anchor_booster.predict(X)
|
|
172
190
|
anchor_booster_noncat_pred = anchor_booster_noncat.predict(X)
|
|
173
191
|
|
|
@@ -175,19 +193,65 @@ def test_compare_anchor_boosting_to_lgbm(parameters):
|
|
|
175
193
|
assert not np.allclose(lgbm_pred, anchor_booster_noncat_pred)
|
|
176
194
|
|
|
177
195
|
|
|
196
|
+
@pytest.mark.parametrize("gamma", [1, 10])
|
|
197
|
+
def test_compare_input_types(gamma):
|
|
198
|
+
X_polars, y_polars, a_polars = simulate(f1, shift=0, seed=0, return_dtype="polars")
|
|
199
|
+
X_numpy, y_numpy, a_numpy = simulate(f1, shift=0, seed=0, return_dtype="numpy")
|
|
200
|
+
X_pandas, y_pandas, a_pandas = simulate(f1, shift=0, seed=0, return_dtype="pandas")
|
|
201
|
+
|
|
202
|
+
model_polars = AnchorBooster(
|
|
203
|
+
gamma=gamma,
|
|
204
|
+
num_boost_round=10,
|
|
205
|
+
objective="regression",
|
|
206
|
+
).fit(X_polars, y_polars, Z=a_polars)
|
|
207
|
+
|
|
208
|
+
model_numpy = AnchorBooster(
|
|
209
|
+
gamma=gamma,
|
|
210
|
+
num_boost_round=10,
|
|
211
|
+
objective="regression",
|
|
212
|
+
).fit(X_numpy, y_numpy, Z=a_numpy)
|
|
213
|
+
|
|
214
|
+
model_pandas = AnchorBooster(
|
|
215
|
+
gamma=gamma,
|
|
216
|
+
num_boost_round=10,
|
|
217
|
+
objective="regression",
|
|
218
|
+
).fit(X_pandas, y_pandas, Z=a_pandas)
|
|
219
|
+
|
|
220
|
+
pred_pandas = model_pandas.predict(X_pandas)
|
|
221
|
+
pred_polars = model_polars.predict(X_polars)
|
|
222
|
+
pred_numpy = model_numpy.predict(X_numpy)
|
|
223
|
+
|
|
224
|
+
np.testing.assert_allclose(pred_polars, pred_numpy, rtol=1e-5)
|
|
225
|
+
np.testing.assert_allclose(pred_polars, pred_pandas, rtol=1e-5)
|
|
226
|
+
|
|
227
|
+
|
|
178
228
|
@pytest.mark.parametrize("objective", ["regression", "binary"])
|
|
179
|
-
def
|
|
229
|
+
def test_categorical_and_onehot_is_the_same(objective):
|
|
180
230
|
X, y, a = simulate(f1, shift=0, seed=0)
|
|
181
231
|
|
|
232
|
+
a = np.digitize(a[:, 0], bins=[-1, 0, 1, 2, 3])
|
|
233
|
+
|
|
182
234
|
if objective == "binary":
|
|
183
235
|
y = (y > 0).astype(int)
|
|
184
236
|
|
|
185
|
-
|
|
237
|
+
anchor_booster1 = AnchorBooster(
|
|
186
238
|
gamma=1,
|
|
187
|
-
num_boost_round=
|
|
239
|
+
num_boost_round=10,
|
|
188
240
|
objective=objective,
|
|
189
241
|
learning_rate=0.1,
|
|
190
242
|
).fit(X, y, Z=a)
|
|
191
243
|
|
|
192
|
-
|
|
193
|
-
|
|
244
|
+
predictions1 = anchor_booster1.predict(X)
|
|
245
|
+
|
|
246
|
+
M = scipy.sparse.csr_matrix(
|
|
247
|
+
(np.ones(len(a), dtype=np.float64), (np.arange(len(a)), a)),
|
|
248
|
+
shape=(len(a), len(np.unique(a))),
|
|
249
|
+
)
|
|
250
|
+
anchor_booster2 = AnchorBooster(
|
|
251
|
+
gamma=1,
|
|
252
|
+
num_boost_round=10,
|
|
253
|
+
objective=objective,
|
|
254
|
+
learning_rate=0.1,
|
|
255
|
+
).fit(X, y, Z=M.toarray())
|
|
256
|
+
predictions2 = anchor_booster2.predict(X)
|
|
257
|
+
np.testing.assert_allclose(predictions1, predictions2, rtol=1e-5)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
import scipy.sparse
|
|
4
|
+
|
|
5
|
+
from anchorboosting.models import Proj
|
|
6
|
+
|
|
7
|
+
cases = [
|
|
8
|
+
(
|
|
9
|
+
np.array([1, 1, 3]),
|
|
10
|
+
np.array([1.0, 2.0, 3.0]),
|
|
11
|
+
np.array([1.5, 1.5, 3.0]),
|
|
12
|
+
),
|
|
13
|
+
(
|
|
14
|
+
np.array([[0.0], [0.0], [1.0]]),
|
|
15
|
+
np.array([1.0, 2.0, 3.0]),
|
|
16
|
+
np.array([0.0, 0.0, 3.0]),
|
|
17
|
+
),
|
|
18
|
+
(
|
|
19
|
+
np.array([[0.0, 1.0], [0.0, 1.0], [1.0, 0.0]]),
|
|
20
|
+
np.array([1.0, 2.0, 3.0]),
|
|
21
|
+
np.array([1.5, 1.5, 3]),
|
|
22
|
+
),
|
|
23
|
+
(
|
|
24
|
+
np.array([2, 2, 1, 7]),
|
|
25
|
+
np.array([1.0, 2.0, 3.0, 1.0]),
|
|
26
|
+
np.array([1.5, 1.5, 3.0, 1.0]),
|
|
27
|
+
),
|
|
28
|
+
(
|
|
29
|
+
np.array([1, 1, 0]),
|
|
30
|
+
np.array([1.0, 0.0, 3.0]),
|
|
31
|
+
np.array([0.5, 0.5, 3]),
|
|
32
|
+
),
|
|
33
|
+
(np.array([0]), np.array([1.0]), np.array([1.0])),
|
|
34
|
+
(np.array([[0.0]]), np.array([0.0]), np.array([0.0])),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("Z, f, result", cases)
|
|
39
|
+
def test_cached_proj_result(Z, f, result):
|
|
40
|
+
np.testing.assert_almost_equal(Proj(Z)(f), result)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("Z, f, _", cases)
|
|
44
|
+
def test_cached_proj_dot_product(Z, f, _):
|
|
45
|
+
np.testing.assert_almost_equal(
|
|
46
|
+
np.dot(Proj(Z)(f).T, f),
|
|
47
|
+
np.dot(Proj(Z)(f).T, Proj(Z)(f)),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_proj_sandwich():
|
|
52
|
+
n = 100
|
|
53
|
+
num_categories = 10
|
|
54
|
+
num_leaves = 5
|
|
55
|
+
rng = np.random.RandomState(0)
|
|
56
|
+
|
|
57
|
+
Z = rng.choice(np.arange(num_categories), size=n, replace=True)
|
|
58
|
+
leaves = rng.choice(np.arange(num_leaves), size=n, replace=True)
|
|
59
|
+
f = rng.normal(size=n)
|
|
60
|
+
|
|
61
|
+
M = scipy.sparse.csr_matrix((f, (np.arange(n), leaves)), (n, num_leaves)).toarray()
|
|
62
|
+
proj = Proj(Z)
|
|
63
|
+
|
|
64
|
+
sandwich1 = proj.sandwich(leaves, num_leaves, f)
|
|
65
|
+
for ldx in range(num_leaves):
|
|
66
|
+
M[:, ldx] = proj(M[:, ldx])
|
|
67
|
+
np.testing.assert_almost_equal(sandwich1, M.T @ M)
|
|
@@ -9,9 +9,10 @@ from anchorboosting.simulate import f1, f2, simulate
|
|
|
9
9
|
@pytest.mark.parametrize("seed", [0, 1])
|
|
10
10
|
@pytest.mark.parametrize("fun", [f1, f2])
|
|
11
11
|
@pytest.mark.parametrize("objective", ["regression", "binary"])
|
|
12
|
-
|
|
12
|
+
@pytest.mark.parametrize("input_dtype", ["numpy", "pandas", "polars"])
|
|
13
|
+
def test_refit(objective, seed, fun, input_dtype):
|
|
13
14
|
|
|
14
|
-
X, y, a = simulate(fun, shift=0, seed=seed)
|
|
15
|
+
X, y, a = simulate(fun, shift=0, seed=seed, return_dtype=input_dtype)
|
|
15
16
|
|
|
16
17
|
if objective == "binary":
|
|
17
18
|
y = (y > 0).astype(int)
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
Changelog
|
|
2
|
-
=========
|
|
3
|
-
|
|
4
|
-
0.2.0 (2025-06-12)
|
|
5
|
-
------------------
|
|
6
|
-
|
|
7
|
-
- Sped up boosting.
|
|
8
|
-
|
|
9
|
-
0.1.0 (2025-05-29)
|
|
10
|
-
------------------
|
|
11
|
-
|
|
12
|
-
- Initial release of the `anchorboosting` package.
|
|
13
|
-
- Implemented `AnchorBooster` class for anchor regression and anchor probit classification.
|
anchorboosting-0.2.0/README.md
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# copy-pasted from https://github.com/mlondschien/ivmodels/blob/09f8bc5a78b3793ae5\
|
|
2
|
-
# 1f0bb7f0331b70a0971ebc/ivmodels/utils.py
|
|
3
|
-
import numpy as np
|
|
4
|
-
import scipy
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def proj(Z, f, n_categories=None, copy=False):
|
|
8
|
-
"""
|
|
9
|
-
Project f onto the subspace spanned by Z.
|
|
10
|
-
|
|
11
|
-
If `Z` is a matrix of floats, uses a copy of `ivmodel`'s `proj:
|
|
12
|
-
https://github.com/mlondschien/ivmodels/blob/main/ivmodels/utils.py#L12
|
|
13
|
-
If `Z` is a 1d array of unsigned integers, assumes that `Z` is categorical with
|
|
14
|
-
values 0, 1, ..., n_categories-1. In this case, the `n_categories` argument must be
|
|
15
|
-
be supplied. The projection is done by averaging the values of f within each
|
|
16
|
-
category. If `Z` is None, returns np.zeros_like(f).
|
|
17
|
-
|
|
18
|
-
Parameters
|
|
19
|
-
----------
|
|
20
|
-
Z: np.ndarray of dimension (n, d_Z) or (n,)
|
|
21
|
-
The `Z` matrix or 1d array of integers. If None, returns np.zeros_like(f).
|
|
22
|
-
*args: np.ndarrays of dimension (n, d_f) or (n,)
|
|
23
|
-
Vectors or matrices to project.
|
|
24
|
-
n_categories: int, optional, default=None
|
|
25
|
-
If not None, then Z is assumed to be categorical with categories 0, 1, ...,
|
|
26
|
-
n_categories-1. The projection is done by averaging the values of f within each
|
|
27
|
-
category.
|
|
28
|
-
copy: bool, default=False
|
|
29
|
-
If False, overwrites *args.
|
|
30
|
-
|
|
31
|
-
Returns
|
|
32
|
-
-------
|
|
33
|
-
np.ndarray of dimension (n, d_f) or (n,)
|
|
34
|
-
Projection of args onto the subspace spanned by Z. Same number of
|
|
35
|
-
outputs as args. Same dimension as args
|
|
36
|
-
"""
|
|
37
|
-
if Z is None:
|
|
38
|
-
return np.zeros_like(f)
|
|
39
|
-
|
|
40
|
-
if len(f.shape) > 2:
|
|
41
|
-
raise ValueError(f"*args should have shapes (n, d_f) or (n,). Got {f.shape}.")
|
|
42
|
-
if f.shape[0] != Z.shape[0]:
|
|
43
|
-
raise ValueError(f"Shape mismatch: Z.shape={Z.shape}, f.shape={f.shape}.")
|
|
44
|
-
|
|
45
|
-
if n_categories is not None:
|
|
46
|
-
f = f.copy() if copy else f
|
|
47
|
-
|
|
48
|
-
if len(Z.shape) != 1 or not np.issubdtype(Z.dtype, np.integer):
|
|
49
|
-
raise ValueError(
|
|
50
|
-
"If n_categories is not None, then Z should be a single column of "
|
|
51
|
-
f"integers. Got shape {Z.shape} and dtype {Z.dtype}."
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
if len(f.shape) == 1:
|
|
55
|
-
means = np.zeros((n_categories,))
|
|
56
|
-
counts = np.zeros(n_categories)
|
|
57
|
-
|
|
58
|
-
# np.add.at(a, indices, b) is equivalent to a[indices] += b
|
|
59
|
-
np.add.at(means, Z, f)
|
|
60
|
-
np.add.at(counts, Z, 1)
|
|
61
|
-
|
|
62
|
-
means[counts > 0] = means[counts > 0] / counts[counts > 0]
|
|
63
|
-
|
|
64
|
-
f[:] = means[Z]
|
|
65
|
-
else:
|
|
66
|
-
means = np.zeros((n_categories, f.shape[1]))
|
|
67
|
-
counts = np.zeros(n_categories)
|
|
68
|
-
|
|
69
|
-
# np.add.at(a, indices, b) is equivalent to a[indices] += b
|
|
70
|
-
np.add.at(means, Z, f)
|
|
71
|
-
np.add.at(counts, Z, 1)
|
|
72
|
-
|
|
73
|
-
means[counts > 0, :] = means[counts > 0, :] / counts[counts > 0, None]
|
|
74
|
-
|
|
75
|
-
f[:] = means[Z, :]
|
|
76
|
-
|
|
77
|
-
return f
|
|
78
|
-
|
|
79
|
-
# The gelsy driver raises in this case - we handle it separately
|
|
80
|
-
if len(f.shape) == 2 and f.shape[1] == 0:
|
|
81
|
-
return np.zeros_like(f)
|
|
82
|
-
|
|
83
|
-
# return np.dot(Z, scipy.linalg.pinv(Z.T @ Z) @ Z.T @ args[0])
|
|
84
|
-
return np.dot(Z, scipy.linalg.lstsq(Z, f, cond=None, lapack_driver="gelsy")[0])
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import numpy as np
|
|
2
|
-
import pytest
|
|
3
|
-
from scipy.linalg import norm
|
|
4
|
-
from sklearn.preprocessing import OneHotEncoder
|
|
5
|
-
|
|
6
|
-
from anchorboosting.utils import proj
|
|
7
|
-
|
|
8
|
-
cases = [
|
|
9
|
-
(
|
|
10
|
-
np.array([[0], [0], [1]]),
|
|
11
|
-
np.array([[1.0], [2.0], [3.0]]),
|
|
12
|
-
np.array([[1.5], [1.5], [3]]),
|
|
13
|
-
),
|
|
14
|
-
(
|
|
15
|
-
np.array([[2], [2], [1], [3]]),
|
|
16
|
-
np.array([[1.0], [2.0], [3.0], [1.0]]),
|
|
17
|
-
np.array([[1.5], [1.5], [3], [1]]),
|
|
18
|
-
),
|
|
19
|
-
(
|
|
20
|
-
np.array([[1], [1], [0]]),
|
|
21
|
-
np.array([[1.0, 2.0], [0.0, 2.0], [3.0, 4.0]]),
|
|
22
|
-
np.array([[0.5, 2.0], [0.5, 2.0], [3.0, 4.0]]),
|
|
23
|
-
),
|
|
24
|
-
(
|
|
25
|
-
np.array([[1], [1], [0]]),
|
|
26
|
-
np.array([1.0, 0.0, 3.0]),
|
|
27
|
-
np.array([0.5, 0.5, 3]),
|
|
28
|
-
),
|
|
29
|
-
(
|
|
30
|
-
np.array([[1], [1], [0]]),
|
|
31
|
-
np.array([[1.0, 2.0], [0.0, 2.0], [3.0, 4.0]]),
|
|
32
|
-
np.array([[0.5, 2.0], [0.5, 2.0], [3.0, 4.0]]),
|
|
33
|
-
),
|
|
34
|
-
(
|
|
35
|
-
np.array([[2], [2], [1]]),
|
|
36
|
-
np.array([[1.0, 2.0], [0.0, 2.0], [3.0, 4.0]]),
|
|
37
|
-
np.array([[0.5, 2.0], [0.5, 2.0], [3.0, 4.0]]),
|
|
38
|
-
),
|
|
39
|
-
(np.array([[0]]), np.array([[1]]), np.array([[1]])),
|
|
40
|
-
]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@pytest.mark.parametrize("anchor, residuals, result", cases)
|
|
44
|
-
@pytest.mark.parametrize("n_categories", [4, None])
|
|
45
|
-
def test_proj_result(n_categories, anchor, residuals, result):
|
|
46
|
-
if n_categories is None:
|
|
47
|
-
if anchor.shape[1] != 1:
|
|
48
|
-
pytest.skip("one_hot only makes sense for 1D anchors")
|
|
49
|
-
encoder = OneHotEncoder(sparse_output=False, drop=None)
|
|
50
|
-
anchor = encoder.fit_transform(anchor).astype(float)
|
|
51
|
-
else:
|
|
52
|
-
anchor = anchor.flatten()
|
|
53
|
-
|
|
54
|
-
np.testing.assert_almost_equal(
|
|
55
|
-
proj(anchor, residuals.copy(), n_categories=n_categories), result
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@pytest.mark.parametrize("anchor, residuals, _", cases)
|
|
60
|
-
def test_proj_dot_product(anchor, residuals, _):
|
|
61
|
-
np.testing.assert_almost_equal(
|
|
62
|
-
np.dot(proj(anchor, residuals).T, residuals),
|
|
63
|
-
np.dot(proj(anchor, residuals).T, proj(anchor, residuals)),
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
@pytest.mark.parametrize("anchor, residuals, result", cases)
|
|
68
|
-
@pytest.mark.parametrize("gamma", [0.1, 1, 2, 10, 100, 1000])
|
|
69
|
-
def test_proj_orthogonal(anchor, residuals, result, gamma):
|
|
70
|
-
np.testing.assert_almost_equal(
|
|
71
|
-
norm(residuals - proj(anchor, residuals)) ** 2
|
|
72
|
-
+ gamma * norm(proj(anchor, residuals)) ** 2,
|
|
73
|
-
norm(residuals) ** 2 + (gamma - 1) * norm(proj(anchor, residuals)) ** 2,
|
|
74
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|