Skip to content

Commit 75b065b

Browse files
committed
Fix issue #319
1 parent 1f184f4 commit 75b065b

File tree

8 files changed

+200
-45
lines changed

8 files changed

+200
-45
lines changed

.circleci/config.yml

+2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ jobs:
4242
echo "backend: Agg" > "matplotlibrc"
4343
pytest -v --runslow --cov=gerrychain --junitxml=test-reports/junit.xml tests
4444
codecov
45+
46+
no_output_timeout: 20m
4547
environment:
4648
PYTHONHASHSEED: "0"
4749
- store_test_results:

gerrychain/__init__.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
from .graph import Graph
77
from .partition import GeographicPartition, Partition
88
from .updaters.election import Election
9-
9+
10+
# Will need to change this to a logging option later
11+
# It might be good to see how often this happens
12+
warnings.simplefilter("once")
1013

1114
try:
1215
import geopandas

gerrychain/graph/graph.py

+12
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,18 @@ def from_file(
145145
146146
:returns: The Graph object of the geometries from `filename`.
147147
:rtype: Graph
148+
149+
.. Warning::
150+
151+
This method requires the optional ``geopandas`` dependency.
152+
So please install ``gerrychain`` with the ``geo`` extra
153+
via the command:
154+
155+
.. code-block:: console
156+
157+
pip install gerrychain[geo]
158+
159+
or install ``geopandas`` separately.
148160
"""
149161
import geopandas as gp
150162

gerrychain/partition/geographic.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111

1212

1313
class GeographicPartition(Partition):
14-
"""A :class:`Partition` with areas, perimeters, and boundary information included.
14+
"""
15+
A :class:`Partition` with areas, perimeters, and boundary information included.
1516
These additional data allow you to compute compactness scores like
1617
`Polsby-Popper <https://en.wikipedia.org/wiki/Polsby-Popper_Test>`_.
1718
"""

gerrychain/proposals/tree_proposals.py

+50-16
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@
66
from ..tree import (
77
recursive_tree_part, bipartition_tree, bipartition_tree_random,
88
_bipartition_tree_random_all, uniform_spanning_tree,
9-
find_balanced_edge_cuts_memoization,
9+
find_balanced_edge_cuts_memoization, ReselectException,
1010
)
1111
from typing import Callable, Optional, Dict, Union
1212

1313

14+
class MetagraphError(Exception):
15+
"""
16+
Raised when the partition we are trying to split is a low degree
17+
node in the metagraph.
18+
"""
19+
pass
20+
21+
1422
def recom(
1523
partition: Partition,
1624
pop_col: str,
@@ -70,26 +78,52 @@ def recom(
7078
:rtype: Partition
7179
"""
7280

73-
edge = random.choice(tuple(partition["cut_edges"]))
74-
parts_to_merge = (partition.assignment.mapping[edge[0]], partition.assignment.mapping[edge[1]])
75-
76-
subgraph = partition.graph.subgraph(
77-
partition.parts[parts_to_merge[0]] | partition.parts[parts_to_merge[1]]
78-
)
81+
bad_district_pairs = set()
82+
n_parts = len(partition)
83+
tot_pairs = n_parts * (n_parts - 1) / 2 # n choose 2
7984

8085
# Try to add the region aware in if the method accepts the weight dictionary
8186
if 'weight_dict' in signature(method).parameters:
8287
method = partial(method, weight_dict=weight_dict)
8388

84-
flips = recursive_tree_part(
85-
subgraph.graph,
86-
parts_to_merge,
87-
pop_col=pop_col,
88-
pop_target=pop_target,
89-
epsilon=epsilon,
90-
node_repeats=node_repeats,
91-
method=method,
92-
)
89+
while len(bad_district_pairs) < tot_pairs:
90+
try:
91+
while True:
92+
edge = random.choice(tuple(partition["cut_edges"]))
93+
# Need to sort the tuple so that the order is consistent
94+
# in the bad_district_pairs set
95+
parts_to_merge = [partition.assignment.mapping[edge[0]],
96+
partition.assignment.mapping[edge[1]]]
97+
parts_to_merge.sort()
98+
99+
if tuple(parts_to_merge) not in bad_district_pairs:
100+
break
101+
102+
subgraph = partition.graph.subgraph(
103+
partition.parts[parts_to_merge[0]] | partition.parts[parts_to_merge[1]]
104+
)
105+
106+
flips = recursive_tree_part(
107+
subgraph.graph,
108+
parts_to_merge,
109+
pop_col=pop_col,
110+
pop_target=pop_target,
111+
epsilon=epsilon,
112+
node_repeats=node_repeats,
113+
method=method,
114+
)
115+
break
116+
117+
except Exception as e:
118+
if isinstance(e, ReselectException):
119+
bad_district_pairs.add(tuple(parts_to_merge))
120+
continue
121+
else:
122+
raise
123+
124+
if len(bad_district_pairs) == tot_pairs:
125+
raise MetagraphError(f"Bipartitioning failed for all {tot_pairs} district pairs."
126+
f"Consider rerunning the chain with a different random seed.")
93127

94128
return partition.flip(flips)
95129

gerrychain/tree.py

+46-8
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import random
3636
from collections import deque, namedtuple
3737
from typing import Any, Callable, Dict, List, Optional, Set, Union, Hashable, Sequence, Tuple
38+
import warnings
3839

3940

4041
def predecessors(h: nx.Graph, root: Any) -> Dict:
@@ -295,6 +296,22 @@ def part_nodes(start):
295296
return cuts
296297

297298

299+
class BipartitionWarning(UserWarning):
300+
"""
301+
Generally raised when it is proving difficult to find a balanced cut.
302+
"""
303+
pass
304+
305+
306+
class ReselectException(Exception):
307+
"""
308+
Raised when the algorithm is unable to find a balanced cut after some
309+
maximum number of attempts, but the user has allowed the algorithm to
310+
reselect the pair of nodes to try and recombine.
311+
"""
312+
pass
313+
314+
298315
def bipartition_tree(
299316
graph: nx.Graph,
300317
pop_col: str,
@@ -306,7 +323,8 @@ def bipartition_tree(
306323
weight_dict: Optional[Dict] = None,
307324
balance_edge_fn: Callable = find_balanced_edge_cuts_memoization,
308325
choice: Callable = random.choice,
309-
max_attempts: Optional[int] = 10000
326+
max_attempts: Optional[int] = 10000,
327+
allow_pair_reselection: bool = False
310328
) -> Set:
311329
"""
312330
This function finds a balanced 2 partition of a graph by drawing a
@@ -347,10 +365,15 @@ def bipartition_tree(
347365
:param max_attempts: The maximum number of attempts that should be made to bipartition.
348366
Defaults to 1000.
349367
:type max_attempts: Optional[int], optional
368+
:param allow_pair_reselection: Whether we would like to return an error to the calling
369+
function to ask it to reselect the pair of nodes to try and recombine. Defaults to False.
370+
:type allow_pair_reselection: bool, optional
350371
351372
:returns: A subset of nodes of ``graph`` (whose induced subgraph is connected). The other
352373
part of the partition is the complement of this subset.
353374
:rtype: Set
375+
376+
:raises BipartitionWarning: If a possible cut cannot be found after 50 attempts.
354377
:raises RuntimeError: If a possible cut cannot be found after the maximum number of attempts.
355378
"""
356379
# Try to add the region-aware in if the spanning_tree_fn accepts a weight dictionary
@@ -378,6 +401,17 @@ def bipartition_tree(
378401
restarts += 1
379402
attempts += 1
380403

404+
if attempts == 50 and not allow_pair_reselection:
405+
warnings.warn("Failed to find a balanced cut after 50 attempts.\n"
406+
"Consider running with the parameter\n"
407+
"allow_pair_reselection=True to allow the algorithm to\n"
408+
"select a different pair of nodes to try an recombine.",
409+
BipartitionWarning)
410+
411+
if allow_pair_reselection:
412+
raise ReselectException(f"Failed to find a balanced cut after {max_attempts} attempts.\n"
413+
f"Selecting a new district pair")
414+
381415
raise RuntimeError(f"Could not find a possible cut after {max_attempts} attempts.")
382416

383417

@@ -589,13 +623,17 @@ def recursive_tree_part(
589623
min_pop = max(pop_target * (1 - epsilon), pop_target * (1 - epsilon) - debt)
590624
max_pop = min(pop_target * (1 + epsilon), pop_target * (1 + epsilon) - debt)
591625
new_pop_target = (min_pop + max_pop) / 2
592-
nodes = method(
593-
graph.subgraph(remaining_nodes),
594-
pop_col=pop_col,
595-
pop_target=new_pop_target,
596-
epsilon=(max_pop - min_pop) / (2 * new_pop_target),
597-
node_repeats=node_repeats,
598-
)
626+
627+
try:
628+
nodes = method(
629+
graph.subgraph(remaining_nodes),
630+
pop_col=pop_col,
631+
pop_target=new_pop_target,
632+
epsilon=(max_pop - min_pop) / (2 * new_pop_target),
633+
node_repeats=node_repeats,
634+
)
635+
except Exception:
636+
raise
599637

600638
if nodes is None:
601639
raise BalanceError()

0 commit comments

Comments
 (0)