Partitioning a colored grid

爷,独闯天下 提交于 2021-02-07 04:11:52

问题


I want to gerrymander a grid. What this means is that given an n x m grid with squares colored yellow and red, I want to partition the grid in such a way that yellow will be the majority color in as many partions as possible, as in this image:

All partitions must be continuous, the same number of squares, and all squares will be colored (although it would be awesome if an algorithm could generalize to grids with some squares not colored).

I'm not sure how to even go about 'algorithmitizing' this problem, past brute forcing every possible partition which is hard enough in and of itself and is incredibly inefficient.

What's the best way to accomplish this?


回答1:


tl;dr: Used simulated annealing, swapping voters between districts. The demo at the bottom lets you do the swap step (Randomly evolve) and optimize for a gerrymandered district (anneal)

General Optimization

We can frame this as optimization problem, where we're trying to maximize the number of districts that red wins and minimize the number of districts that blue wins.

Let's formalize this:

function heuristic(state) {
  return state.districts_that_red_wins - state.districts_that_blue_wins;
}

where state is an assignment of voters to districts.

This will work, but could be improved a little bit. Let's introduce the notion of wasted votes to nudge our optimization in the right direction. We want to maximize wasted blue votes and minimize wasted red votes. I'm arbitrarily weighting them as being 1/10 as important as a district since there are 10 voters per district. That gives us a function to maximize:

function heuristic(state) {
  let {red_wins, blue_wins, tied, wasted_red_votes, wasted_blue_votes} = get_winners(state, voters);
  return red_wins - blue_wins - 0.1 * wasted_red_votes + 0.1 * wasted_blue_votes;
}

You may want to optimize other things e.g. compactness of districts. You can add these to the heursitic function.

Simulated Annealing

Let's pick an optimization algorithm to optimize state. We're subject to some constraints that make it hard to generate random maps that fit these conditions. And I suspect it's impossible to find the best district allocation without brute force, which would be impossible. So let's use an algorithm that lets us iteratively improve our solution.

I like simulated annealing because it's easy to implement and understand, and performs a little bit better than hill-climbing by preventing us from getting stuck in early local optima. My temperature function is simply max(0.8 - iterations/total_iterations, 0). At the beginning, 20% of the time we'll take a new state if and only if it's better; the other 80% we'll take the new state regardless. This slowly becomes more like hill-climbing until we're 80% of the way through our computation budget, then we only change the state if it improves our heuristic score. The choice of 80% is totally arbitrary.

To implement SA, we need an initial state (or a way of generating it). I'm going to use the "Perfect Representation" as the initial state for simplicity, mostly because I don't know how to generate random connected, equally-sized districts. We also need a way of making a small change to the state. I'll discuss this in the next section. Finally, we need a way of scoring the states. Let's use the function from the previous section because it's pretty cheap to compute.

If you're interested, look at the anneal function, or just read the Wikipedia article.

State Evolution

For this problem, given a state, we need to find another similar state that won't change the heuristic score very much to see if we're going in the right direction. I chose to find a pair of points, from two different districts and swap them.

We need to maintain some invariants:

  • Keep all districts continuous
  • Keep all districts the same size

The second one is easy: always swap points, never (permanently) assign from one district to another. The first is trickier, and we need a brief detour into graph theory. An articulation point (see picture) is a point that cannot be removed without bisecting a graph. For us, this means that we cannot remove articulation points without making a district discontinuous. Once we have a point that can be removed, we need to make sure it's added to a district it's adjacent to. This is pretty simple.

articulation points

Since we're on a grid and all districts must be continuous, we can just consider the immediate neighbours of a point to determine whether it's an articulation point. If you can't see this, it's not super important, you can use the algorithm that works in general on a graph. I found the grid version easier because it involves no recursion.

See the is_swappable function if you're interested. This is what the "Randomly evolve" button in the demo does.

High-level, our code for evolving our state should look like this:

function evolve_state() {
    randomly pick a source district
    randomly pick a non-articulation point, source_point, from source_district

    for each neighbour of the articulation point
        if the neighbour is in a different district target_district
            temporarily remove source_point from source_district and add it to target_district
            if any articulation point (other than source point), target_point, in target_district is adjacent to source_district
                swap target_point and source_point
                return;
            restore source_point
}

Note: I implemented this in a way that randomly iterates through all source_district, source_point, neighbour, target_district and target_point because I wasn't sure how sparse this would be. If you implement this pseudocode exactly, you will likely need more iterations than I use to converge to a solution.

See evolve_state if you're interested.

Every function I haven't called out is a utility function or for drawing.

Demo

Now for a demo. :) (Uses Lodash for utility functions and Mithril for DOM manipulation)

If you want to play around with this, it might be easier to use my Plunker: http://plnkr.co/edit/Bho4qhQBKRShXWX8fHmt.

const RED = 'R';
const BLUE = 'B';
const VOTERS_PER_DISTRICT = 10;
const neighbours = [{x: 1, y: 0}, {x: 0, y: 1}, {x: -1, y: 0}, {x: 0, y: -1}];

/* UTILITY FUNCTIONS */

/**
 Create a generator that starts at a random point p, 0 <= p < max
 The generator will iterate over values p, p+1, ... max, 0, ... p-1
*/
function* cyclic_generator(max) {
    let start = _.random(max);

    for (let i=0; i<max; i++) {
        yield (i + start) % max;
    }
}

/**
  Return grid[x][y] if x and y are within grid. Otherwise return undefined
*/
function grid_get(grid, x, y) {
  if(_.isUndefined(grid[x])) {
    return undefined;
  }
  else {
    return grid[x][y];
  }
}

/** Generates a 2d array red and blue voters */
function generate_voters() {
  return _.times(5, x => _.times(10, () => {return {vote: x > 2 ? RED : BLUE, district_vote: 0xffffff}}))
}

/** Generate an initial state */ 
function generate_initial_state() {
  return _.range(5).map(x => _.range(10).map(y => {return {x, y}}));
}

/**
  Randomly swap two squares in the grid between two districts. 
  The new square to be added must be connected to the district, and the 
  old square must not break another district in two
 */
function evolve_state(state) {
  state = _.cloneDeep(state);
  
  // Create a grid with the district number
  let point_to_district = _.range(5).map(x => _.range(10).map(y => -1));
  state.forEach((district, i) => district.forEach(({x, y}) => point_to_district[x][y] = i));

  // swap a point from source_district to target_district.
  // then swap a point from target_district to source_district. 
  for(let source_district_idx of cyclic_generator(state.length)) {
    let source_articulation_points = state[source_district_idx].filter(point => is_swappable(point_to_district, point, source_district_idx));
    for(let source_point_idx of cyclic_generator(source_articulation_points.length)) {
      let source_point = source_articulation_points[source_point_idx];
      
      for(let neighbour_idx of cyclic_generator(4)) {
        let neighbour = neighbours[neighbour_idx];
        let target_district_idx = grid_get(point_to_district, source_point.x + neighbour.x, source_point.y + neighbour.y);
        if (_.isUndefined(target_district_idx) || target_district_idx == source_district_idx) {
          continue;
        }
        
        // swap the source point
        point_to_district[source_point.x][source_point.y] = target_district_idx;
        _.remove(state[source_district_idx], ({x, y}) => x == source_point.x && y == source_point.y); 
        // we don't add the point the the target array yet because we don't want to swap that point back
        
        // try to find a point in target_district that we can move to source_district
        let target_articulation_points = state[target_district_idx].filter(point => is_swappable(point_to_district, point, target_district_idx));
        for(let target_point_idx of cyclic_generator(target_articulation_points.length)) {
          let target_point = target_articulation_points[target_point_idx];
          for(let n of neighbours) {
            if(grid_get(point_to_district, target_point.x + n.x, target_point.y + n.y) === source_district_idx) {
              
              // found a point that we can swap!
              // console.log('swapping points!', source_point, target_point);
              
              _.remove(state[target_district_idx], ({x, y}) => x == target_point.x && y == target_point.y);
              state[target_district_idx].push(source_point);
              state[source_district_idx].push(target_point);
              return state;
            }
          }
        }
        
        // unswap source point since we were unsuccessful
        point_to_district[source_point.x][source_point.y] = source_district_idx;
        state[source_district_idx].push(source_point);
      }
    }
  }
  throw 'Could not find any states to swap' // this should never happen, since there will always be the option of reversing the previous step
}

/*
  Return whether a point can be removed from a district without creating disjoint districts. 
  In graph theory, points that cannot be removed are articulation points. 
  For a general algorithm, see: https://stackoverflow.com/questions/15873153/explanation-of-algorithm-for-finding-articulation-points-or-cut-vertices-of-a-gr
  
  My version takes advantage of the fact that we're on a grid and that all the districts must be continuous, 
  so we can consider only the immediate neighbours of a point.
*/
function is_swappable(grid, p, district) {
  
  // if the the point is not even in this district, it makes no sense for this to consider this point at all
  if(grid[p.x][p.y] != district) {
    return false;
  }
  
  // if two opposite edges are part of this district, this is an articulation point
  // .x.  x is an articulation point
  // Exception:
  // .x.  x is not an articulation point
  // ...
  if (grid_get(grid, p.x+1, p.y) === district && grid_get(grid, p.x-1, p.y) === district && grid_get(grid, p.x, p.y+1) !== district && grid_get(grid, p.x, p.y-1) !== district) {
    return false;
  }
  if (grid_get(grid, p.x, p.y+1) === district && grid_get(grid, p.x, p.y-1) === district && grid_get(grid, p.x+1, p.y) !== district && grid_get(grid, p.x-1, p.y) !== district) {
    return false;
  }
  
  // check if any corners are missing:
  // .x  x is not an articulation point      .x   x is an articulation point
  // ..                                       .
  for(let i = 0; i < 4; i++) {
    let nx = neighbours[i].x;
    let ny = neighbours[i].y;
    let nx2 = neighbours[(i+1)%4].x;
    let ny2 = neighbours[(i+1)%4].y;
    
    if (grid_get(grid, p.x+nx, p.y+ny) === district && grid_get(grid, p.x+nx2, p.y+ny2) === district && grid_get(grid, p.x+nx+nx2, p.y+ny+ny2) !== district) {
      return false;
    }
  }
  return true;
}

/** Count how many districts each party wins */
function get_winners(state, voters) {
  let red_wins = 0;
  let blue_wins = 0;
  let tied = 0;
  
  let wasted_red_votes= 0; // see https://en.wikipedia.org/wiki/Wasted_vote
  let wasted_blue_votes = 0;
  
  state.forEach(district => {
    let counts = _.countBy(district.map(({x, y}) => voters[x][y].vote))
    
    if ((counts[BLUE] || 0) > (counts[RED] || 0)) {
      blue_wins++;
      wasted_blue_votes += (counts[BLUE] || 0) - VOTERS_PER_DISTRICT / 2 - 1;
      wasted_red_votes += (counts[RED] || 0);
    }
    else if ((counts[RED] || 0) > (counts[BLUE] || 0)) {
      red_wins++;
      wasted_red_votes += (counts[RED] || 0) - VOTERS_PER_DISTRICT / 2 - 1;
      wasted_blue_votes += (counts[BLUE] || 0);
    }
    else {
      tied++;
    }
  });
  return {red_wins, blue_wins, tied, wasted_red_votes, wasted_blue_votes};
}

/* GUI */

/* Display a grid showing which districts each party won */
function render_districts(state, voters) {
  let red_districts = 0;
  let blue_districts = 0;
  let grey_districts = 0;
  
  // Color each district
  state.forEach(district => {
    let counts = _.countBy(district.map(({x, y}) => voters[x][y].vote))

    let district_color;
    if ((counts[BLUE] || 0) > (counts[RED] || 0)) {
      district_color = 'blue' + blue_districts++;
    }
    else if ((counts[RED] || 0) > (counts[BLUE] || 0)) {
      district_color = 'red' + red_districts++;
    }
    else {
      district_color = 'grey' + grey_districts++;
    }
    
    district.map(({x, y}) => voters[x][y].district_color = district_color);
  });
  
  return m('table', [
    m('tbody', voters.map(row => 
      m('tr', row.map(cell => m('td', {'class': cell.district_color}, cell.vote)))
    ))
  ]);
}

/** Score a state with four criteria: 
  - maximize number of red districts
  - minimize number of blue districts
  - minimize number of red voters in districts that red wins
  - maximize number of blue voters in districts that blue wins
  
  The first two criteria are arbitrarily worth 10x more than the latter two
  The latter two are to nudge the final result toward the correct solution
 */
function heuristic(state) {
  let {red_wins, blue_wins, tied, wasted_red_votes, wasted_blue_votes} = get_winners(state, voters);
  return red_wins - blue_wins - 0.1 * wasted_red_votes + 0.1 * wasted_blue_votes;
}

/**
 Optimization routine to find the maximum of prob_fcn.
 prob_fcn: function to maximize. should take state as its argument
 transition: how to generate another state from the previous state
 initialize_state: a function that returns an initial state
 iters: number of iterations to run
 
 Stolen from my repo here: https://github.com/c2huc2hu/automated-cryptanalysis/blob/master/part3.js
*/
function anneal(prob_fcn, transition, initialize_state, seeds=1, iters=1000) {
    let best_result = initialize_state();

    for(let i=0; i<seeds; i++) {
        let curr_state = initialize_state();
        let curr_cost = prob_fcn(curr_state);

        // perform annealing. do a few extra steps with temp=0 to refine the final solution
        for(let j=0; j<iters; j++) {
            let candidate_state = transition(curr_state);
            let candidate_cost = prob_fcn(candidate_state);
            temp = 0.8 - j / iters;

            if(candidate_cost >= curr_cost || Math.random() < temp) {
                curr_state = candidate_state;
                curr_cost = candidate_cost;
            }
        }

        if(prob_fcn(curr_state) > prob_fcn(best_result)) {
            best_result = curr_state;
        }
    }

    return best_result;
}

let voters = generate_voters();
let state = generate_initial_state();

// main rendering code: this code renders the UI
m.mount(document.getElementById('actions'), {view: function() {
  return m('div', [
    m('button', {onclick: () => state = generate_initial_state()}, 'Reset'),
    m('button', {onclick: () => state = evolve_state(state)}, 'Randomly evolve'), // randomly evolves

    m('br'),
    m('label', {'for': 'radio-blue'}, 'Gerrymander for blue'),
    m('input', {type: 'radio', name: 'heuristic', value: 'blue', id: 'radio-blue'}),
    m('label', {'for': 'radio-red'}, 'Gerrymander for red'),
    m('input', {type: 'radio', name: 'heuristic', value: 'red', id: 'radio-red'}),
    m('br'),
    m('label', {'for': 'anneal-steps'}, 'Anneal steps: '),
    m('input', {id: 'anneal-steps', type: 'number', value: '500'}),
    m('button', {onclick: function() {
      let minimize = document.getElementById('radio-red').checked;
      let _heuristic = minimize ? heuristic : state => -heuristic(state)
      
      let new_state = anneal(_heuristic, evolve_state, generate_initial_state, 1, parseInt(document.getElementById('anneal-steps').value));
      if(_heuristic(new_state) > _heuristic(state)) {
        state = new_state;
      }
      else {
        console.log('found no better solutions')
      }
    }}, 'Anneal!'),
  ]);
}});

// This renders the grid
m.mount(document.getElementById('grid'), {
  view: function() {
    return render_districts(state, voters)
  }
});

// state = anneal(heuristic, evolve_state, generate_initial_state, 5, 1000);
document.getElementById('radio-red').checked = true;
m.redraw();
/* Layout */

table {
  border: solid 1px black;
}

td {
  padding: 5px;
  border: solid 1px black;
}

button {
  margin: 10px;
}

p {
  max-width: 500px;
}

/* Colour classes. In hindsight, this wasn't a good idea */
.red0 {
  background-color: red;
}
.red1 {
  background-color: darkred;
}
.red2 {
  background-color: pink;
}
.red3 {
  background-color: deeppink;
}
.red4 {
  background-color: lightsalmon;
}

.blue0 {
  background-color: aqua;
}
.blue1 {
  background-color: cadetblue;
}
.blue2 {
  background-color: steelblue;
}
.blue3 {
  background-color: royalblue;
}
.blue4 {
  background-color: midnightblue;
}

.grey0 {
  background-color: lightgrey;
}
.grey1 {
  background-color: silver;
}
.grey2 {
  background-color: darkgray;
}
.grey3 {
  background-color: gray;
}
.grey4 {
  background-color: dimgray;
}
<!DOCTYPE html>
<html>

  <head>
    <script data-require="lodash.js@4.17.4" data-semver="4.17.4" src="https://cdn.jsdelivr.net/npm/lodash@4.17.4/lodash.min.js"></script>
    <script data-require="mithril@1.0.1" data-semver="1.0.1" src="https://cdnjs.cloudflare.com/ajax/libs/mithril/1.0.1/mithril.js"></script>
    <link rel="stylesheet" href="style.css" />
  </head>

  <body>
    <h1>Gerrymandering simulation</h1>
    
    <p>
      There are two parties, red and blue (chosen because they contrast well).
      Each person will always vote a certain way, and is marked with R or B in the table. 
      People are divided into districts, shown here as groups of people marked in a single colour.
    </p>
    
    <p>
      Use the buttons below to divide up districts.
      The reset button will restore the initial state.
      The randomly-evolve button will swap two people between districts
      The anneal button will optimize for your chosen party. 
      You should limit the number of steps to ~1000 or your browser will appear to hang.
      In general, it is sufficient to run a few seeds for 500 iterations. 
    </p>
    
    <div id="grid"></div>
    
    <div id="actions"></div>
    
    <script src="script.js"></script>
  </body>

</html>


来源:https://stackoverflow.com/questions/51640083/partitioning-a-colored-grid

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!