Skip to content

Commit 37eea51

Browse files
committed
Fixes sample weight with sklearn 1.9
- Stores sample_weight before sorting - Updates _get_n_samples_bootstrap and _generate_sample_indices. - Adds unit tests.
1 parent 9e8240b commit 37eea51

2 files changed

Lines changed: 156 additions & 12 deletions

File tree

quantile_forest/_quantile_forest.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,16 @@ def fit(self, X, y, sample_weight=None, sparse_pickle=False):
174174
for i in range(y.shape[1]):
175175
y_sorted[:, i] = y[sorter[:, i], i]
176176

177+
self.sample_weight_ = sample_weight
178+
177179
if sample_weight is not None:
178-
sample_weight = np.asarray(sample_weight)[sorter]
180+
sample_weight_sorted = np.asarray(sample_weight)[sorter]
181+
else:
182+
sample_weight_sorted = None
179183

180184
# Get map of tree leaf nodes to training indices.
181185
y_train_leaves = self._get_y_train_leaves(
182-
X, y_sorted, sorter=sorter, sample_weight=sample_weight
186+
X, y_sorted, sorter=sorter, sample_weight=sample_weight_sorted
183187
)
184188

185189
# Get map of tree leaf nodes to target value bounds (for monotonicity constraints).
@@ -352,17 +356,28 @@ def _get_y_train_leaves(self, X, y, sorter=None, sample_weight=None):
352356
X_leaves = self.apply(X)
353357

354358
if self.bootstrap:
355-
n_samples_bootstrap = _get_n_samples_bootstrap(n_samples, self.max_samples)
359+
params = {
360+
"n_samples": n_samples,
361+
"max_samples": self.max_samples,
362+
}
363+
if sklearn_version >= parse_version("1.9.0"):
364+
params["sample_weight"] = self.sample_weight_
365+
n_samples_bootstrap = _get_n_samples_bootstrap(**params)
356366

357367
shape = (n_samples if not self.bootstrap else n_samples_bootstrap, self.n_estimators)
358368
bootstrap_indices = np.empty(shape, dtype=np.int64)
359369
X_leaves_bootstrap = np.empty(shape, dtype=np.int64)
360370
for i, estimator in enumerate(self.estimators_):
361371
# Get bootstrap indices.
362372
if self.bootstrap:
363-
bootstrap_indices[:, i] = _generate_sample_indices(
364-
estimator.random_state, n_samples, n_samples_bootstrap
365-
)
373+
params = {
374+
"random_state": estimator.random_state,
375+
"n_samples": n_samples,
376+
"n_samples_bootstrap": n_samples_bootstrap,
377+
}
378+
if sklearn_version >= parse_version("1.9.0"):
379+
params["sample_weight"] = self.sample_weight_
380+
bootstrap_indices[:, i] = _generate_sample_indices(**params)
366381
else:
367382
bootstrap_indices[:, i] = np.arange(n_samples)
368383

@@ -534,7 +549,9 @@ def _oob_samples(self, X, indices=None, duplicates=None):
534549
for i, estimator in enumerate(self.estimators_):
535550
# Get the indices excluded from the bootstrapping process.
536551
if self.unsampled_indices_ is None:
537-
unsampled_indices = self._get_unsampled_indices(estimator, duplicates=duplicates)
552+
unsampled_indices = self._get_unsampled_indices(
553+
estimator, duplicates=duplicates, sample_weight=self.sample_weight_
554+
)
538555
else:
539556
# Avoid generating unsampled indices if they are precomputed.
540557
unsampled_indices = np.asarray(self.unsampled_indices_[i])
@@ -564,7 +581,7 @@ def _oob_samples(self, X, indices=None, duplicates=None):
564581

565582
return X_leaves, X_indices
566583

567-
def _get_unsampled_indices(self, estimator, duplicates=None):
584+
def _get_unsampled_indices(self, estimator, duplicates=None, sample_weight=None):
568585
"""Get the unsampled indices for a base estimator.
569586
570587
Parameters
@@ -575,6 +592,13 @@ def _get_unsampled_indices(self, estimator, duplicates=None):
575592
duplicates : list of lists, default=None
576593
List of sets of functionally identical indices.
577594
595+
sample_weight : array-like of shape (n_samples, n_outputs), \
596+
default=None
597+
Sample weights. If None, then samples are equally weighted. Splits
598+
that would create child nodes with net zero or negative weight are
599+
ignored while searching for a split in each node. For each output,
600+
the ordering of the weights correspond to the sorted samples.
601+
578602
Returns
579603
-------
580604
unsampled_indices : array of shape (n_unsampled,)
@@ -584,9 +608,12 @@ def _get_unsampled_indices(self, estimator, duplicates=None):
584608
warn("Unsampled indices only exist if bootstrap=True.")
585609
return np.array([])
586610
n_train_samples = self.n_train_samples_
587-
n_samples_bootstrap = _get_n_samples_bootstrap(n_train_samples, self.max_samples)
611+
sample_weight = self.sample_weight_
612+
params = {} if sklearn_version < parse_version("1.9") else {"sample_weight": sample_weight}
613+
n_samples_bootstrap = _get_n_samples_bootstrap(n_train_samples, self.max_samples, **params)
614+
params = {} if sklearn_version < parse_version("1.9") else {"sample_weight": sample_weight}
588615
sample_indices = _generate_sample_indices(
589-
estimator.random_state, n_train_samples, n_samples_bootstrap
616+
estimator.random_state, n_train_samples, n_samples_bootstrap, **params
590617
)
591618
unsampled_indices = generate_unsampled_indices(
592619
sample_indices,

quantile_forest/tests/test_quantile_forest.py

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import numpy as np
99
import pytest
10+
import sklearn
1011
from scipy.stats import percentileofscore
1112
from sklearn import datasets
1213
from sklearn.ensemble import ExtraTreesRegressor, RandomForestRegressor
@@ -19,6 +20,7 @@
1920
assert_array_almost_equal,
2021
assert_array_equal,
2122
)
23+
from sklearn.utils.fixes import parse_version
2224
from sklearn.utils.validation import check_is_fitted
2325

2426
from quantile_forest import ExtraTreesQuantileRegressor, RandomForestQuantileRegressor
@@ -30,6 +32,8 @@
3032
calc_weighted_quantile,
3133
)
3234

35+
sklearn_version = parse_version(sklearn.__version__)
36+
3337
np.random.seed(0)
3438

3539
FOREST_REGRESSORS: dict[str, Any] = {
@@ -765,8 +769,10 @@ def check_proximity_counts(name):
765769
n_samples = len(X)
766770
proximities = est.proximity_counts(X)
767771
X_leaves = est.apply(X)
772+
params = (n_samples, n_samples)
773+
params = params if sklearn_version < parse_version("1.9") else params + (None,)
768774
bootstrap_indices = np.array(
769-
[_generate_sample_indices(e.random_state, n_samples, n_samples) for e in est.estimators_]
775+
[_generate_sample_indices(e.random_state, *params) for e in est.estimators_]
770776
)
771777
for train_idx, train_idx_prox in enumerate(proximities):
772778
for proximity_idx, proximity_count in train_idx_prox:
@@ -1218,8 +1224,10 @@ def check_proximity_counts_oob(name):
12181224
# Check that OOB proximity counts match OOB bootstrap counts.
12191225
X_leaves = est.apply(X)
12201226
n_samples = len(X)
1227+
params = (n_samples, n_samples)
1228+
params = params if sklearn_version < parse_version("1.9") else params + (None,)
12211229
bootstrap_indices = np.array(
1222-
[_generate_sample_indices(e.random_state, n_samples, n_samples) for e in est.estimators_]
1230+
[_generate_sample_indices(e.random_state, *params) for e in est.estimators_]
12231231
)
12241232
for train_idx, train_idx_prox in enumerate(proximities):
12251233
for proximity_idx, proximity_count in train_idx_prox:
@@ -1384,6 +1392,115 @@ def test_serialization(name, sparse_pickle, monotonic_cst, multi_target):
13841392
check_serialization(name, sparse_pickle, monotonic_cst, multi_target)
13851393

13861394

1395+
@pytest.mark.skipif(
1396+
sklearn_version < parse_version("1.9"),
1397+
reason="sample_weight in bootstrap functions requires scikit-learn >= 1.9",
1398+
)
1399+
@pytest.mark.parametrize("name", FOREST_REGRESSORS)
1400+
def test_sample_weight_stored_on_fit(name):
1401+
"""Check that sample_weight is stored during fit."""
1402+
X, y = datasets.make_regression(n_samples=100, n_features=4, random_state=0)
1403+
sample_weight = np.random.default_rng(0).uniform(0.1, 2.0, size=len(y))
1404+
1405+
ForestRegressor = FOREST_REGRESSORS[name]
1406+
1407+
est = ForestRegressor(n_estimators=5, random_state=0)
1408+
est.fit(X, y, sample_weight=sample_weight)
1409+
assert est.sample_weight_ is not None
1410+
assert est.sample_weight_.shape[0] == len(y)
1411+
1412+
est_no_weight = ForestRegressor(n_estimators=5, random_state=0)
1413+
est_no_weight.fit(X, y)
1414+
assert est_no_weight.sample_weight_ is None
1415+
1416+
1417+
@pytest.mark.skipif(
1418+
sklearn_version < parse_version("1.9"),
1419+
reason="sample_weight in bootstrap functions requires scikit-learn >= 1.9",
1420+
)
1421+
@pytest.mark.parametrize("name", FOREST_REGRESSORS)
1422+
def test_sample_weight_predict(name):
1423+
"""Check that predictions work with sample_weight on sklearn 1.9+."""
1424+
X, y = datasets.make_regression(n_samples=200, n_features=4, random_state=0)
1425+
sample_weight = np.random.default_rng(0).uniform(0.1, 2.0, size=len(y))
1426+
1427+
ForestRegressor = FOREST_REGRESSORS[name]
1428+
1429+
est = ForestRegressor(n_estimators=10, bootstrap=True, random_state=0)
1430+
est.fit(X, y, sample_weight=sample_weight)
1431+
1432+
y_pred = est.predict(X, quantiles=[0.25, 0.5, 0.75])
1433+
assert y_pred.shape == (len(X), 3)
1434+
assert not np.isnan(y_pred).any()
1435+
1436+
# Predictions with sample_weight should differ from uniform weights.
1437+
est_uniform = ForestRegressor(n_estimators=10, bootstrap=True, random_state=0)
1438+
est_uniform.fit(X, y)
1439+
y_pred_uniform = est_uniform.predict(X, quantiles=[0.25, 0.5, 0.75])
1440+
assert not np.allclose(y_pred, y_pred_uniform)
1441+
1442+
1443+
@pytest.mark.skipif(
1444+
sklearn_version < parse_version("1.9"),
1445+
reason="sample_weight in bootstrap functions requires scikit-learn >= 1.9",
1446+
)
1447+
@pytest.mark.parametrize("name", FOREST_REGRESSORS)
1448+
def test_sample_weight_oob(name):
1449+
"""Check that OOB predictions work with sample_weight on sklearn 1.9+."""
1450+
X, y = datasets.make_regression(n_samples=500, n_features=8, noise=0.1, random_state=0)
1451+
sample_weight = np.random.default_rng(0).uniform(0.1, 2.0, size=len(y))
1452+
1453+
ForestRegressor = FOREST_REGRESSORS[name]
1454+
1455+
est = ForestRegressor(n_estimators=20, bootstrap=True, oob_score=True, random_state=0)
1456+
est.fit(X, y, sample_weight=sample_weight)
1457+
1458+
y_pred = est.predict(X, quantiles=[0.2, 0.5, 0.8], oob_score=True)
1459+
assert y_pred.shape == (len(X), 3)
1460+
1461+
1462+
@pytest.mark.skipif(
1463+
sklearn_version < parse_version("1.9"),
1464+
reason="sample_weight in bootstrap functions requires scikit-learn >= 1.9",
1465+
)
1466+
@pytest.mark.parametrize("name", FOREST_REGRESSORS)
1467+
def test_sample_weight_unsampled_indices(name):
1468+
"""Check that _get_unsampled_indices uses sample_weight on sklearn 1.9+."""
1469+
X, y = datasets.make_regression(n_samples=200, n_features=4, random_state=0)
1470+
sample_weight = np.random.default_rng(0).uniform(0.1, 2.0, size=len(y))
1471+
1472+
ForestRegressor = FOREST_REGRESSORS[name]
1473+
1474+
est = ForestRegressor(n_estimators=5, bootstrap=True, random_state=0)
1475+
est.fit(X, y, sample_weight=sample_weight)
1476+
1477+
with warnings.catch_warnings():
1478+
warnings.simplefilter("ignore", UserWarning)
1479+
unsampled = est._get_unsampled_indices(est.estimators_[0])
1480+
1481+
assert len(unsampled) > 0
1482+
assert all(idx < len(y) for idx in unsampled)
1483+
1484+
1485+
@pytest.mark.skipif(
1486+
sklearn_version < parse_version("1.9"),
1487+
reason="sample_weight in bootstrap functions requires scikit-learn >= 1.9",
1488+
)
1489+
@pytest.mark.parametrize("name", FOREST_REGRESSORS)
1490+
def test_sample_weight_proximity_counts(name):
1491+
"""Check that proximity_counts works with sample_weight on sklearn 1.9+."""
1492+
X, y = datasets.make_regression(n_samples=100, n_features=4, random_state=0)
1493+
sample_weight = np.random.default_rng(0).uniform(0.1, 2.0, size=len(y))
1494+
1495+
ForestRegressor = FOREST_REGRESSORS[name]
1496+
1497+
est = ForestRegressor(n_estimators=5, bootstrap=True, random_state=0)
1498+
est.fit(X, y, sample_weight=sample_weight)
1499+
1500+
proximities = est.proximity_counts(X)
1501+
assert len(proximities) == len(X)
1502+
1503+
13871504
def test_calc_quantile():
13881505
"""Check quantile calculations."""
13891506
quantiles = [0.0, 0.25, 0.5, 0.75, 1.0]

0 commit comments

Comments
 (0)