Source code for pref_voting.spatial_profiles

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patheffects as path_effects
import seaborn as sns
from pref_voting.utility_functions import *
from pref_voting.utility_profiles import UtilityProfile

[docs] class SpatialProfile(object): """ A spatial profile is a set of candidates and voters in a multi-dimensional space. Each voter and candidate is assigned vector of floats representing their position on each issue. Args: cand_pos (dict): A dictionary mapping each candidate to their position in the space. voter_pos (dict): A dictionary mapping each voter to their position in the space. Attributes: candidates (list): A list of candidates. voters (list): A list of voters. cand_pos (dict): A dictionary mapping each candidate to their position in the space. voter_pos (dict): A dictionary mapping each voter to their position in the space. num_dims (int): The number of dimensions in the space. cand_types (dict): A dictionary mapping each candidate to their type (e.g., party affiliation). """ def __init__(self, cand_pos, voter_pos, candidate_types=None): cand_dims = [len(v) for v in cand_pos.values()] voter_dims = [len(v) for v in voter_pos.values()] assert len(cand_dims) > 0, "There must be at least one candidate." assert len(set(cand_dims)) == 1, "All candidate positions must have the same number of dimensions." assert len(voter_dims) > 0, "There must be at least one voter." assert len(set(voter_dims)) == 1, "All voter positions must have the same number of dimensions." assert cand_dims[0] == voter_dims[0], "Candidate and voter positions must have the same number of dimensions." self.candidates = sorted(list(cand_pos.keys())) self.voters = sorted(list(voter_pos.keys())) self.cand_pos = cand_pos self.voter_pos = voter_pos self.num_dims = len(list(cand_pos.values())[0]) self.candidate_types = candidate_types or {c:'unknown' for c in self.candidates} @property def num_cands(self): """ Returns the number of candidates in the profile. """ return len(self.candidates) @property def num_voters(self): """ Returns the number of voters in the profile. """ return len(self.voters)
[docs] def voter_position(self, v): """ Given a voter v, returns their position in the space. """ return self.voter_pos[v]
[docs] def candidate_position(self, c): """ Given a candidate c, returns their position in the space. """ return self.cand_pos[c]
[docs] def candidate_type(self, c): """ Given a candidate c, returns their type. """ return self.candidate_types[c]
[docs] def set_candidate_types(self, cand_types): """ Sets the types of each candidate. """ assert set(cand_types.keys()) == set(self.candidates), "The candidate types must be specified for all candidates." self.candidate_types = cand_types
[docs] def to_utility_profile(self, utility_function = None, uncertainty_function=None, batch=False, return_virtual_cand_positions=False): """ Returns a utility profile corresponding to the spatial profile. Args: utility_function (callable, optional): A function that takes two vectors and returns a float. The default utility function is the quadratic utility function. uncertainty_function (callable, optional): A function that models uncertainty and returns covariance parameters. batch (bool, optional): If True, generate positions in batches. Default is False. return_virtual_cand_positions (bool, optional): If True, return virtual candidate positions. Default is False. Returns: UtilityProfile: A utility profile corresponding to the spatial profile. (optional) Tuple[UtilityProfile, dict]: The utility profile and virtual candidate positions if `return_virtual_cand_positions` is True. """ import numpy as np from pref_voting.generate_spatial_profiles import generate_covariance utility_function = utility_function or quadratic_utility if uncertainty_function is not None: virtual_cand_positions = {} for c in self.candidates: if batch: covariance = generate_covariance(self.num_dims, *uncertainty_function(self, c, self.voters[0])) positions = np.random.multivariate_normal(self.candidate_position(c), covariance, size=len(self.voters)) else: positions = [np.random.multivariate_normal(self.candidate_position(c), generate_covariance(self.num_dims, *uncertainty_function(self, c, v))) for v in self.voters] virtual_cand_positions[c] = positions utility_profile = [ {c: utility_function(self.voter_position(v), virtual_cand_positions[c][vidx]) for c in self.candidates} for vidx, v in enumerate(self.voters) ] if return_virtual_cand_positions: return UtilityProfile(utility_profile), virtual_cand_positions else: return UtilityProfile(utility_profile) else: utility_profile = [ {c: utility_function(np.array(self.voter_position(v)), np.array(self.candidate_position(c))) for c in self.candidates} for v in self.voters ] return UtilityProfile(utility_profile)
[docs] def add_candidate(self, candidate_positions, add_multiple_candidates = False): """ Add a candidate to the spatial profile. Args: candidate_positions (list): A list of candidate positions """ if add_multiple_candidates: assert all([len(pos) == self.num_dims for pos in candidate_positions]), f"Candidates positions ({candidate_positions}) must be the same dimension as the profile dimension ({self.num_dims})" starting_cand_name = self.num_cands for c_pos in candidate_positions: self.cand_pos[starting_cand_name] = c_pos starting_cand_name += 1 elif not add_multiple_candidates: assert len(candidate_positions) == self.num_dims, f"Candidates position ({candidate_positions}) must be the same dimension as the profile dimension ({self.num_dims})" starting_cand_name = self.num_cands self.cand_pos[starting_cand_name] = candidate_positions self.candidates = sorted(list(self.cand_pos.keys()))
[docs] def move_candidate(self, cand, new_cand_pos): """ Move cand to a new position """ assert len(new_cand_pos) == self.num_dims, f"The new position {new_cand_pos} must be the same as the profile dimension: {self.num_dims}" assert cand in self.candidates, f"Candidate {cand} is not in the profile." self.cand_pos[cand] = new_cand_pos
[docs] def to_string(self): """ Returns a string representation of the spatial profile. """ sp_str = '' for c in self.candidates: sp_str += f'C-{c}:{",".join([str(x) for x in self.candidate_position(c)])}_' for v in self.voters: sp_str += f'V-{v}:{",".join([str(x) for x in self.voter_position(v)])}_' return sp_str[:-1]
[docs] @classmethod def from_string(cls, sp_str): """ Returns a spatial profile described by ``sp_str``. ``sp_str`` must be in the format produced by the :meth:`pref_voting.SpatialProfile.write` function. """ cand_positions = {} voter_positions = {} sp_data = sp_str.split('_') for d in sp_data: if d.startswith("C-"): cand,positions = d.split(':') cand_positions[int(cand[2:])] = np.array([float(x) for x in positions.split(',')]) elif d.startswith("V-"): voter,positions = d.split(':') voter_positions[int(voter[2:])] = np.array([float(x) for x in positions.split(',')]) return cls(cand_positions, voter_positions)
[docs] def view(self, show_cand_labels=False, show_voter_labels=False, bin_width=None, dpi=150): """ Displays the spatial model in a 1D, 2D, or 3D plot. Args: show_cand_labels (optional, bool): If True, displays the labels of each candidate. The default is False. show_voter_labels (optional, bool): If True, displays the labels of each voter. The default is False. Note: In 1D visualizations, voter labels are disabled regardless of this setting. bin_width (optional, float): Width of bins for grouping voters in 1D visualization. If None, a suitable width is calculated. dpi (optional, int): Resolution in dots per inch. Default is 150. """ assert self.num_dims <= 3, "Can only view profiles with 1, 2, or 3 dimensions" sns.set_theme(style="darkgrid") # Define the candidate color consistently across all dimensions candidate_color = "red" if self.num_dims == 1: # Get all voter positions voter_positions = [self.voter_position(v)[0] for v in self.voters] # Calculate histogram data if bin_width is None: # Auto-calculate a reasonable bin width based on data range position_range = max(voter_positions) - min(voter_positions) bin_width = max(position_range / 20, 0.05) # Create histogram data - exact counts bins = {} for pos in voter_positions: # Round to nearest bin binned_pos = round(pos / bin_width) * bin_width bins[binned_pos] = bins.get(binned_pos, 0) + 1 # Get the bin positions and counts bin_positions = list(bins.keys()) bin_counts = list(bins.values()) max_count = max(bin_counts) if bin_counts else 1 # Create figure with sufficient space for labels and high resolution fig, ax = plt.subplots(figsize=(10, 6), dpi=dpi) # Calculate the space needed for candidates at the top candidate_area_height = max_count * 0.3 # Plot the bars for voters bars = ax.bar( bin_positions, bin_counts, width=bin_width*0.8, alpha=0.6, color="blue", label="Voters" ) # Calculate position for candidates at the top top_line_y = max_count + 0.2 candidate_y_pos = top_line_y + candidate_area_height * 0.3 candidate_positions = [self.candidate_position(c)[0] for c in self.candidates] # Plot candidates above the histogram cand_scatter = ax.scatter(candidate_positions, [candidate_y_pos] * len(self.candidates), color=candidate_color, marker='X', s=100, zorder=5) # Create a custom legend box legend = ax.legend([cand_scatter, bars], ['Candidates', 'Voters'], loc='upper right', bbox_to_anchor=(0.99, 0.99)) # Draw the figure to get legend position for label placement fig.canvas.draw() # Get legend position for detecting label overlap if legend: legend_bbox = legend.get_window_extent().transformed(ax.transData.inverted()) legend_left = legend_bbox.x0 legend_right = legend_bbox.x1 legend_width = legend_right - legend_left else: legend_left = float('inf') legend_right = float('inf') legend_width = 0 if show_cand_labels: # Add labels to each candidate for c in self.candidates: pos = self.candidate_position(c)[0] # Buffer to detect legend overlap legend_buffer = 0.05 * legend_width # Check if candidate is under or near the legend if (pos >= legend_left - legend_buffer) and (pos <= legend_right + legend_buffer): # Place label below the marker to avoid legend overlap ax.annotate(c, (pos, candidate_y_pos), xytext=(0, -25), textcoords='offset points', ha='center', va='top', fontsize=13, fontweight='bold', color=candidate_color) else: # Otherwise place it above ax.annotate(c, (pos, candidate_y_pos), xytext=(0, 15), textcoords='offset points', ha='center', va='bottom', fontsize=13, fontweight='bold', color=candidate_color) # Set axis labels ax.set_xlabel('Position') ax.set_ylabel('Number of voters') # Configure y-axis to show integers for the histogram area ax.yaxis.set_major_locator(plt.MaxNLocator(integer=True)) # Set y-limits to include the candidate area ax.set_ylim(0, top_line_y + candidate_area_height) # Hide y-ticks in the candidate area yticks = [t for t in ax.get_yticks() if t <= max_count] ax.set_yticks(yticks) # Adjust the figure layout plt.tight_layout(rect=[0, 0, 1, 0.97]) plt.show() elif self.num_dims == 2: fig, ax = plt.subplots(figsize=(10, 6), dpi=dpi) # Get voter positions x_voters = [self.voter_position(v)[0] for v in self.voters] y_voters = [self.voter_position(v)[1] for v in self.voters] # Plot voters with semi-transparency to visualize density voter_scatter = ax.scatter(x_voters, y_voters, color="blue", alpha=0.2, edgecolor="black", linewidth=0.3, s=30, label="Voters") # Plot candidates x_cand = [self.candidate_position(c)[0] for c in self.candidates] y_cand = [self.candidate_position(c)[1] for c in self.candidates] cand_scatter = ax.scatter(x_cand, y_cand, color=candidate_color, marker='X', s=100, edgecolor="white", linewidth=0.7, zorder=5, label="Candidates") # Create a legend ax.legend([cand_scatter, voter_scatter], ['Candidates', 'Voters'], loc='upper right') if show_cand_labels: for c in self.candidates: pos = self.candidate_position(c) text = ax.annotate(c, (pos[0], pos[1]), xytext=(0, 10), textcoords='offset points', ha='center', va='bottom', fontsize=11, fontweight='bold', color=candidate_color) # Add white outline to text for better visibility text.set_path_effects([ path_effects.Stroke(linewidth=1.5, foreground='white'), path_effects.Normal() ]) if show_voter_labels: for v in self.voters: pos = self.voter_position(v) ax.annotate(v + 1, (pos[0], pos[1]), fontsize=8) ax.set_xlabel('Dimension 1') ax.set_ylabel('Dimension 2') plt.tight_layout() plt.show() elif self.num_dims == 3: fig = plt.figure(figsize=(10, 6), dpi=dpi) ax = fig.add_subplot(111, projection='3d') # Fetch all positions x_voters = [self.voter_position(v)[0] for v in self.voters] y_voters = [self.voter_position(v)[1] for v in self.voters] z_voters = [self.voter_position(v)[2] for v in self.voters] x_cand = [self.candidate_position(c)[0] for c in self.candidates] y_cand = [self.candidate_position(c)[1] for c in self.candidates] z_cand = [self.candidate_position(c)[2] for c in self.candidates] # Plot voters with high transparency for better visibility through clusters voter_scatter = ax.scatter(x_voters, y_voters, z_voters, color="blue", alpha=0.1, edgecolor="black", linewidth=0.5, s=30, label="Voters") # Plot candidate markers with white outline for better visibility cand_scatter = ax.scatter(x_cand, y_cand, z_cand, color=candidate_color, marker="X", s=40, edgecolor="white", linewidth=0.7, label="Candidates") # Add voter labels if requested if show_voter_labels: for v in self.voters: pos = self.voter_position(v) ax.text(pos[0], pos[1], pos[2], str(v + 1), fontsize=8) # Add candidate labels if show_cand_labels: for c in self.candidates: pos = self.candidate_position(c) text = ax.text(pos[0], pos[1], pos[2] + 0.15, c, fontsize=11, fontweight='bold', color=candidate_color, ha='center', va='bottom') # Add white outline to text for better visibility text.set_path_effects([ path_effects.Stroke(linewidth=1.5, foreground='white'), path_effects.Normal() ]) # Create legend ax.legend([cand_scatter, voter_scatter], ['Candidates', 'Voters'], loc='upper right') # Set axis labels ax.set_xlabel('Dimension 1') ax.set_ylabel('Dimension 2') ax.set_zlabel('Dimension 3') plt.tight_layout() plt.show()
[docs] def display(self): """ Displays the positions of each candidate and voter in the profile. """ print("Candidates: ") for c in self.candidates: print("Candidate ", c, " position: ", self.candidate_position(c)) print("\nVoters: ") for v in self.voters: print("Voter ", v, " position: ", self.voter_position(v))