diff --git a/.circleci/config.yml b/.circleci/config.yml index aff33822..0599fe57 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,6 +31,7 @@ jobs: conda config --set always_yes yes --set auto_update_conda false # install dependencies in conda env + conda install python=3.8 conda install pytest pytest-cov conda install codecov python setup.py install diff --git a/gerrychain/__init__.py b/gerrychain/__init__.py index b726625a..0f59c49d 100644 --- a/gerrychain/__init__.py +++ b/gerrychain/__init__.py @@ -1,7 +1,7 @@ from ._version import get_versions from .chain import MarkovChain from .graph import Graph -from .partition import GeographicPartition, Partition +from .partition import GeographicPartition, Partition, MultiMemberPartition from .updaters.election import Election __version__ = get_versions()['version'] diff --git a/gerrychain/constraints/__init__.py b/gerrychain/constraints/__init__.py index cf505496..a9fa7151 100644 --- a/gerrychain/constraints/__init__.py +++ b/gerrychain/constraints/__init__.py @@ -48,7 +48,8 @@ single_flip_contiguous) from .validity import (Validator, districts_within_tolerance, no_vanishing_districts, refuse_new_splits, - within_percent_of_ideal_population) + within_percent_of_ideal_population, + within_percent_of_ideal_population_per_representative) __all__ = ["LowerBound", "SelfConfiguringLowerBound", "SelfConfiguringUpperBound", "UpperBound", @@ -58,4 +59,5 @@ "no_worse_L_minus_1_polsby_popper", "contiguous", "contiguous_bfs", "no_more_discontiguous", "single_flip_contiguous", "Validator", "districts_within_tolerance", "no_vanishing_districts", - "refuse_new_splits", "within_percent_of_ideal_population", "Bounds"] + "refuse_new_splits", "within_percent_of_ideal_population", + "within_percent_of_ideal_population_per_representative", "Bounds"] diff --git a/gerrychain/constraints/validity.py b/gerrychain/constraints/validity.py index cf6e3525..716c6c3a 100644 --- a/gerrychain/constraints/validity.py +++ b/gerrychain/constraints/validity.py @@ -76,6 +76,34 @@ def population(partition): return Bounds(population, bounds=bounds) +def within_percent_of_ideal_population_per_representative( + initial_partition, percent=0.01, pop_key="population" +): + """Require that all districts are within a certain percent of "ideal" (i.e., + uniform) population per representative. + + Ideal population is defined as "total population / number of representatives." + + :param initial_partition: Starting MultiMemberPartition from which to compute district + information. + :param percent: (optional) Allowed percentage deviation. Default is 1%. + :param pop_key: (optional) The name of the population + :class:`Tally `. Default is ``"population"``. + :return: A :class:`.Bounds` constraint on the population attribute identified + by ``pop_key``. + """ + + def population_per_rep(partition): + return [v / partition.magnitudes[k] for k, v in partition[pop_key].items()] + + number_of_representatives = initial_partition.number_of_representatives + total_population = sum(initial_partition[pop_key].values()) + ideal_population = total_population / number_of_representatives + bounds = ((1 - percent) * ideal_population, (1 + percent) * ideal_population) + + return Bounds(population_per_rep, bounds=bounds) + + def deviation_from_ideal(partition, attribute="population"): """Computes the deviation of the given ``attribute`` from exact equality among parts of the partition. Usually ``attribute`` is the population, and diff --git a/gerrychain/partition/__init__.py b/gerrychain/partition/__init__.py index 5c9c6515..9947ab09 100644 --- a/gerrychain/partition/__init__.py +++ b/gerrychain/partition/__init__.py @@ -1,4 +1,5 @@ from .partition import Partition from .geographic import GeographicPartition +from .multi_member import MultiMemberPartition -__all__ = ['Partition', 'GeographicPartition'] +__all__ = ['Partition', 'GeographicPartition', 'MultiMemberPartition'] diff --git a/gerrychain/partition/multi_member.py b/gerrychain/partition/multi_member.py new file mode 100644 index 00000000..c10cf189 --- /dev/null +++ b/gerrychain/partition/multi_member.py @@ -0,0 +1,45 @@ +from gerrychain.partition import Partition + + +class MultiMemberPartition(Partition): + """ + A :class:`Partition` with district magnitude information included. + These additional data allows for districts of different scales (number of representatives) + to be properly balanced. + """ + + def __init__(self, graph=None, assignment=None, updaters=None, magnitudes=None, + parent=None, flips=None): + """ + :param graph: Underlying graph. + :param assignment: Dictionary assigning nodes to districts. + :param updaters: Dictionary of functions to track data about the partition. + The keys are stored as attributes on the partition class, + which the functions compute. + :param magnitudes (dict): Dictionary assigning districts to number of + representatives + """ + super().__init__(graph=graph, assignment=assignment, updaters=updaters, parent=parent, + flips=flips) + if parent is None: + self._init_magnitudes(magnitudes) + else: + self._update_magnitudes_from_parent(magnitudes) + self.number_of_representatives = sum(magnitudes.values()) + + def _init_magnitudes(self, magnitudes): + """ + :param magnitudes: Dictionary assigning districts to number of representatives. If None or + incomplete, excluded districts are assigned a magnitude of 1 representative. + """ + dist_ids = self.assignment.parts.keys() + self.magnitudes = {dist_id: 1 for dist_id in dist_ids} + if magnitudes is not None: + self.magnitudes = {**self.magnitudes, **magnitudes} + + def _update_magnitudes_from_parent(self, magnitudes): + parent = self.parent + self.magnitudes = {**parent.magnitudes, **magnitudes} + + def flip(self, flips, magnitudes): + return self.__class__(parent=self, flips=flips, magnitudes=magnitudes) diff --git a/gerrychain/proposals/tree_proposals.py b/gerrychain/proposals/tree_proposals.py index ad647ecf..3e44ec7a 100644 --- a/gerrychain/proposals/tree_proposals.py +++ b/gerrychain/proposals/tree_proposals.py @@ -9,7 +9,8 @@ def recom( - partition, pop_col, pop_target, epsilon, node_repeats=1, method=bipartition_tree + partition, pop_col, pop_target, epsilon, node_repeats=1, method=bipartition_tree, + multimember=False, ): """ReCom proposal. @@ -45,6 +46,8 @@ def recom( partition.parts[parts_to_merge[0]] | partition.parts[parts_to_merge[1]] ) + magnitudes = partition.magnitudes if multimember else None + flips = recursive_tree_part( subgraph, parts_to_merge, @@ -53,9 +56,14 @@ def recom( epsilon=epsilon, node_repeats=node_repeats, method=method, + magnitudes=magnitudes ) - return partition.flip(flips) + if multimember: + new_magnitudes = flips[1] + flips = flips[0] + + return partition.flip(flips, new_magnitudes) if multimember else partition.flip(flips) def reversible_recom(partition, pop_col, pop_target, epsilon, @@ -121,15 +129,18 @@ def bounded_balance_edge_fn(*args, **kwargs): class ReCom: - def __init__(self, pop_col, ideal_pop, epsilon, method=bipartition_tree_random): + def __init__(self, pop_col, ideal_pop, epsilon, method=bipartition_tree_random, + multimember=False,): self.pop_col = pop_col self.ideal_pop = ideal_pop self.epsilon = epsilon self.method = method + self.multimember = multimember def __call__(self, partition): return recom( - partition, self.pop_col, self.ideal_pop, self.epsilon, method=self.method + partition, self.pop_col, self.ideal_pop, self.epsilon, method=self.method, + multimember=self.multimember ) diff --git a/gerrychain/tree.py b/gerrychain/tree.py index 131ee495..9f070ff4 100644 --- a/gerrychain/tree.py +++ b/gerrychain/tree.py @@ -274,7 +274,8 @@ def bipartition_tree_random( def recursive_tree_part( - graph, parts, pop_target, pop_col, epsilon, node_repeats=1, method=bipartition_tree + graph, parts, pop_target, pop_col, epsilon, node_repeats=1, method=bipartition_tree, + magnitudes=None ): """Uses :func:`~gerrychain.tree.bipartition_tree` recursively to partition a tree into ``len(parts)`` parts of population ``pop_target`` (within ``epsilon``). Can be used to @@ -291,6 +292,8 @@ def recursive_tree_part( :rtype: dict """ flips = {} + new_magnitudes = {} + remaining_nodes = set(graph.nodes) # We keep a running tally of deviation from ``epsilon`` at each partition # and use it to tighten the population constraints on a per-partition @@ -302,13 +305,16 @@ def recursive_tree_part( debt = 0 for part in parts[:-1]: - min_pop = max(pop_target * (1 - epsilon), pop_target * (1 - epsilon) - debt) - max_pop = min(pop_target * (1 + epsilon), pop_target * (1 + epsilon) - debt) + part_mag = 1 if magnitudes is None else magnitudes[part] + min_pop = max(pop_target * part_mag * (1 - epsilon), + pop_target * part_mag * (1 - epsilon) - debt) + max_pop = min(pop_target * part_mag * (1 + epsilon), + pop_target * part_mag * (1 + epsilon) - debt) nodes = method( graph.subgraph(remaining_nodes), pop_col=pop_col, pop_target=(min_pop + max_pop) / 2, - epsilon=(max_pop - min_pop) / (2 * pop_target), + epsilon=(max_pop - min_pop) / (2 * pop_target * part_mag), node_repeats=node_repeats, ) @@ -319,14 +325,21 @@ def recursive_tree_part( for node in nodes: flips[node] = part part_pop += graph.nodes[node][pop_col] - debt += part_pop - pop_target + debt += part_pop - pop_target * part_mag remaining_nodes -= nodes + if magnitudes is not None: + new_magnitudes[part] = part_mag + # All of the remaining nodes go in the last part for node in remaining_nodes: flips[node] = parts[-1] - return flips + if magnitudes is not None: + new_magnitudes[parts[-1]] = magnitudes[parts[-1]] + return flips, new_magnitudes + else: + return flips def get_seed_chunks(