Repository: scikit-learn/scikit-learn
File: sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py (Python)

Code before:
    mapper_2 = _BinMapper(n_bins=min(256, n_distinct * 3) + 1)
    binned_2 = mapper_2.fit_transform(data)

    assert_allclose(mapper_1.bin_thresholds_[0], mapper_2.bin_thresholds_[0])
    assert_array_equal(binned_1, binned_2)


@pytest.mark.parametrize("n_bins", [50, None])
def test_binmapper_weighted_vs_repeated_equivalence(global_random_seed, n_bins):
    rng = np.random.RandomState(global_random_seed)

    n_samples = 200
    X = rng.randn(n_samples, 3)
    if n_bins is None:
        n_bins = np.unique(X[:, rng.randint(3)]).shape[0] + rng.randint(5)

    sw = rng.randint(0, 5, size=n_samples)
    X_repeated = np.repeat(X, sw, axis=0)

    est_weighted = _BinMapper(n_bins=n_bins).fit(X, sample_weight=sw)
    est_repeated = _BinMapper(n_bins=n_bins).fit(X_repeated, sample_weight=None)
    assert_allclose(est_weighted.bin_thresholds_, est_repeated.bin_thresholds_)

    X_trans_weighted = est_weighted.transform(X)
    X_trans_repeated = est_repeated.transform(X)
    assert_array_equal(X_trans_weighted, X_trans_repeated)


@pytest.mark.parametrize(
    "max_bins, scale, offset",
    [
        (3, 2, -1),
        (42, 1, 0),
        (255, 0.3, 42),
    ],
)
def test_bin_mapper_identity_small(max_bins, scale, offset):
    data = np.arange(max_bins).reshape(-1, 1) * scale + offset
    # max_bins is the number of bins for non-missing values
    n_bins = max_bins + 1
    binned = _BinMapper(n_bins=n_bins).fit_transform(data)
    assert_array_equal(binned, np.arange(max_bins).reshape(-1, 1))


@pytest.mark.parametrize(
    "max_bins_small, max_bins_large",
    [
        (2, 2),
        (3, 3),
        (4, 4),
        (42, 42),

Review comment (bug):
I found an occurrence of a test failure when `global_random_seed=63` when running:

```
SKLEARN_TESTS_GLOBAL_RANDOM_SEED="all" pytest -vl sklearn/ensemble/_hist_gradient_boosting/tests -k test_binmapper_weighted_vs_repeated_equivalence
```

Can you reproduce?

Here is the error message I get:

<details>

```python
=========================================================================== test session starts ============================================================================
platform darwin -- Python 3.13.3, pytest-8.4.0, pluggy-1.6.0 -- /Users/ogrisel/miniforge3/envs/dev/bin/python3.13
cachedir: .pytest_cache
rootdir: /Users/ogrisel/code/scikit-learn
configfile: pyproject.toml
plugins: xdist-3.7.0, run-parallel-0.4.3, anyio-4.9.0
collected 451 items / 449 deselected / 2 selected                                                                                                                          
Collected 0 items to run in parallel

sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py::test_binmapper_weighted_vs_repeated_equivalence[63-50] FAILED                                        [ 50%]
sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py::test_binmapper_weighted_vs_repeated_equivalence[63-None] PASSED                                      [100%]

================================================================================= FAILURES =================================================================================
__________________________________________________________ test_binmapper_weighted_vs_repeated_equivalence[63-50] __________________________________________________________

global_random_seed = 63, n_bins = 50

    @pytest.mark.parametrize("n_bins", [50, None])
    def test_binmapper_weighted_vs_repeated_equivalence(global_random_seed, n_bins):
        rng = np.random.RandomState(global_random_seed)
    
        n_samples = 200
        X = rng.randn(n_samples, 3)
        if n_bins is None:
            n_bins = np.unique(X[:, rng.randint(3)]).shape[0] + rng.randint(5) + 1
    
        sw = rng.randint(0, 5, size=n_samples)
        X_repeated = np.repeat(X, sw, axis=0)
    
        est_weighted = _BinMapper(n_bins=n_bins).fit(X, sample_weight=sw)
        est_repeated = _BinMapper(n_bins=n_bins).fit(X_repeated, sample_weight=None)
>       assert_allclose(est_weighted.bin_thresholds_, est_repeated.bin_thresholds_)
E       AssertionError: 
E       Not equal to tolerance rtol=1e-07, atol=0
E       
E       Mismatched elements: 6 / 144 (4.17%)
E       Max absolute difference among violations: 0.022745
E       Max relative difference among violations: 0.02664642
E        ACTUAL: array([[-1.712595, -1.285816, -1.226962, -1.009446, -0.960101, -0.882069,
E               -0.83596 , -0.69571 , -0.675828, -0.603487, -0.399731, -0.361047,
E               -0.340069, -0.237937, -0.207451, -0.087749, -0.075609,  0.032785,...
E        DESIRED: array([[-1.712595, -1.285816, -1.226962, -1.009446, -0.960101, -0.882069,
E               -0.83596 , -0.69571 , -0.675828, -0.593016, -0.399731, -0.361047,
E               -0.340069, -0.237937, -0.207451, -0.087749, -0.075609,  0.032785,...

X          = array([[-2.13897865e+00,  1.11206124e+00,  3.58015526e-02],
       [-6.30157742e-01,  3.54051160e-05, -1.20895742e+00]...      [-3.99730932e-01,  9.48906177e-01,  2.40632339e-01],
       [-3.83554471e-02, -1.19674458e+00, -1.27746484e+00]])
X_repeated = array([[-2.13897865,  1.11206124,  0.03580155],
       [ 0.29162807, -1.65065515, -1.50712909],
       [ 0.29162807, -...3234],
       [-0.39973093,  0.94890618,  0.24063234],
       [-0.39973093,  0.94890618,  0.24063234]], shape=(392, 3))
est_repeated = _BinMapper(n_bins=50)
est_weighted = _BinMapper(n_bins=50)
global_random_seed = 63
n_bins     = 50
n_samples  = 200
rng        = RandomState(MT19937) at 0x13D5D3240
sw         = array([1, 0, 4, 2, 4, 0, 4, 0, 4, 0, 4, 0, 2, 1, 3, 4, 4, 3, 0, 0, 3, 1,
       0, 4, 2, 2, 0, 1, 2, 2, 0, 4, 3, 4, 0,...4, 4, 1, 3, 2, 4, 2, 2, 0, 4, 0,
       2, 1, 0, 2, 2, 0, 0, 4, 3, 1, 4, 1, 0, 0, 0, 2, 4, 4, 3, 3, 2, 2,
       4, 0])

sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py:215: AssertionError
-------------------------------------------------------------------------- Captured stdout setup ---------------------------------------------------------------------------
I: Seeding RNGs with 819376607
========================================================================= short test summary info ==========================================================================
FAILED sklearn/ensemble/_hist_gradient_boosting/tests/test_binning.py::test_binmapper_weighted_vs_repeated_equivalence[63-50] - AssertionError: 
=============================================================== 1 failed, 1 passed, 449 deselected in 0.46s ================================================================
```

</details>

So the problem happens when `max_bins=50 < np.unique(X[sw != 0], axis=0).shape[0]=155`, which means that it's not related to the branch of the code that uses midpoints as bin edges but rather uses the branch of the code that uses the `_averaged_weighted_percentile`. I think we try to extract a minimal reproducer to understand the root cause of this failure case.

Code after:
def test_bin_mapper_repeated_values_invariance(n_distinct):
    rng = np.random.RandomState(42)
    distinct_values = rng.normal(size=n_distinct)
    assert len(np.unique(distinct_values)) == n_distinct

    repeated_indices = rng.randint(low=0, high=n_distinct, size=1000)
    data = distinct_values[repeated_indices]
    rng.shuffle(data)
    assert_array_equal(np.unique(data), np.sort(distinct_values))

    data = data.reshape(-1, 1)

    mapper_1 = _BinMapper(n_bins=n_distinct + 1)
    binned_1 = mapper_1.fit_transform(data)
    assert_array_equal(np.unique(binned_1[:, 0]), np.arange(n_distinct))

    # Adding more bins to the mapper yields the same results (same thresholds)
    mapper_2 = _BinMapper(n_bins=min(256, n_distinct * 3) + 1)
    binned_2 = mapper_2.fit_transform(data)

    assert_allclose(mapper_1.bin_thresholds_[0], mapper_2.bin_thresholds_[0])
    assert_array_equal(binned_1, binned_2)


@pytest.mark.parametrize("n_bins", [50, 250])
def test_binmapper_weighted_vs_repeated_equivalence(global_random_seed, n_bins):
    rng = np.random.RandomState(global_random_seed)

    n_samples = 200
    X = rng.randn(n_samples, 3)
    sw = rng.randint(0, 5, size=n_samples)
    X_repeated = np.repeat(X, sw, axis=0)

    est_weighted = _BinMapper(n_bins=n_bins).fit(X, sample_weight=sw)
    est_repeated = _BinMapper(n_bins=n_bins).fit(X_repeated, sample_weight=None)
    assert_allclose(est_weighted.bin_thresholds_, est_repeated.bin_thresholds_)

    X_trans_weighted = est_weighted.transform(X)
    X_trans_repeated = est_repeated.transform(X)
    assert_array_equal(X_trans_weighted, X_trans_repeated)


# Note: we use a small number of RNG seeds to check that the tests is not seed
# dependent while keeping the statistical test valid. If we had used the
# global_random_seed fixture, it would have been expected to get some wrong
# rejections of the null hypothesis because of the large number of
# tests run by the fixture.
@pytest.mark.parametrize("seed", [0, 1, 42])
@pytest.mark.parametrize("n_bins", [3, 5])
def test_subsampled_weighted_vs_repeated_equivalence(seed, n_bins):
    rng = np.random.RandomState(seed)