diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2616c07..7f3f19bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -249,6 +249,13 @@ Shared setup blocks use: ``` +A page whose blocks are all illustrative fragments can opt out wholesale, with the marker placed +just under the title: + +```md + +``` + Snippet failures report the original documentation filename and line number. The docs build fails if a notebook cannot execute, and the test suite rejects notebooks containing committed outputs. diff --git a/docs/conf.py b/docs/conf.py index d2898d17..939a5ec1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -38,7 +38,8 @@ "deflist", # definition lists inside {glossary} directives "dollarmath", ] -myst_heading_anchors = 3 +# 4 so the migration guide's level-4 subsections can be linked from its table of contents. +myst_heading_anchors = 4 # Tutorial notebooks are committed without outputs. The docs build executes them into this # ignored cache before Sphinx runs, keeping generated images and animations out of Git. diff --git a/docs/generate_recom_assets.py b/docs/generate_recom_assets.py index 84d85095..d3365f1a 100644 --- a/docs/generate_recom_assets.py +++ b/docs/generate_recom_assets.py @@ -1,5 +1,6 @@ """Regenerate the static Gerrymandria plan and region assets used by the docs.""" +import random from io import BytesIO from pathlib import Path from collections.abc import Hashable, Mapping, Sequence @@ -19,6 +20,9 @@ from gerrychain.examples import gerrymandria from gerrychain.proposals import ReCom +Point = tuple[int, int] +Segment = tuple[Point, Point] + DOCS = Path(__file__).parent IMAGES = DOCS / "user" / "images" DISTRICTR_COLORS = ( @@ -51,7 +55,14 @@ def assignment_image( graph: Graph, assignment: Mapping[Hashable, Hashable], show_labels: bool = False, + tree_edges: Sequence[Segment] | None = None, + cut_edge: Segment | None = None, ) -> Image.Image: + """Render a plan as a colored grid, optionally overlaying a spanning tree and its cut edge. + + ``tree_edges`` and ``cut_edge`` are given in grid coordinates rather than node ids, because the + spanning tree is built on a subgraph whose node ids are renumbered from the parent graph's. + """ grid_size = int(len(assignment) ** 0.5) grid = np.empty((grid_size, grid_size)) labels = sorted(set(assignment.values()), key=int) @@ -84,6 +95,25 @@ def assignment_image( **LABEL_STYLE, ) + if tree_edges: + for (x1, y1), (x2, y2) in tree_edges: + ax.plot([x1, x2], [y1, y2], color="black", linewidth=2, zorder=2) + if cut_edge is not None: + # Drawn above the tree edges but below the nodes, so the cut reads as a severed + # connection between two nodes rather than a line laid over them. + (x1, y1), (x2, y2) = cut_edge + ax.plot([x1, x2], [y1, y2], color="white", linewidth=5, zorder=3) + if tree_edges: + ax.scatter( + [x for edge in tree_edges for x, _ in edge], + [y for edge in tree_edges for _, y in edge], + s=250, + c="#e6e6e6", + edgecolors="black", + linewidths=1, + zorder=4, + ) + buffer = BytesIO() fig.savefig(buffer, format="png", bbox_inches="tight", pad_inches=0) plt.close(fig) @@ -128,6 +158,90 @@ def save_district_dual_graph(graph: Graph) -> None: plt.close(fig) +DemoFrame = tuple[dict[Hashable, Hashable], list[Segment] | None, Segment | None] + + +def _edges_between( + graph: Graph, + sources: set[Hashable], + targets: set[Hashable], +) -> list[tuple[Hashable, Hashable]]: + """Every graph edge running from ``sources`` into ``targets``, in a stable order.""" + pairs = ((node, n) for node in sources for n in graph.neighbors(node) if n in targets) + return sorted(pairs, key=str) + + +def _adjacent_district_pairs( + graph: Graph, + assignment: Mapping[Hashable, Hashable], +) -> list[tuple[Hashable, Hashable]]: + """Every pair of districts that share a boundary, in a stable order.""" + pairs = { + tuple(sorted((assignment[node], assignment[n]), key=str)) + for node in assignment + for n in graph.neighbors(node) + if assignment[node] != assignment[n] + } + return sorted(pairs, key=str) + + +def _spanning_tree_edges( + graph: Graph, + nodes: set[Hashable], + rng: random.Random, +) -> list[tuple[Hashable, Hashable]]: + """Draw a random spanning tree over an induced set of nodes, growing it one edge at a time.""" + seen = {min(nodes, key=str)} + edges = [] + while seen != nodes: + edge = rng.choice(_edges_between(graph, seen, nodes - seen)) + edges.append(edge) + seen.add(edge[1]) + return edges + + +def recom_demo_frames( + graph: Graph, + *, + seed: int, + total_steps: int, +) -> list[DemoFrame]: + """Build frames showing how ReCom turns one pair of districts into the next.""" + assignments = recom_assignments(graph, seed=seed, total_steps=total_steps) + rng = random.Random(seed) + + def point(node_id: Hashable) -> Point: + data = graph.node_data(node_id) + return (data["x"], data["y"]) + + frames: list[DemoFrame] = [(assignments[0], None, None)] + for before, after in zip(assignments, assignments[1:]): + moved = next((node for node in before if before[node] != after[node]), None) + if moved is not None: + # A node that moved went from one district of the merged pair to the other, so its old + # and new labels name both of them. + label_a, label_b = sorted({before[moved], after[moved]}, key=str) + else: + # ReCom re-split a merged pair exactly as it was, so the plan is unchanged and nothing + # identifies which pair it merged. Any adjacent pair illustrates the step, because + # cutting the bridge between two districts' trees always restores those districts. + label_a, label_b = rng.choice(_adjacent_district_pairs(graph, after)) + part_a = {node for node in after if after[node] == label_a} + part_b = {node for node in after if after[node] == label_b} + bridge = rng.choice(_edges_between(graph, part_a, part_b)) + tree = [ + *_spanning_tree_edges(graph, part_a, rng), + *_spanning_tree_edges(graph, part_b, rng), + bridge, + ] + + tree_edges = [(point(node1), point(node2)) for node1, node2 in tree] + frames.append((before, tree_edges, None)) + frames.append((before, tree_edges, (point(bridge[0]), point(bridge[1])))) + frames.append((after, None, None)) + return frames + + def recom_assignments( graph: Graph, *, @@ -172,6 +286,21 @@ def save_assignment_gif( ) +def save_demo_gif(graph: Graph, frames: Sequence[DemoFrame], filename: str) -> None: + """Render the tree-and-cut demo frames as a looping GIF.""" + images = [ + assignment_image(graph, assignment, tree_edges=tree_edges, cut_edge=cut_edge) + for assignment, tree_edges, cut_edge in frames + ] + images[0].save( + IMAGES / filename, + save_all=True, + append_images=images[1:], + duration=500, + loop=0, + ) + + def regenerate_gerrymandria() -> None: graph = gerrymandria() for filename, attribute in ( @@ -184,8 +313,15 @@ def regenerate_gerrymandria() -> None: assignment_image(graph, assignment, show_labels=True).save(IMAGES / filename) save_district_dual_graph(graph) + # The hero animation for the ReCom guide: unlike the plain ensemble gif below, this one shows + # the spanning tree and the cut edge that produce each new pair of districts. + save_demo_gif( + graph, + recom_demo_frames(graph, seed=42, total_steps=20), + "gerrychain_demo.gif", + ) + simple_assignments = recom_assignments(graph, seed=2024, total_steps=40) - save_assignment_gif(graph, simple_assignments, "gerrychain_demo.gif") save_assignment_gif(graph, simple_assignments, "gerrymandria_grid_ensemble.gif") municipality_assignments = recom_assignments( diff --git a/docs/topics/v1p0p0_migration_guide.md b/docs/topics/v1p0p0_migration_guide.md index e47e285b..44b57f22 100644 --- a/docs/topics/v1p0p0_migration_guide.md +++ b/docs/topics/v1p0p0_migration_guide.md @@ -1,510 +1,617 @@ # Migration Guide for GerryChain v1.0.0 -This guide will help you understand what you need to change in your "legacy" GerryChain code so that it can run in the v1.0.0 release. + + +This guide will help you understand what you need to change in your "legacy" GerryChain code so that +it can run in the v1.0.0 release. ## Table of Contents -* [Primary Benefit of GerryChain v1.0.0 for Legacy Users](#primary-benefit-of-gerrychain-v100-for-legacy-users) - * [ Minimizing the Impact on Legacy Code](#minimizing-the-impact-on-legacy-code) - * [Creating a Graph to use in your Partition](#creating-a-graph-to-use-in-your-partition) - * [Changes to Accessing and Setting Node and Edge Data](#changes-to-accessing-and-setting-node-and-edge-data) - * [Running a MarkovChain()](#running-a-markovchain) - * [Analyzing Data after running a MarkovChain()](#analyzing-data-after-running-a-markovchain) - * [Translating node_ids and edge_ids after running a MarkovChain()](#translating-node_ids-and-edge_ids-after-running-a-markovchain) - * [Node IDs and Edge IDs - a deep dive...](#node-ids-and-edge-ids---a-deep-dive) + +- [Primary Benefit of GerryChain v1.0.0 for Legacy Users](#primary-benefit-of-gerrychain-v100-for-legacy-users) + - [ Minimizing the Impact on Legacy Code](#minimizing-the-impact-on-legacy-code) + - [Creating a Graph to use in your Partition](#creating-a-graph-to-use-in-your-partition) + - [Changes to Accessing and Setting Node and Edge Data](#changes-to-accessing-and-setting-node-and-edge-data) + - [Running a MarkovChain()](#running-a-markovchain) + - [Changes to ReCom Proposals](#changes-to-recom-proposals) + - [The `ReCom` Namespace](#the-recom-namespace) + - [Renamed Proposal and Tree Parameters](#renamed-proposal-and-tree-parameters) + - [Multi-Member ReCom](#multi-member-recom) + - [Analyzing Data after running a MarkovChain()](#analyzing-data-after-running-a-markovchain) + - [Translating node_ids and edge_ids after running a MarkovChain()](#translating-node_ids-and-edge_ids-after-running-a-markovchain) + - [Node IDs and Edge IDs - a deep dive...](#node-ids-and-edge-ids---a-deep-dive) ## Primary Benefit of GerryChain v1.0.0 for Legacy Users -The primary benefit of the v1.0.0 release for legacy users -is a significant performance boost: about 2X on large graphs. +The primary benefit of the v1.0.0 release for legacy users is a significant performance boost: about +2X on large graphs. -This performance boost was realized by changing the underlying graph engine from NetworkX to RustworkX - RustworkX is faster. +This performance boost was realized by changing the underlying graph engine from NetworkX to +RustworkX - RustworkX is faster. -There are additional enhancements in v1.0.0 that -this guide will not address. This guide will -focus on what you need to do to get your legacy -code working. +There are additional enhancements in v1.0.0 that this guide will not address. This guide will focus +on what you need to do to get your legacy code working. -To provide some context for what follows, the -primary change that affects legacy code is that -the GerryChain.Graph object is no longer a -subclass of NetworkX.Graph, but is instead a -new class that wraps the underlying graph engine. -This allowed us to provide a single interface to -user code that could be implemented under -the covers with either a NetworkX.Graph object -or a RustworkX.PyGraph object. +To provide some context for what follows, the primary change that affects legacy code is that the +GerryChain.Graph object is no longer a subclass of NetworkX.Graph, but is instead a new class that +wraps the underlying graph engine. This allowed us to provide a single interface to user code that +could be implemented under the covers with either a NetworkX.Graph object or a RustworkX.PyGraph +object. -To the extent possible the functions exposed by -the new GerryChain.Graph object are the same as -those exposed by the old NetworkX.Graph based -graph object - details below. +To the extent possible the functions exposed by the new GerryChain.Graph object are the same as +those exposed by the old NetworkX.Graph based graph object - details below. ## Minimizing the Impact on Legacy Code -The new GerryChain.Graph object supports most -but not all of the functionality of the old -NetworkX based graph object, so most of the -code to manipulate your graph will run unchanged, -but some changes will be required, in particular -to set and access node and edge data. More on -this below. +The new GerryChain.Graph object supports most but not all of the functionality of the old NetworkX +based graph object, so most of the code to manipulate your graph will run unchanged, but some +changes will be required, in particular to set and access node and edge data. More on this below. -We crafted the interface -of the new Graph object to be close to the same -as that of the old Graph object. Functions that were -inherited from NetworkX that were most commonly used by -legacy code have been implemented as explicit -functions of the new Graph object, so that -legacy code would run unchanged. +We crafted the interface of the new Graph object to be similar to that of the old Graph object. The +NetworkX functions most commonly used by legacy code are now explicit functions of the new Graph +object, allowing legacy code to run unchanged. -Examples of formerly inherited functions that are -now explicit functions are: +Examples of formerly inherited functions that are now explicit functions are: -* degree() -* neighbors() +- degree() +- neighbors() -In addition, those functions that had been added to -the previous GerryChain.Graph object such as: +In addition, those functions that had been added to the previous GerryChain.Graph object such as: -* Graph.from_json(...) -* my_gc_graph.to_json(...) -* Graph.from_file(...) -* Graph.from_geodataframe(...) -* etc. +- Graph.from_json(...) +- my_gc_graph.to_json(...) +- Graph.from_file(...) +- Graph.from_geodataframe(...) +- etc. -have been implemented for the new Gerrychain.Graph object, -to avoid requiring changes in legacy code. +have been implemented for the new GerryChain.Graph object, to avoid requiring changes in legacy +code. ### Creating a Graph to use in your Partition -There are two ways to create a graph that you can -use in your Partition object: +There are two ways to create a graph that you can use in your Partition object: -1. Creating a NetworkX.Graph object, making -whatever modifications are needed before -running a chain, and then -using that NetworkX.Graph object to create your -Partition object. +1. Creating a NetworkX.Graph object, making whatever modifications are needed before running a + chain, and then using that NetworkX.Graph object to create your Partition object. -2. Creating a GerryChain.Graph object directly, -and then making whatever modifications are -needed to that graph object and then using that -GerryChain.Graph object to create your Partition -object. +2. Creating a GerryChain.Graph object directly, and then making whatever modifications are needed to + that graph object and then using that GerryChain.Graph object to create your Partition object. -Approach #1 is the most compatible with previous -(legacy) workflows since, prior to release 1.0.0, -GerryChain treated the `Graph` class as a proper -subclass of the NetworkX `Graph` class. +Approach #1 is the most compatible with previous (legacy) workflows since, prior to release 1.0.0, +GerryChain treated the `Graph` class as a proper subclass of the NetworkX `Graph` class. -Your code in this case would look something -like: +Your code in this case would look something like: ```python - import networkx - from gerrychain import Graph +import networkx +from gerrychain import Graph - my_networkx_graph = networkx.Graph() +my_networkx_graph = networkx.Graph() - ... code to create your NetworkX graph ... +... code to create your NetworkX graph ... - ... create a Partition using my_networkx_graph ... +... create a Partition using my_networkx_graph ... ``` -However, it is often not possible (or convenient) -to restructure your code to just use NetworkX -functionality to build your graph. For instance, -you might have code that creates a graph from -JSON with: +However, it is often not possible (or convenient) to restructure your code to just use NetworkX +functionality to build your graph. For instance, you might have code that creates a graph from JSON +with: ```python - my_graph = Graph.from_json(...) +my_graph = Graph.from_json(...) ``` -or + +or ```python - my_graph = Graph.from_geodataframe(...) +my_graph = Graph.from_geodataframe(...) ``` -In these cases, any modifications to your graph -needed before running a chain will need to be -done using the new GerryChain.Graph object. -So your code would look something like: + +In these cases, any modifications to your graph needed before running a chain will need to be done +using the new GerryChain.Graph object. So your code would look something like: ```python - from gerrychain import Graph +from gerrychain import Graph - my_gerrychain_graph = \ - Graph.from_json(...) +my_gerrychain_graph = Graph.from_json(...) - ... code to modify your graph ... +... code to modify your graph ... - ... create a Partition using my_gerrychain_graph ... +... create a Partition using my_gerrychain_graph ... ``` ### Changes to Accessing and Setting Node and Edge Data -As stated above, the new GerryChain Graph object no longer -inherits from NetworkX.Graph, and instead wraps -either a NetworkX.Graph or a RustworkX.PyGraph. +As stated above, the new GerryChain Graph object no longer inherits from NetworkX.Graph, and instead +wraps either a NetworkX.Graph or a RustworkX.PyGraph. -As a result, the way to access and set node and edge data -has changed. +As a result, the way to access and set node and edge data has changed. -If your code accesses or updates the data associated with nodes -then it probably contains code that looks something -like this: -```python +If your code accesses or updates the data associated with nodes, then it probably contains code that +looks something like this: - my_graph.nodes[node_id][""] = new_value +```python +my_graph.nodes[node_id][""] = new_value - node_attr_value = mygraph.nodes[node_id][""] +node_attr_value = mygraph.nodes[node_id][""] ``` + In v1.0.0, this code will need to be changed to be: ```python - my_graph.node_data(node_id)[""] = new_value - - node_attr_value = mygraph.node_data(node_id)[""] +my_graph.node_data(node_id)[""] = new_value +node_attr_value = mygraph.node_data(node_id)[""] ``` -The needed change is a simple change in -syntax - "node_data" instead of "nodes" and -parentheses instead of square-brackets. +The syntax change is simple: use `node_data` instead of `nodes`, and parentheses instead of square +brackets. -If your code accesses or updates the data associated with edges -then it probably contains code that looks something -like this: +If your code accesses or updates the data associated with edges, then it probably contains code that +looks something like this: ```python - my_graph.edges[node1_id, node2_id][""] = new_value +my_graph.edges[node1_id, node2_id][""] = new_value - edge_attr_value = my_graph.edges[node1_id, node2_id][""] +edge_attr_value = my_graph.edges[node1_id, node2_id][""] ``` In v1.0.0, this code will need to be changed to be: ```python - # Get an edge_id given the two nodes in the edge - my_edge_id = \ - Graph.get_edge_id_from_edge((node1_id, node2_id)) +# Get an edge_id given the two nodes in the edge +my_edge_id = my_graph.get_edge_id_from_edge((node1_id, node2_id)) - my_graph.edge_data(my_edge_id)[""] = new_value +my_graph.edge_data(my_edge_id)[""] = new_value - edge_attr_value = my_graph.edge_data(my_edge_id)[""] +edge_attr_value = my_graph.edge_data(my_edge_id)[""] ``` -The important change here is that you need to obtain the -edge_id before calling my_graph.edge_data(edge_id). In NetworkX -an edge_id is a tuple of node_ids, but in RustworkX an edge_id -is an integer. +The important change here is that you need to obtain the edge_id before calling +my_graph.edge_data(edge_id). In NetworkX, an edge_id is a tuple of node_ids, but in RustworkX, an +edge_id is an integer. -Note that the new GerryChain.Graph object also provides a way -to obtain the node_ids associated with an edge: +Note that the new GerryChain.Graph object also provides a way to obtain the node_ids associated with +an edge: ```python - edge_node_ids = my_graph.get_edge_from_edge_id(my_edge_id) - - node1_id = edge_node_ids[0] - node2_id = edge_node_ids[1] +edge_node_ids = my_graph.get_edge_from_edge_id(my_edge_id) + +node1_id = edge_node_ids[0] +node2_id = edge_node_ids[1] ``` ### Running a MarkovChain() -In most cases, there will be no need to change any legacy -code in order to run a MarkovChain() in v1.0.0. +The graph work in v1.0.0 requires very little change here: the code that creates a Partition object +will convert the graph provided into a RustworkX.PyGraph automatically, and all of the internal code +that runs when running a MarkovChain() has been updated to work with the RustworkX.PyGraph object. -The code that creates a Partition object will convert the -graph provided into a RustworkX.PyGraph automatically and -all of the internal code that runs when running a MarkovChain() -has been updated to work with the RustworkX.PyGraph object. +Separately from the graph change, though, three MarkovChain() parameters were renamed in v1.0.0, so +legacy code that passes them **by keyword** will need updating: -The exception to this is if the legacy code contains custom -code for updaters. Since any such code will operate on a -GerryChain.Graph object that is wrapped around a RustworkX.PyGraph -object, that code will need to be aware of the fact that -node_ids and edge_ids will be different from those in the -original NetworkX.Graph object used to create the Partition -object. +| Legacy name | v1.0.0 name | +| --------------- | ------------------- | +| `proposal` | `proposal_fn` | +| `accept` | `acceptance_fn` | +| `initial_state` | `initial_partition` | + +The parameter order did not change, so legacy code that passes these positionally keeps working +unchanged. + + + +```python +# Legacy keyword form - now raises +# TypeError: MarkovChain.__init__() got an unexpected keyword argument 'proposal' +chain = MarkovChain( + proposal=my_proposal, + constraints=[contiguous], + accept=always_accept, + initial_state=initial_partition, + total_steps=1000, +) + +# v1.0.0 +chain = MarkovChain( + proposal_fn=my_proposal, + constraints=[contiguous], + acceptance_fn=always_accept, + initial_partition=initial_partition, + total_steps=1000, +) +``` + +This rename is part of a general convention in v1.0.0: parameters that take a function end in `_fn`. +Plural collection parameters such as `constraints` and `updaters` keep their noun names. The same +rename applies to the acceptance functions, the optimizers, and the tree functions, so if a keyword +you are passing names a callable and it is rejected, try adding the `_fn` suffix. + +The exception to this is if the legacy code contains custom code for updaters. Since any such code +will operate on a GerryChain.Graph object that is wrapped around a RustworkX.PyGraph object, that +code will need to be aware of the fact that node_ids and edge_ids will be different from those in +the original NetworkX.Graph object used to create the Partition object. + +Another concern for such code is the use of subgraphs. In RustworkX, the node_ids and edge_ids for a +subgraph are different from those of the parent graph, so again, any code involving subgraphs that +runs when doing a MarkovChain() will need to be aware of the fact that node_ids and edge_ids are +different from the parent graph. + +There is a more detailed discussion of node_ids and edge_ids later in this guide. + +One other addition worth knowing: MarkovChain() now takes an `rng` argument (an integer seed or a +`random.Random`), and the chain's random number generator (RNG) is passed to your proposal and +acceptance functions. This is what makes runs reproducible. There is more on this in the +reproducibility guide. + +### Changes to ReCom Proposals + +Legacy code almost always builds a ReCom proposal with `functools.partial`: + +```python +from functools import partial +from gerrychain.proposals import recom + +my_proposal = partial( + recom, + pop_col="TOTPOP", + pop_target=ideal_population, + epsilon=0.01, + node_repeats=2, +) +``` + +That still works. `recom` is still exported and still takes a partition as its first argument, so +the `partial` idiom is not going away. But v1.0.0 adds a shorter way to say the same thing, and +renames a few parameters underneath. + +#### The `ReCom` Namespace + +Because some users found `partial` hard to understand, v1.0.0 also provides slim, ready-made +builders that make creating proposal functions more legible: + +```python +from gerrychain.proposals import ReCom + +my_proposal = ReCom.district_pairs_mst( + pop_col="TOTPOP", + pop_target=ideal_population, + epsilon=0.01, +) +``` + +The namespace provides five variants: + +| Builder | Alias | Description | +| -------------------------- | ----- | --------------------------------------------------- | +| `ReCom.cut_edges_mst` | `A` | Cut-edge selection and a minimum spanning tree | +| `ReCom.district_pairs_mst` | `B` | District-pair selection and a minimum spanning tree | +| `ReCom.cut_edges_ust` | `C` | Cut-edge selection and a uniform spanning tree | +| `ReCom.district_pairs_ust` | `D` | District-pair selection and a uniform spanning tree | +| `ReCom.reversible` | `R` | Reversible ReCom | + +The variants differ along two axes. `district_pairs_*` picks uniformly among adjacent district +pairs, while `cut_edges_*` picks a cut edge at random and merges the districts on either side, so a +pair's chance is proportional to how many cut edges it shares. `*_mst` draws a minimum spanning tree +over random edge weights using Kruskal's algorithm; `*_ust` draws a uniform spanning tree using +Wilson's algorithm. `ReCom.reversible(...)` is Reversible ReCom. The single-letter aliases `A`, `B`, +`C`, `D`, and `R` are also available. + +> **Note:** If your legacy code used the old `ReCom` _class_ - the one you constructed with +> `ReCom(pop_col=..., ideal_pop=..., epsilon=...)` - that class is gone. `ReCom` is now a namespace +> and cannot be instantiated; calling `ReCom(...)` raises a `TypeError` pointing you at the builder +> methods. Note also that the old class's `ideal_pop` parameter is spelled `pop_target` throughout +> v1.0.0. + +When you need parameters the slim builders do not expose, use `build_recom_proposal_fn`, which takes +the full set and returns a proposal function: + +```python +from gerrychain.proposals import build_recom_proposal_fn + +my_proposal = build_recom_proposal_fn( + pop_col="TOTPOP", + pop_target=ideal_population, + epsilon=0.01, + region_surcharge={"county": 0.5}, + bipartition_tree_fn=my_bipartition_tree_fn, +) +``` + +#### Renamed Proposal and Tree Parameters + +If you pass any of these by keyword, they need updating: + +| Legacy name | v1.0.0 name | Where | +| ----------------- | ---------------------------- | -------------------------------------- | +| `method` | `bipartition_tree_fn` | `recom` | +| `balance_edge_fn` | `find_balanced_edge_cuts_fn` | `bipartition_tree` | +| `one_sided_cut` | `single_district_cut` | `bipartition_tree`, custom cut finders | +| `choice` | `cut_choice_fn` | `bipartition_tree` | -Another concern for such code is the use of subgraphs. -In RustworkX, the node_ids and edge_ids for a subgraph are -different from those of the parent graph, so again, any code -involving subgraphs that runs when doing a MarkovChain() -will need to be aware of the fact that node_ids and edge_ids -are different from the parent graph. +Two of these deserve extra attention. -There is a more detailed discussion of node_ids and edge_ids -later in this guide. +`single_district_cut` was renamed because the old name described the cut rather than what the flag +actually controls: whether only the returned side must be population-balanced (peeling off one +district) or both sides must be (bisecting). If you wrote a custom balanced-edge-cut finder, it must +now accept this parameter unconditionally. Previously GerryChain inspected your function's signature +and only passed the flag if it was present; that probe has been removed. + +`cut_choice_fn` is not a drop-in rename of `choice`. The old `choice` defaulted to `random.choice` +and was called with just the list of cuts. The new `cut_choice_fn` is called as +`cut_choice_fn(cuts, rng=rng)`, so that the chain can supply its own seeded RNG: + +```python +def choose_random_cut(cuts, *, rng): + return rng.choice(cuts) +``` + +Also note that `node_repeats` now defaults to `0` rather than `1`. With the default memoized cut +finder, each search already examines every edge in the tree, so re-rooting cannot make an uncuttable +tree cuttable; the new default redraws the tree immediately instead. Legacy code that passes a +positive `node_repeats` still runs, but now emits a warning: + +```console +UserWarning: node_repeats is not beneficial with `find_balanced_edge_cuts_memoization`, +which exhaustively searches each spanning tree. Set node_repeats=0 to redraw the tree +after an unsuccessful search. +``` + +Setting `node_repeats=0` (or simply dropping the argument) is the fix. Positive values remain useful +only with `find_balanced_edge_cuts_contraction` or a custom cut-edge finder whose result depends on +the root choice. + +#### Multi-Member ReCom + +Multi-member ReCom - where districts elect different fixed numbers of members - lived on a separate +branch before the RustworkX migration and was rebuilt for v1.0.0. If you were using that branch, the +feature is now available from the main package: + +```python +from gerrychain.proposals import MultiMemberReCom + +members_per_district = {"1": 1, "2": 1, "3": 2, "4": 4} +pop_target = total_population / sum(members_per_district.values()) + +my_proposal = MultiMemberReCom.district_pairs_mst( + pop_col="TOTPOP", + pop_target=pop_target, + epsilon=0.01, + members_per_district=members_per_district, +) +``` + +`MultiMemberReCom` offers the same four non-reversible variants as `ReCom`, plus +`build_multi_member_recom_proposal_fn` for the full parameter set. Three things differ from the +single-member case: + +- `pop_target` is the population for a _single member_, so it is the total population divided by the + total number of members, not by the number of districts. +- Member counts attach to district labels and stay fixed for the whole run. The keys of + `members_per_district` must match the partition's district labels exactly, and every count must be + a positive integer. +- The equal-population constraint is not appropriate, since districts are deliberately unequal in + size. Use `within_percent_of_ideal_population_per_member` instead. + +There is no reversible multi-member variant, and no random multi-member seed generation yet; build a +starting plan by merging single-member districts. The [ReCom user guide](../user/recom.ipynb) has a +worked example. ### Analyzing Data after running a MarkovChain() -After running a MarkovChain(), legacy code often -uses NetworkX functionality - to extract data from the -graph and/or partition or to plot the results. -A function has been provided to convert the embedded -RustworkX.PyGraph back to become a NetworkX.Graph -object with all of the data preserved (the -`to_networkx_graph()` function, specifically). - -So, if your legacy code uses NetworkX functionality -to post-process the data after running a MarkovChain(), -or perhaps to plot results, -then you probably want to convert the graph associated -with your Partition object to be a NetworkX.Graph -object: +After running a MarkovChain(), legacy code often uses NetworkX functionality to extract data from +the graph or partition, or to plot the results. The `to_networkx_graph()` function converts the +embedded RustworkX.PyGraph back into a NetworkX.Graph object with all of the data preserved. + +So, if your legacy code uses NetworkX functionality to post-process the data after running a +MarkovChain(), or perhaps to plot results, then you probably want to convert the graph associated +with your Partition object to be a NetworkX.Graph object: ```python - my_gc_graph = my_partition.graph - - my_networkx_graph = my_gc_graph.to_networkx_graph() +my_gc_graph = my_partition.graph + +my_networkx_graph = my_gc_graph.to_networkx_graph() - ... do post-processing using my_networkx_graph ... +... do post-processing using my_networkx_graph ... ``` -Note that all node_data and edge_data that was -generated during the procssing of the MarkovChain() +Note that all node_data and edge_data that was generated during the processing of the MarkovChain() is preserved in the generated NetworkX.Graph object. -For many legacy projects, this is all that will be required. -However, if your post MarkovChain() processing depends on -the original NetworkX.Graph node_ids and/or edge_ids, then -you have work to do, because the node_ids and edge_ids that -are stored in the chain are RustworkX.PyGraph node_ids and -edge_ids. +For many legacy projects, this is all that will be required. However, if your post MarkovChain() +processing depends on the original NetworkX.Graph node_ids and/or edge_ids, then you have work to +do, because the node_ids and edge_ids that are stored in the chain are RustworkX.PyGraph node_ids +and edge_ids. -You will need to translate those node_ids and edge_ids back -to the original NetworkX.Graph node_ids and edge_ids. +You will need to translate those node_ids and edge_ids back to the original NetworkX.Graph node_ids +and edge_ids. -We have provided routines to help you do this translation. -Those routines are discussed below. +We have provided routines to help you do this translation. Those routines are discussed below. #### Translating node_ids and edge_ids after running a MarkovChain() -After running MarkovChain() results can be -stored in several places: +After running a MarkovChain(), results can be stored in several places: + +- In the data associated with the nodes or the edges in the graph, or -* In the data associated with the nodes or the - edges in the graph, or +- In the data associated with a partition - for instance in the assignments of nodes to a district + or in the attributes of the partition (created by updaters). -* In the data associated with a partition - for - instance in the assignments of nodes to a - district or in the attributes of the partition - (created by updaters). +As stated above, the graph used in the MarkovChain() processing is a RustworkX.PyGraph object, and +the node_ids and the edge_ids for that graph are NOT necessarily the same as the node_ids and the +edge_ids (pair of node_ids) that were used to build the graph. -As stated above, the graph used in the MarkovChain() -processing is a RustworkX.PyGraph object, and -the node_ids and the edge_ids for that graph are NOT -necessarily the same as the node_ids and the edge_ids (pair -of node_ids) that were used to build the graph. +So, if your code depends on the original NetworkX.Graph node_ids and edge_ids, you will need to +translate the node_ids and edge_ids back into what they were in the original NetworkX.Graph object. +There are routines to do this: -So, if your code depends on the original -NetworkX.Graph node_ids and edge_ids, -you will need to translate the node_ids and edge_ids -back into what they were in the original NetworkX.Graph -object. There are routines to do this: ```python - def original_nx_node_id_for_internal_node_id( - self, - internal_node_id: Any - ) -> Any: - - def original_nx_node_ids_for_set( - self, - set_of_node_ids: set[Any] - ) -> Any: - - def original_nx_node_ids_for_list( - self, - list_of_node_ids: list[Any] - ) -> list[Any]: +def original_nx_node_id_for_internal_node_id( + self, + internal_node_id: Any +) -> Any: + +def original_nx_node_ids_for_set( + self, + set_of_node_ids: set[Any] +) -> Any: + +def original_nx_node_ids_for_list( + self, + list_of_node_ids: list[Any] +) -> list[Any]: ``` -which are attached as methods of the new GerryChain Graph -object and can thus be invoked as: + +which are attached as methods of the new GerryChain Graph object and can thus be invoked as: + ```python - my_graph.original_nx_node_id_for_internal_node_id(internal_node_id) - my_graph.original_nx_node_ids_for_set(set_of_node_ids) - my_graph.original_nx_node_ids_for_list(list_of_node_ids) +my_graph.original_nx_node_id_for_internal_node_id(internal_node_id) +my_graph.original_nx_node_ids_for_set(set_of_node_ids) +my_graph.original_nx_node_ids_for_list(list_of_node_ids) ``` -> Note: the "internal_node_id" in the above routines refers -> to the RustworkX.PyGraph node_id as do the sets and lists -> of node_ids. - -If you wish to extract NetworkX.Graph edges then you will need -a tuple of node_ids. First you need to get the -RustworkX.PyGraph node_ids for an edge, given its -RustworkX edge_id (an integer), and then you need -to use the `original_nx_node_id_for_internal_node_id()` -routine to get the original NX node_ids for the edge. The routines to do this are: + +> Note: the "internal_node_id" in the above routines refers to the RustworkX.PyGraph node_id as do +> the sets and lists of node_ids. + +If you wish to extract NetworkX.Graph edges, then you will need a tuple of node_ids. First you need +to get the RustworkX.PyGraph node_ids for an edge, given its RustworkX edge_id (an integer), and +then you need to use the `original_nx_node_id_for_internal_node_id()` routine to get the original NX +node_ids for the edge. The routines to do this are: + ```python - def get_edge_from_edge_id( - self, - edge_id: Any - ) -> tuple[Any, Any]: - def original_nx_node_id_for_internal_node_id( - self, - internal_node_id: Any - ) -> Any: +def get_edge_from_edge_id( + self, + edge_id: Any +) -> tuple[Any, Any]: +def original_nx_node_id_for_internal_node_id( + self, + internal_node_id: Any +) -> Any: ``` -So, to extract the NetworkX.Graph edge for a specific edge from a new GerryChain Graph object that embeds a RustworkX.PyGraph object, you would need to do: + +So, to extract the NetworkX.Graph edge for a specific edge from a new GerryChain Graph object that +embeds a RustworkX.PyGraph object, you would need to do: + ```python - rx_edge = my_graph.get_edge_from_edge_id(my_edge_id) - nx_node_id_1 = ( - my_graph.original_nx_node_id_for_internal_node_id(rx_edge[0] - ) - nx_node_id_2 = ( - my_graph.original_nx_node_id_for_internal_node_id(rx_edge[1] - ) - my_nx_edge = (nx_node_id_1, nx_node_id_2) +rx_edge = my_graph.get_edge_from_edge_id(my_edge_id) +nx_node_id_1 = my_graph.original_nx_node_id_for_internal_node_id(rx_edge[0]) +nx_node_id_2 = my_graph.original_nx_node_id_for_internal_node_id(rx_edge[1]) +my_nx_edge = (nx_node_id_1, nx_node_id_2) ``` ## Node IDs and Edge IDs - a deep dive... -If you have written custom code, (for instance, a -custom updater or a spanning function or a bipartition -function) then you need to understand how node_ids -and edge_ids have changed. +If you have written custom code (for instance, a custom updater, spanning-tree function, or +bipartition function), then you need to understand how node_ids and edge_ids have changed. -In NetworkX a node_id can be any hashable Python object -other than `None` (an integer, a string, a tuple, etc.). -In RustworkX a node_id is always -an integer, and even more, the set of node_ids is always -a sequential list of integers starting at 0 with no -gaps. +In NetworkX, a node_id can be any hashable Python object other than `None` (an integer, a string, a +tuple, etc.). In RustworkX, a node_id is always an integer, and in the graphs that GerryChain builds +the node_ids always run sequentially from 0 with no gaps. RustworkX on its own can leave gaps when +nodes are removed, but GerryChain never removes them: the embedded graph is frozen once a Partition +is created. -In NetworkX an edge is a tuple of node_ids and an edge_id is the ***same*** tuple of node_ids, -so there is no difference between an edge and an edge_id. -However, in RustworkX while edges are still -a tuple of node_ids, an edge_id is an integer. -So an edge and an edge_id are different. +In NetworkX, both an edge and its edge_id are represented by the same tuple of node_ids. In +RustworkX, an edge is still a tuple of node_ids, but its edge_id is an integer. This creates some interesting challenges... -As discussed above, the new codebase converts -NetworkX based graphs to be graphs based on RustworkX. -The codebase also maintains a mapping from NetworkX -node_ids to RustworkX node_ids and vice versa. - -So, legacy code that involves node_ids needs to be -aware of whether it is manipulating "original" NetworkX -node_ids or "internal" RustworkX node_ids used during MarkovChain() -calculations. This is relatively straight-forward - you -just need to know which kind of node_id you need and -perhaps convert from one kind to the other. - -Edges and edge_ids are more challenging, because you -need to determine whether the "edge" in your legacy -code is referring to a tuple (you want to get at -the node_ids for the edge) or to the ID for the edge -(needed when you want to get data associated with -the edge). There are routines to get an edge (tuple) -from an edge_id and vice versa. - -Remember that before running MarkovChain(), or more -accurately, until you create a Partition object, the -embedded graph is still NetworkX based and so you -can probably be clever and continue to use NetworkX -to deal with edges - one way to do this is to ask -to get direct access to the embedded NetworkX graph -and operate directly on the embedded graph. The -routine to gain access to the embedded NetworkX.Graph -object is: +As discussed above, the new codebase converts NetworkX based graphs to be graphs based on RustworkX. +The codebase also maintains a mapping from NetworkX node_ids to RustworkX node_ids and vice versa. + +So, legacy code that involves node_ids needs to be aware of whether it is manipulating "original" +NetworkX node_ids or "internal" RustworkX node_ids used during MarkovChain() calculations. This is +relatively straightforward - you just need to know which kind of node_id you need and perhaps +convert from one kind to the other. + +Edges and edge_ids are more challenging, because you need to determine whether the "edge" in your +legacy code is referring to a tuple (you want to get at the node_ids for the edge) or to the ID for +the edge (needed when you want to get data associated with the edge). There are routines to get an +edge (tuple) from an edge_id and vice versa. + +Remember that before running MarkovChain(), or more accurately, until you create a Partition object, +the embedded graph is still NetworkX based and so you can probably be clever and continue to use +NetworkX to deal with edges - one way to do this is to ask to get direct access to the embedded +NetworkX graph and operate directly on the embedded graph. The routine to gain access to the +embedded NetworkX.Graph object is: + ```python - def get_nx_graph(self) -> networkx.Graph: +def get_nx_graph(self) -> networkx.Graph: ``` -The routines to go back and forth from edge_ids -to/from edges are: + +The routines to convert between edge_ids and edges are: + ```python - def get_edge_from_edge_id(self, edge_id: Any) -> tuple[Any, Any]: +def get_edge_from_edge_id(self, edge_id: Any) -> tuple[Any, Any]: - def get_edge_id_from_edge(self, edge: tuple[Any, Any]) -> Any: +def get_edge_id_from_edge(self, edge: tuple[Any, Any]) -> Any: ``` -Another issue is iterating over nodes and edges. -The routines to do this are: + +Another issue is iterating over nodes and edges. These are properties, not methods, so access them +without parentheses: + ```python - def edge_indices(self) -> set[Any]: # edge_ids +@property +def edge_indices(self) -> set[Any]: # edge_ids - def edges(self) -> set[tuple[Any, Any]]: +@property +def edges(self) -> set[tuple[Any, Any]]: - def node_indices(self) -> set[Any] +@property +def node_indices(self) -> set[Any]: - def nodes(self) -> list[Any]: +@property +def nodes(self) -> list[Any]: ``` -**Caution:** In pre v1.0.0 code -graph.nodes[node_id] would return a -data dict for the data associated with -the given node_id. However, in v1.0.0 this -code will interpret the node_id as an index -into the list of nodes defined for the -graph. This might end up being a subtle and -hence hard bug to find, so please search -your legacy code for any uses of -graph.nodes[node_id]... - -Note that node_indices and nodes return the same thing - except -that one is a list and the other a set. The nodes function was -retained because legacy code uses it a lot to iterate over the graph. - -The edge_indices function returns edge_ids which for a graph -with an embedded NetworkX graph will return tuples of -node_ids and for a graph with an embedded RustworkX graph -will return integers. The edges function always returns tuples -of node_ids but for NetworkX the node_ids will be NetworkX node_ids -which do not have to be integers while for RustworkX the node_ids -will always be integers. - -The last and most important issue involving node_ids and -edge_ids is subgraphs. We have noted already that node_ids -and edge_ids in RustworkX are always sequential integers with no -gaps starting at 0. This is one of the things that makes -it possible for RustworkX to be faster than NetworkX. However, -it also means that node_ids and edge_ids in RustworkX -subgraphs are also sequential integers with no gaps starting -at 0 - which means that the node_ids for the same node might -be different in a subgraph containing that node than in that -same node's id in the parent graph. This can cause some -headaches pretty big headaches for anyone working with them, -but we anticipate that most users will not need to deal with -this pain-point. - -There are two major issues that result from subgraphs having -new node_ids and edge_ids: - -1. Results of computations performed on subgraphs that contain - node_id or edge_id information will need to be converted - to refer to the corresponding node_ids and/or edge_ids in - the parent graph. For instance, if a subroutine operates - on a subgraph and returns flips, then the node_ids in the - flips will need to be converted back to be parent graph - node_ids. - -2. There is a danger that a computation mixes parent graph - node/edge_ids and subgraph node/edge_ids. To guard - against this in the new codebase, calls on graph.subgraph() - are always made as actual parameters to a function call. - This guarantees that the subgraph IDs cannot be used - in the caller's context. In addition, as mentioned - above in #1, it is the responsibility of the called - function to convert any IDs in the return value to be - those that are appropriate for the parent graph. - -To make it possible to translate from a subgraph back to -the parent, whenever a subgraph is created, a mapping -(dictionary) is created that converts the subgraph node_id -to the parent graph's node_id. This is hidden slightly -from the public interface, but is accessible as -`_node_id_to_parent_node_id_map` and two routines exist to -make it convenient to convert flips and sets of nodes back -to the node_ids appropriate for the parent graph: +That is, write `my_graph.nodes`, not `my_graph.nodes()`. + +**Caution:** In pre v1.0.0 code graph.nodes[node_id] would return a data dict for the data +associated with the given node_id. However, in v1.0.0 this code will interpret the node_id as an +index into the list of nodes defined for the graph. This might end up being a subtle and hence hard +bug to find, so please search your legacy code for any uses of graph.nodes[node_id]... + +Note that node_indices and nodes return the same thing - except that one is a list and the other a +set. The nodes function was retained because legacy code uses it a lot to iterate over the graph. + +The edge_indices function returns edge_ids which for a graph with an embedded NetworkX graph will +return tuples of node_ids and for a graph with an embedded RustworkX graph will return integers. The +edges function always returns tuples of node_ids but for NetworkX the node_ids will be NetworkX +node_ids which do not have to be integers while for RustworkX the node_ids will always be integers. + +The last and most important issue involving node_ids and edge_ids is subgraphs. We have noted +already that node_ids and edge_ids in RustworkX are always sequential integers with no gaps starting +at 0. This is one of the things that makes it possible for RustworkX to be faster than NetworkX. +However, node_ids and edge_ids in RustworkX subgraphs are also sequential integers with no gaps +starting at 0. The same node can therefore have a different ID in a subgraph than in its parent +graph. This can cause some pretty big headaches, but we anticipate that most users will not need to +deal with this pain point. + +There are two major issues that result from subgraphs having new node_ids and edge_ids: + +1. Results of computations performed on subgraphs that contain node_id or edge_id information will + need to be converted to refer to the corresponding node_ids and/or edge_ids in the parent graph. + For instance, if a subroutine operates on a subgraph and returns flips, then the node_ids in the + flips will need to be converted back into parent graph node_ids. + +2. There is a danger that a computation mixes parent graph node/edge_ids and subgraph node/edge_ids. + To guard against this in the new codebase, calls on graph.subgraph() are always made as actual + parameters to a function call. This guarantees that the subgraph IDs cannot be used in the + caller's context. In addition, as mentioned above in #1, it is the responsibility of the called + function to convert any returned IDs into the corresponding parent graph IDs. + +To make it possible to translate from a subgraph back to the parent, whenever a subgraph is created, +a mapping (dictionary) is created that converts the subgraph node_id to the parent graph's node_id. +This is hidden slightly from the public interface, but is accessible as +`_node_id_to_parent_node_id_map` and two routines exist to make it convenient to convert flips and +sets of nodes back to the node_ids appropriate for the parent graph: + ```python - def translate_subgraph_node_ids_for_flips( - self, - flips: dict[Any, int] - ) -> dict[Any, int]: - - def translate_subgraph_node_ids_for_set_of_nodes( - self, - set_of_nodes: set[Any] - ) -> set[Any]: +def translate_subgraph_node_ids_for_flips( + self, + flips: dict[Any, int] +) -> dict[Any, int]: + +def translate_subgraph_node_ids_for_set_of_nodes( + self, + set_of_nodes: set[Any] +) -> set[Any]: ``` diff --git a/docs/user/images/gerrychain_demo.gif b/docs/user/images/gerrychain_demo.gif index 1b0e8a22..ecb50951 100644 Binary files a/docs/user/images/gerrychain_demo.gif and b/docs/user/images/gerrychain_demo.gif differ diff --git a/docs/user/install.md b/docs/user/install.md index 4f7e0a0b..4c81f979 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -85,7 +85,7 @@ some brief instructions for doing so on different systems: "Location" and from there you can copy the path to your clipboard and paste it in the terminal. -### Using uv (recommended) +### Using uv (Recommended) We recommend [uv](https://docs.astral.sh/uv/), which manages both Python itself and your project's packages. When using uv it is not necessary to have Python installed on the machine: the Astral diff --git a/docs/user/intro.md b/docs/user/intro.md index 252f2868..5393e396 100644 --- a/docs/user/intro.md +++ b/docs/user/intro.md @@ -39,7 +39,7 @@ connections, islands, data errors, or jurisdiction-specific contiguity rules. A district then is a collection of nodes, and a district plan is the overall assignment of nodes to districts. -## Parts of the chain +## Parts of the Chain The Markov Chain's behavior is entirely governed by four modular layers: **proposals**, **updaters**, **validators**, @@ -84,7 +84,7 @@ Acceptance functions techniques such as the [Metropolis-Hastings] algorithm. ::: -## Workflow for a new GerryChain Project +## Workflow for a New GerryChain Project - **Get the data** : You will need data that defines distinct geographic areas (your nodes) diff --git a/docs/user/partitions.ipynb b/docs/user/partitions.ipynb index 254aedf2..8aaf12c7 100644 --- a/docs/user/partitions.ipynb +++ b/docs/user/partitions.ipynb @@ -5,7 +5,7 @@ "id": "1d8393a7", "metadata": {}, "source": [ - "# Working with Partitions\n", + "# Working With Partitions\n", "\n", "
\n", " Download PA File\n", @@ -52,7 +52,7 @@ "id": "b1d443d5", "metadata": {}, "source": [ - "## Creating a partition\n", + "## Creating a Partition\n", "\n", "There are a couple of ways in which we could make a partition. The first way is to\n", "just make a random assignment with a population balance of $\\varepsilon$" @@ -113,7 +113,7 @@ "id": "09b8d577", "metadata": {}, "source": [ - "## `partition.graph`: the underlying graph\n", + "## `partition.graph`: The Underlying Graph\n", "\n", "`partition.graph` is a\n", "[gerrychain.Graph](../api/graphs.rst)\n", @@ -182,7 +182,7 @@ "id": "f82107aa", "metadata": {}, "source": [ - "## `partition.assignment`: assign nodes to parts\n", + "## `partition.assignment`: Assign Nodes to Parts\n", "\n", "`partition.assignment` gives you a mapping from node IDs to part IDs (\"part\" is\n", "our generic word for \"district\"). It is a custom data structure but you can use\n", @@ -209,7 +209,7 @@ "id": "89b762fa", "metadata": {}, "source": [ - "## `partition.parts`: the nodes in each part\n", + "## `partition.parts`: The Nodes in Each Part\n", "\n", "`partition.parts` gives you a mapping from each part ID to the set of nodes that\n", "belong to that part. This is the \"opposite\" mapping of `assignment`.\n", @@ -234,7 +234,7 @@ "id": "5622fb95", "metadata": {}, "source": [ - "## `partition.subgraphs`: the subgraphs of each part\n", + "## `partition.subgraphs`: The Subgraphs of Each Part\n", "\n", "For each part of our partition, we can look at the _subgraph_ that it defines.\n", "That is, we can look at the graph made up of all the nodes in a certain part and\n", @@ -262,7 +262,7 @@ "id": "a3cc3b5a", "metadata": {}, "source": [ - "## Outputs of updaters\n", + "## Outputs of Updaters\n", "\n", "The other main way we can extract information from `partition` is through the\n", "updaters that we configured when we created it. We gave `partition` just one\n", @@ -313,4 +313,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/user/quickstart.ipynb b/docs/user/quickstart.ipynb index 9e456a21..4433aa74 100644 --- a/docs/user/quickstart.ipynb +++ b/docs/user/quickstart.ipynb @@ -5,7 +5,7 @@ "id": "58f1e441", "metadata": {}, "source": [ - "# Getting started with GerryChain\n", + "# Getting Started With GerryChain\n", "\n", "