Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Support for Multi-Member ReCom #360

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .circleci/config.yml
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion 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']
Expand Down
6 changes: 4 additions & 2 deletions gerrychain/constraints/__init__.py
Expand Up @@ -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",
Expand All @@ -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"]
28 changes: 28 additions & 0 deletions gerrychain/constraints/validity.py
Expand Up @@ -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 <gerrychain.updaters.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
Expand Down
3 changes: 2 additions & 1 deletion 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']
45 changes: 45 additions & 0 deletions 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<Any, numeric>): 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)
19 changes: 15 additions & 4 deletions gerrychain/proposals/tree_proposals.py
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
)


Expand Down
25 changes: 19 additions & 6 deletions gerrychain/tree.py
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
)

Expand All @@ -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(
Expand Down