From 3cc36f2b7d328e2d739ee9c5bc68b8c088d86ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?BU=20Fanchen=20=E5=8D=9C=E5=87=A1=E8=BE=B0?= <32029544+bokveizen@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:21:12 +0000 Subject: [PATCH 1/2] Fix independent seeded Euclidean and ball proposal streams --- prefsampling/core/euclidean.py | 26 +++++-- prefsampling/point/ball.py | 33 ++++---- tests/test_euclidean_rng.py | 137 +++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 21 deletions(-) create mode 100644 tests/test_euclidean_rng.py diff --git a/prefsampling/core/euclidean.py b/prefsampling/core/euclidean.py index 8371c72..01450ef 100644 --- a/prefsampling/core/euclidean.py +++ b/prefsampling/core/euclidean.py @@ -201,8 +201,10 @@ def sample_election_positions( Additional keyword arguments passed to the :code:`candidates_positions` sampler when the latter is a Callable. seed : int, default: :code:`None` - Seed for numpy random number generator. Also passed to the point samplers if - a value is provided. + Root seed for independent voter and candidate streams. Point samplers receive + derived integer seeds, or None when no seed is provided. Repeated calls with the + same root seed reproduce the same positions. Seeded coordinates differ from + versions that reused the same seed for both populations. Returns ------- @@ -211,10 +213,18 @@ def sample_election_positions( """ validate_int(num_dimensions, lower_bound=0, value_descr="number of dimensions") - if voters_positions_args is None: - voters_positions_args = dict() - if candidates_positions_args is None: - candidates_positions_args = dict() + voters_positions_args = ( + dict(voters_positions_args) if voters_positions_args is not None else dict() + ) + candidates_positions_args = ( + dict(candidates_positions_args) if candidates_positions_args is not None else dict() + ) + + voters_seed, candidates_seed = None, None + if seed is not None: + voters_stream, candidates_stream = np.random.SeedSequence(seed).spawn(2) + voters_seed = int(voters_stream.generate_state(1, dtype=np.uint64)[0]) + candidates_seed = int(candidates_stream.generate_state(1, dtype=np.uint64)[0]) voters_positions_args["num_dimensions"] = num_dimensions candidates_positions_args["num_dimensions"] = num_dimensions @@ -225,7 +235,7 @@ def sample_election_positions( voters_positions, voters_positions_args, "voters", - seed=seed, + seed=voters_seed, ) cand_pos = _sample_points( num_candidates, @@ -233,6 +243,6 @@ def sample_election_positions( candidates_positions, candidates_positions_args, "candidates", - seed=seed, + seed=candidates_seed, ) return voters_pos, cand_pos diff --git a/prefsampling/point/ball.py b/prefsampling/point/ball.py index e3c1805..d407919 100644 --- a/prefsampling/point/ball.py +++ b/prefsampling/point/ball.py @@ -153,8 +153,10 @@ def ball_resampling( max_numer_resampling : int, default: :code:`1000` The maximum number of resampling. If exceeded, the center point is used. seed : int, default: :code:`None` - Seed for numpy random number generator. We increase the seed by one each time we - resample (to avoid always resampling the same point). + Root seed for a new integer seed on every proposal, including retries. If None, + an inner sampler seed is used as the root when provided; otherwise the inner + sampler's unseeded behavior is preserved. Seeded coordinates differ from + versions that advanced the seed only on rejection. Returns ------- @@ -172,27 +174,32 @@ def ball_resampling( validate_int(num_dimensions, "num_dimensions", 1) center_point = validate_center_point(center_point, num_dimensions) - if seed is not None: - inner_sampler_args["seed"] = seed + inner_sampler_args = dict(inner_sampler_args) + root_seed = seed if seed is not None else inner_sampler_args.get("seed") + seed_sequence = np.random.SeedSequence(root_seed) if root_seed is not None else None - points = [] - for _ in range(num_points): + def sample_point(): + if seed_sequence is not None: + child = seed_sequence.spawn(1)[0] + inner_sampler_args["seed"] = int(child.generate_state(1, dtype=np.uint64)[0]) point = inner_sampler(**inner_sampler_args) if isinstance(point, Iterable): point = np.array(point, dtype=float) else: point = np.array([point], dtype=float) - if len(point) != num_dimensions: + if point.shape != (num_dimensions,): raise ValueError( - f"The inner sampler did not return a point with the suitable number " - f"of dimensions ({num_dimensions} needed but {len(point)} returned)." + f"The inner sampler did not return a point with the suitable shape " + f"({(num_dimensions,)} needed but {point.shape} returned)." ) + return point + + points = [] + for _ in range(num_points): + point = sample_point() num_resampling = 0 while np.linalg.norm(point - center_point) > (width / 2): - if seed is not None: - seed += 1 - inner_sampler_args["seed"] = seed - point = inner_sampler(**inner_sampler_args) + point = sample_point() num_resampling += 1 if num_resampling == max_numer_resampling: warnings.warn( diff --git a/tests/test_euclidean_rng.py b/tests/test_euclidean_rng.py new file mode 100644 index 0000000..acc25ab --- /dev/null +++ b/tests/test_euclidean_rng.py @@ -0,0 +1,137 @@ +from unittest import TestCase + +import numpy as np + +from prefsampling.core.euclidean import EuclideanSpace, sample_election_positions +from prefsampling.point import ball_resampling, cube, gaussian + + +class TestEuclideanStreams(TestCase): + def test_spaces_are_reproducible_and_distinct(self): + for space in EuclideanSpace: + for counts in [(4, 4), (5, 3)]: + for seed in [1, 42, 12345]: + with self.subTest(space=space, counts=counts, seed=seed): + args = (*counts, 2, space, space) + voters, candidates = sample_election_positions(*args, seed=seed) + repeated = sample_election_positions(*args, seed=seed) + np.testing.assert_array_equal(voters, repeated[0]) + np.testing.assert_array_equal(candidates, repeated[1]) + self.assertEqual(len(np.unique(voters, axis=0)), counts[0]) + self.assertEqual(len(np.unique(candidates, axis=0)), counts[1]) + self.assertFalse(np.array_equal(voters[:counts[1]], candidates)) + if space == EuclideanSpace.GAUSSIAN_BALL: + self.assertTrue(np.all(np.linalg.norm(voters, axis=1) <= 0.5)) + self.assertTrue(np.all(np.linalg.norm(candidates, axis=1) <= 0.5)) + + def test_role_streams_do_not_depend_on_other_count_or_explicit_points(self): + for space in EuclideanSpace: + with self.subTest(space=space): + voters, candidates = sample_election_positions(4, 3, 2, space, space, seed=42) + other_voters = sample_election_positions(4, 7, 2, space, space, seed=42)[0] + other_candidates = sample_election_positions(9, 3, 2, space, space, seed=42)[1] + np.testing.assert_array_equal(voters, other_voters) + np.testing.assert_array_equal(candidates, other_candidates) + explicit_voters, sampled_candidates = sample_election_positions( + 4, 3, 2, voters, space, seed=42 + ) + sampled_voters, explicit_candidates = sample_election_positions( + 4, 3, 2, space, candidates, seed=42 + ) + np.testing.assert_array_equal(explicit_voters, voters) + np.testing.assert_array_equal(sampled_candidates, candidates) + np.testing.assert_array_equal(sampled_voters, voters) + np.testing.assert_array_equal(explicit_candidates, candidates) + + def test_custom_sampler_seeds_and_shared_arguments(self): + seeds = [] + + def sampler(num_points, num_dimensions, seed, widths): + seeds.append(seed) + return cube(num_points, num_dimensions, widths=widths, seed=seed) + + shared = {"widths": 2, "seed": 17} + sample_election_positions(4, 3, 2, sampler, sampler, shared, shared, seed=42) + self.assertEqual(shared, {"widths": 2, "seed": 17}) + self.assertTrue(all(type(seed) is int for seed in seeds)) + self.assertNotEqual(seeds[0], seeds[1]) + seeds.clear() + sample_election_positions(4, 3, 2, sampler, sampler, shared, shared) + self.assertEqual(seeds, [None, None]) + self.assertEqual(shared, {"widths": 2, "seed": 17}) + + def test_builtin_arguments_are_preserved(self): + for space in EuclideanSpace: + with self.subTest(space=space): + shared = {"seed": 17} + sample_election_positions(4, 3, 2, space, space, shared, shared, seed=42) + self.assertEqual(shared, {"seed": 17}) + + +class TestBallProposalStreams(TestCase): + def test_each_proposal_gets_a_new_seed(self): + for reject in [False, True]: + with self.subTest(reject=reject): + seeds = [] + + def sampler(seed): + seeds.append(seed) + return [2, 0] if reject and len(seeds) % 2 else [0, 0] + + arguments = {"seed": 99} + result = ball_resampling(4, 2, sampler, arguments, seed=42) + expected_calls = 8 if reject else 4 + self.assertEqual(len(seeds), expected_calls) + self.assertEqual(len(set(seeds)), expected_calls) + self.assertTrue(all(type(seed) is int for seed in seeds)) + first_seeds = seeds.copy() + seeds.clear() + np.testing.assert_array_equal( + result, ball_resampling(4, 2, sampler, arguments, seed=42) + ) + self.assertEqual(seeds, first_seeds) + self.assertEqual(arguments, {"seed": 99}) + + def test_inner_seed_is_used_when_outer_seed_is_none(self): + def sampler(seed): + return gaussian(1, 2, sigmas=0.1, seed=seed)[0] + + arguments = {"seed": 42} + result = ball_resampling(5, 2, sampler, arguments) + np.testing.assert_array_equal(result, ball_resampling(5, 2, sampler, arguments)) + self.assertEqual(len(np.unique(result, axis=0)), 5) + self.assertEqual(arguments, {"seed": 42}) + + def test_unseeded_sampler_does_not_require_seed_keyword(self): + result = ball_resampling(3, 1, lambda: 0.1, {}) + np.testing.assert_array_equal(result, [[0.1], [0.1], [0.1]]) + seeds = [] + + def sampler(seed): + seeds.append(seed) + return [0, 0] + + ball_resampling(3, 2, sampler, {"seed": None}) + self.assertEqual(seeds, [None, None, None]) + + def test_retry_shapes_are_validated(self): + for malformed in [[0], [[0], [0]]]: + with self.subTest(malformed=malformed): + proposals = iter([[2, 0], malformed]) + with self.assertRaises(ValueError): + ball_resampling(1, 2, lambda: next(proposals), {}) + + def test_retry_limit_warns_and_uses_center(self): + seeds = [] + + def sampler(seed): + seeds.append(seed) + return [10, 10] + + with self.assertWarns(RuntimeWarning): + result = ball_resampling( + 2, 2, sampler, {}, center_point=[1, 1], max_numer_resampling=2, seed=42 + ) + np.testing.assert_array_equal(result, [[1, 1], [1, 1]]) + self.assertEqual(len(seeds), 6) + self.assertEqual(len(set(seeds)), 6) From c32d9e0210c4c4e14cb1bf899dec8b01e5c09f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?BU=20Fanchen=20=E5=8D=9C=E5=87=A1=E8=BE=B0?= <32029544+bokveizen@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:37:17 +0000 Subject: [PATCH 2/2] Repair Euclidean distances, shared sampler arguments and validation --- prefsampling/approval/truncated_ordinal.py | 12 +++++++----- prefsampling/core/composition.py | 7 +++---- prefsampling/core/euclidean.py | 2 +- prefsampling/inputvalidators.py | 16 +++------------- prefsampling/ordinal/euclidean.py | 5 ++--- prefsampling/point/gaussian.py | 19 +++++++++++++++++-- tests/test_input_validators.py | 18 ++++++++++++++++++ .../test_approval_truncated_ordinal.py | 14 +++++++++++++- .../ordinal/test_ordinal_euclidean.py | 11 +++++++++++ tests/test_samplers/point/test_gaussian.py | 9 +++++++++ tests/test_samplers/test_composition.py | 9 ++++++++- 11 files changed, 92 insertions(+), 30 deletions(-) diff --git a/prefsampling/approval/truncated_ordinal.py b/prefsampling/approval/truncated_ordinal.py index 4db00fe..b8cc113 100644 --- a/prefsampling/approval/truncated_ordinal.py +++ b/prefsampling/approval/truncated_ordinal.py @@ -93,17 +93,18 @@ def truncated_ordinal( Proceedings of the International Joint Conference on Artificial Intelligence, 2022. """ if isinstance(rel_num_approvals, Iterable): + rel_num_approvals = list(rel_num_approvals) unique_vote_length = False + if len(rel_num_approvals) != num_voters: + raise ValueError( + "If you provide an iterable as rel_num_approvals, there should " + "be exactly one value per voter, no more, no less." + ) if min(rel_num_approvals) < 0 or max(rel_num_approvals) > 1: raise ValueError( "Incorrect value of rel_num_approvals. All values should be in [0, 1]" ) vote_length = [int(r * num_candidates) for r in rel_num_approvals] - if len(vote_length) != num_voters: - raise ValueError( - "If you provide an iterable as rel_num_approvals, there they should " - "be exactly one value per voter, no more, no less." - ) else: unique_vote_length = True if rel_num_approvals < 0 or 1 < rel_num_approvals: @@ -113,6 +114,7 @@ def truncated_ordinal( ) vote_length = int(rel_num_approvals * num_candidates) + ordinal_sampler_parameters = dict(ordinal_sampler_parameters) ordinal_sampler_parameters["num_voters"] = num_voters ordinal_sampler_parameters["num_candidates"] = num_candidates ordinal_sampler_parameters["seed"] = seed diff --git a/prefsampling/core/composition.py b/prefsampling/core/composition.py index 785b98e..a53983a 100644 --- a/prefsampling/core/composition.py +++ b/prefsampling/core/composition.py @@ -186,15 +186,14 @@ def concatenation( " per sampler, no more, no less." ) - for i, params in enumerate(sampler_parameters): - params["num_voters"] = num_voters_per_sampler[i] - params["num_candidates"] = num_candidates - all_votes = [] for num_voters, sampler, params in zip( num_voters_per_sampler, samplers, sampler_parameters ): if num_voters > 0: + params = dict(params) + params["num_voters"] = num_voters + params["num_candidates"] = num_candidates new_votes = sampler(**params) if not isinstance(new_votes, Iterable): raise ValueError( diff --git a/prefsampling/core/euclidean.py b/prefsampling/core/euclidean.py index 01450ef..a9b83bc 100644 --- a/prefsampling/core/euclidean.py +++ b/prefsampling/core/euclidean.py @@ -119,7 +119,7 @@ def _sample_points( f"{positions.shape} while {(num_points, num_dimensions)} was expected " f"(num_{sampled_object_name}, num_dimensions)." ) - return positions + return positions.reshape(num_points, num_dimensions) if not isinstance(positions, Callable): try: diff --git a/prefsampling/inputvalidators.py b/prefsampling/inputvalidators.py index 11764b1..fb04f69 100644 --- a/prefsampling/inputvalidators.py +++ b/prefsampling/inputvalidators.py @@ -16,18 +16,8 @@ def validate_num_voters_candidates(func): @wraps(func) def wrapper(num_voters, num_candidates, *args, **kwargs): - try: - assert int(num_voters) == num_voters - except (ValueError, AssertionError): - raise TypeError("The number of voters needs to be an integer.") - if num_voters < 1: - raise ValueError("The number of voters needs to be at least 1.") - try: - assert int(num_candidates) == num_candidates - except (ValueError, AssertionError): - raise TypeError("The number of candidates needs to be an integer.") - if num_candidates < 1: - raise ValueError("The number of candidates needs to be at least 1.") + validate_int(num_voters, "number of voters", lower_bound=1) + validate_int(num_candidates, "number of candidates", lower_bound=1) return func(num_voters, num_candidates, *args, **kwargs) return wrapper @@ -62,7 +52,7 @@ def validate_int( """ try: int(value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): raise TypeError(f"The {value_descr} needs to be an integer.") if int(value) != value: raise TypeError(f"The {value_descr} needs to be an integer.") diff --git a/prefsampling/ordinal/euclidean.py b/prefsampling/ordinal/euclidean.py index 40ac0e7..2759a16 100644 --- a/prefsampling/ordinal/euclidean.py +++ b/prefsampling/ordinal/euclidean.py @@ -29,7 +29,7 @@ def euclidean( seed: int = None, ) -> list[list[int]] | list[list[list[int]]]: """ - Generates approval votes according to the Euclidean model. + Generates ordinal votes according to the Euclidean model. In this model voters and candidates are assigned random positions in a Euclidean space (positions can also be provided as argument to the function). @@ -296,13 +296,12 @@ def euclidean( seed=seed, ) - dimension = len(voters_pos[0]) votes = [] distances = np.zeros([num_voters, num_candidates], dtype=float) for i in range(num_voters): for j in range(num_candidates): distances[i][j] = np.linalg.norm( - voters_pos[i] - candidates_pos[j], ord=dimension + voters_pos[i] - candidates_pos[j] ) if tie_radius is None: votes.append(list(np.argsort(distances[i]))) diff --git a/prefsampling/point/gaussian.py b/prefsampling/point/gaussian.py index c6f921e..7d83908 100644 --- a/prefsampling/point/gaussian.py +++ b/prefsampling/point/gaussian.py @@ -34,8 +34,9 @@ def gaussian( they are applied to each dimension independently. widths: Iterable[float], default: :code:`None` Maximal widths for the Gaussian distributions. One width per dimension needs to be - provided. When sampling points, if the distance between the point and the center point - is larger than half the width of a dimension, the point is resampled. + provided, all finite and strictly positive. When sampling points, if the distance + between the point and the center point is larger than half the width of a dimension, + the point is resampled. seed : int, default: :code:`None` Seed for numpy random number generator. @@ -65,8 +66,22 @@ def gaussian( raise TypeError( "The 'widths' parameter needs to be an iterable with one value per dimension." ) + if ( + widths.shape != (num_dimensions,) + or not np.all(np.isfinite(widths)) + or np.any(widths <= 0) + ): + raise ValueError( + "Gaussian widths must be finite and strictly positive, one per dimension." + ) center_point = validate_center_point(center_point, num_dimensions) sigmas = validate_width(sigmas, num_dimensions) + if ( + sigmas.shape != (num_dimensions,) + or not np.all(np.isfinite(sigmas)) + or np.any(sigmas < 0) + ): + raise ValueError("Gaussian sigmas must be finite and non-negative, one per dimension.") rng = np.random.default_rng(seed) if widths is None: diff --git a/tests/test_input_validators.py b/tests/test_input_validators.py index 9538bed..2f9bdb3 100644 --- a/tests/test_input_validators.py +++ b/tests/test_input_validators.py @@ -1,9 +1,27 @@ +import subprocess +import sys from unittest import TestCase from prefsampling.inputvalidators import validate_num_voters_candidates, validate_int class TestInputValidators(TestCase): + def test_validation_with_optimized_python(self): + script = """ +from prefsampling.inputvalidators import validate_num_voters_candidates +function = validate_num_voters_candidates(lambda x, y: x + y) +for args in [(1.5, 2), (2, 1.5), (None, 2), (2, float('inf'))]: + try: + function(*args) + except TypeError: + continue + raise SystemExit('Invalid voter/candidate count accepted: ' + str(args)) +""" + result = subprocess.run( + [sys.executable, "-O", "-c", script], capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_num_voters_candidates_validator(self): decorated_function = validate_num_voters_candidates(lambda x, y: x + y) with self.assertRaises(ValueError): diff --git a/tests/test_samplers/approval/test_approval_truncated_ordinal.py b/tests/test_samplers/approval/test_approval_truncated_ordinal.py index 51256eb..9b4c199 100644 --- a/tests/test_samplers/approval/test_approval_truncated_ordinal.py +++ b/tests/test_samplers/approval/test_approval_truncated_ordinal.py @@ -3,7 +3,7 @@ import numpy as np from prefsampling.approval.truncated_ordinal import truncated_ordinal -from prefsampling.ordinal import mallows, urn +from prefsampling.ordinal import identity, mallows, urn from tests.utils import float_parameter_test_values, TestSampler @@ -37,6 +37,18 @@ def truncated_ordinal_several_p(num_voters, num_candidates, seed=None): class TestApprovalTruncatedOrdinal(TestCase): + def test_arguments_are_preserved(self): + parameters = {"num_voters": 99, "num_candidates": 99, "seed": 17} + votes = truncated_ordinal(2, 3, 0.5, identity, parameters, seed=42) + self.assertEqual(votes, [{0}, {0}]) + self.assertEqual(parameters, {"num_voters": 99, "num_candidates": 99, "seed": 17}) + + def test_generator_approval_sizes(self): + votes = truncated_ordinal(2, 3, iter([0.5, 1]), identity, {}) + self.assertEqual(votes, [{0}, {0, 1, 2}]) + with self.assertRaises(ValueError): + truncated_ordinal(2, 3, iter([]), identity, {}) + def test_approval_truncated_ordinal(self): with self.assertRaises(ValueError): truncated_ordinal(4, 5, -0.5, mallows, {"phi": 0.4}) diff --git a/tests/test_samplers/ordinal/test_ordinal_euclidean.py b/tests/test_samplers/ordinal/test_ordinal_euclidean.py index 7f8a216..56cfccb 100644 --- a/tests/test_samplers/ordinal/test_ordinal_euclidean.py +++ b/tests/test_samplers/ordinal/test_ordinal_euclidean.py @@ -86,6 +86,17 @@ def euclidean_positions( class TestOrdinalEuclidean(TestCase): + def test_three_dimensional_euclidean_distance(self): + positions = [[1, 1, 1], [1.5, 0, 0]] + self.assertEqual(euclidean(1, 2, 3, [[0, 0, 0]], positions), [[1, 0]]) + self.assertEqual( + euclidean(1, 2, 3, [[0, 0, 0]], positions, tie_radius=0.5), + [[[1], [0]]], + ) + + def test_flat_one_dimensional_positions(self): + self.assertEqual(euclidean(2, 3, 1, [0, 3], [0.5, 2, 4]), [[0, 1, 2], [1, 2, 0]]) + def test_weak_orders(self): num_candidates = 5 weak_votes = euclidean( diff --git a/tests/test_samplers/point/test_gaussian.py b/tests/test_samplers/point/test_gaussian.py index 642bbbb..dcef100 100644 --- a/tests/test_samplers/point/test_gaussian.py +++ b/tests/test_samplers/point/test_gaussian.py @@ -24,6 +24,15 @@ def random_gaussian_samplers(num_dim): class TestPointGaussian(TestCase): + def test_invalid_truncation_widths(self): + for widths in [[0, 1], [-1, 1], [np.nan, 1], [np.inf, 1], [[1], [1]]]: + with self.subTest(widths=widths), self.assertRaises(ValueError): + gaussian(3, 2, widths=widths, seed=42) + + def test_invalid_sigmas(self): + for sigmas in [[-1, 1], [np.nan, 1], [np.inf, 1], [[1], [1]]]: + with self.subTest(sigmas=sigmas), self.assertRaises(ValueError): + gaussian(3, 2, sigmas=sigmas, seed=42) def test_gaussian(self): with self.assertRaises(TypeError): diff --git a/tests/test_samplers/test_composition.py b/tests/test_samplers/test_composition.py index 49849fa..d5d2274 100644 --- a/tests/test_samplers/test_composition.py +++ b/tests/test_samplers/test_composition.py @@ -1,10 +1,17 @@ from unittest import TestCase from prefsampling.core import mixture, concatenation -from prefsampling.ordinal import single_crossing, single_peaked_walsh +from prefsampling.ordinal import identity, single_crossing, single_peaked_walsh class TestSamplerComposition(TestCase): + def test_concatenation_with_shared_arguments(self): + shared = {"num_voters": 99, "num_candidates": 99} + votes = concatenation([2, 3], 4, [identity, identity], [shared, shared]) + self.assertEqual(len(votes), 5) + self.assertTrue(all(len(vote) == 4 for vote in votes)) + self.assertEqual(shared, {"num_voters": 99, "num_candidates": 99}) + def test_sampler_mixture(self): with self.assertRaises(ValueError): mixture(