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.
Files changed (39) hide show
  1. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/.github/workflows/ci.yml +22 -0
  2. anchorboosting-0.3.0/CHANGELOG.rst +24 -0
  3. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/PKG-INFO +11 -4
  4. anchorboosting-0.3.0/README.md +14 -0
  5. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting/models.py +107 -20
  6. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting/simulate.py +10 -1
  7. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/PKG-INFO +11 -4
  8. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/SOURCES.txt +8 -3
  9. anchorboosting-0.3.0/asv.conf.json +18 -0
  10. anchorboosting-0.3.0/benchmarks/__init__.py +0 -0
  11. anchorboosting-0.3.0/benchmarks/benchmark.py +77 -0
  12. anchorboosting-0.3.0/benchmarks/data/.gitignore +1 -0
  13. anchorboosting-0.3.0/benchmarks/environment.yml +14 -0
  14. anchorboosting-0.3.0/benchmarks/load.py +110 -0
  15. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/index.rst +1 -1
  16. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/environment.yml +1 -1
  17. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/pyproject.toml +1 -1
  18. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/tests/test_anchor_boosting.py +80 -16
  19. anchorboosting-0.3.0/tests/test_proj.py +67 -0
  20. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/tests/test_refit.py +3 -2
  21. anchorboosting-0.2.0/CHANGELOG.rst +0 -13
  22. anchorboosting-0.2.0/README.md +0 -7
  23. anchorboosting-0.2.0/anchorboosting/utils.py +0 -84
  24. anchorboosting-0.2.0/tests/test_utils.py +0 -74
  25. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/.github/workflows/build.yaml +0 -0
  26. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/.gitignore +0 -0
  27. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/.pre-commit-config.yaml +0 -0
  28. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/LICENSE +0 -0
  29. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting/__init__.py +0 -0
  30. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/dependency_links.txt +0 -0
  31. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/requires.txt +0 -0
  32. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/anchorboosting.egg-info/top_level.txt +0 -0
  33. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/Makefile +0 -0
  34. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/bib.bib +0 -0
  35. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/changelog.rst +0 -0
  36. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/docs/conf.py +0 -0
  37. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/readthedocs.yaml +0 -0
  38. {anchorboosting-0.2.0 → anchorboosting-0.3.0}/setup.cfg +0 -0
  39. {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.2.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 [1]
18
+ Efficient tree-based nonlinear anchor regression and classification for Python.
19
19
 
20
- ## References
20
+ If you use this, please consider citing
21
21
 
22
- [1] Londschien, M., M. Burger, G. Rätsch, P. Bühlmann (2025). Working paper.
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
- Q, _ = np.linalg.qr(Z, mode="reduced") # P_Z f = Q @ (Q^T @ f)
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 = Q @ (Q.T @ r)
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
- # Mdr^T P_Z Mdr = (Mdr^T Q) @ (Mdr^T Q)^T
352
- # One could also compute this using bincount, but it appears this
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
- for idx in range(self.num_boost_round):
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.2.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 [1]
18
+ Efficient tree-based nonlinear anchor regression and classification for Python.
19
19
 
20
- ## References
20
+ If you use this, please consider citing
21
21
 
22
- [1] Londschien, M., M. Burger, G. Rätsch, P. Bühlmann (2025). Working paper.
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/test_refit.py
28
- tests/test_utils.py
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
@@ -30,7 +30,7 @@ API Reference
30
30
  :maxdepth: 1
31
31
  :caption: Other
32
32
 
33
- GitHub <https://github.com/mlondschien/ivmodels>
33
+ GitHub <https://github.com/mlondschien/anchorboosting>
34
34
  changelog
35
35
 
36
36
 
@@ -9,9 +9,9 @@ dependencies:
9
9
  - lightgbm >=4.0.0
10
10
  - setuptools # build
11
11
  - pip # build
12
- - scikit-learn # tests
13
12
  - pytest-cov # tests
14
13
  - pytest # tests
14
+ - pandas # tests
15
15
  - polars # tests
16
16
  - pyarrow # tests
17
17
  - sphinx # docs
@@ -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.2.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
- def test_anchor_boosting_second_order(gamma, objective):
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 = a @ np.linalg.solve(a.T @ a, a.T @ 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 = a @ np.linalg.solve(a.T @ a, a.T @ 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
- def test_compare_anchor_boosting_to_lgbm(parameters):
142
- X, y, a = simulate(f1, shift=0, seed=0)
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(X.to_arrow(), y, categorical_feature=[2]),
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=["x3"])
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(X.to_arrow())
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 test_anchor_booster_init_score(objective):
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
- anchor_booster = AnchorBooster(
237
+ anchor_booster1 = AnchorBooster(
186
238
  gamma=1,
187
- num_boost_round=0,
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
- predictions = anchor_booster.predict(X)
193
- assert np.allclose(predictions, np.ones_like(y) * y.mean())
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
- def test_refit(objective, seed, fun):
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.
@@ -1,7 +0,0 @@
1
- # Anchor Boosting
2
-
3
- Efficient tree-based nonlinear anchor regression and classification for Python [1]
4
-
5
- ## References
6
-
7
- [1] Londschien, M., M. Burger, G. Rätsch, P. Bühlmann (2025). Working paper.
@@ -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