Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions prefsampling/approval/truncated_ordinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
7 changes: 3 additions & 4 deletions prefsampling/core/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 19 additions & 9 deletions prefsampling/core/euclidean.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
-------
Expand All @@ -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
Expand All @@ -225,14 +235,14 @@ def sample_election_positions(
voters_positions,
voters_positions_args,
"voters",
seed=seed,
seed=voters_seed,
)
cand_pos = _sample_points(
num_candidates,
num_dimensions,
candidates_positions,
candidates_positions_args,
"candidates",
seed=seed,
seed=candidates_seed,
)
return voters_pos, cand_pos
16 changes: 3 additions & 13 deletions prefsampling/inputvalidators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand Down
5 changes: 2 additions & 3 deletions prefsampling/ordinal/euclidean.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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])))
Expand Down
33 changes: 20 additions & 13 deletions prefsampling/point/ball.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------
Expand All @@ -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(
Expand Down
19 changes: 17 additions & 2 deletions prefsampling/point/gaussian.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
137 changes: 137 additions & 0 deletions tests/test_euclidean_rng.py
Original file line number Diff line number Diff line change
@@ -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)
Loading