Skip to content

API Reference

Objects

Ballot

Ballot class, contains ranking and assigned weight.

Attributes ranking : tuple of candidate ranking. Entry \(i\) of the tuple is a frozenset of candidates ranked in position \(i\).

weight : (Fraction) weight assigned to a given a ballot. Defaults to 1.

voter_set : optional set of voters who cast a given a ballot.

id : optional ballot id.

Source code in src/votekit/ballot.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
@dataclass(frozen=True, config=ConfigDict(arbitrary_types_allowed=True))
class Ballot:
    """
    Ballot class, contains ranking and assigned weight.

    **Attributes**
    `ranking`
    :   tuple of candidate ranking. Entry $i$ of the tuple is a frozenset of candidates ranked
        in position $i$.

    `weight`
    :   (Fraction) weight assigned to a given a ballot. Defaults to 1.

    `voter_set`
    :   optional set of voters who cast a given a ballot.

    `id`
    :   optional ballot id.
    """

    ranking: tuple[frozenset, ...] = field(default_factory=tuple)
    weight: Fraction = Fraction(1, 1)
    voter_set: Optional[set[str]] = None
    id: Optional[str] = None

    def __post_init__(self):
        # converts weight to a Fraction if an integer or float
        if not isinstance(self.weight, Fraction):
            object.__setattr__(
                self, "weight", Fraction(self.weight).limit_denominator()
            )

    def __eq__(self, other):
        # Check type
        if not isinstance(other, Ballot):
            return False

        # Check id
        if self.id is not None:
            if self.id != other.id:
                return False

        # Check ranking
        if self.ranking != other.ranking:
            return False

        # Check weight
        if self.weight != other.weight:
            return False

        # Check voters
        if self.voter_set is not None:
            if self.voter_set != other.voter_set:
                return False

        return True

    def __hash__(self):
        return hash(self.ranking)

    def __str__(self):
        weight_str = f"Weight: {self.weight}\n"
        ranking_str = "Ballot\n"

        if self.ranking:
            for i, s in enumerate(self.ranking):
                # display number and candidates
                ranking_str += f"{i+1}.) "
                for c in s:
                    ranking_str += f"{c}, "

                # if tie
                if len(s) > 1:
                    ranking_str += "(tie)"
                ranking_str += "\n"
        else:
            ranking_str += "Empty\n"

        return ranking_str + weight_str

    __repr__ = __str__

PreferenceInterval

PreferenceInterval class, contains preference for individual candidates stored as relative share of the interval [0,1].

Attributes

interval : dictionary (candidate, support). A dictionary representing the given PreferenceInterval. The keys are candidate names, and the values are floats representing that candidates share of the interval. Does not have to sum to one, the init method will renormalize.

candidates : frozenset. A frozenset of candidates (with zero and non-zero support)

non_zero_cands : frozenset. A frozenset of candidates with non-zero support.

zero_cands : frozenset. A frozenset of candidates with zero support.

Methods

from_dirichlet : sample a PreferenceInterval from the Dirichlet distribution on the candidate simplex.

normalize : normalize the support values of the PreferenceInterval to sum to 1.

remove_zero_support_cands : remove candidates with zero support from the interval and store them in the attribute zero_cands.

Source code in src/votekit/pref_interval.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
class PreferenceInterval:
    """
    PreferenceInterval class, contains preference for individual candidates stored as relative
    share of the interval [0,1].

    **Attributes**

    `interval`
    :   dictionary (candidate, support). A dictionary representing the given PreferenceInterval.
        The keys are candidate names, and the values are floats representing that candidates
        share of the interval. Does not have to sum to one, the init method will renormalize.

    `candidates`
    : frozenset. A frozenset of candidates (with zero and non-zero support)

    `non_zero_cands`
    : frozenset. A frozenset of candidates with non-zero support.

    `zero_cands`
    : frozenset. A frozenset of candidates with zero support.


    **Methods**

    `from_dirichlet`
    : sample a PreferenceInterval from the Dirichlet distribution on the candidate simplex.

    `normalize`
    : normalize the support values of the PreferenceInterval to sum to 1.

    `remove_zero_support_cands`
    : remove candidates with zero support from the interval and store them in the attribute
        `zero_cands`.
    """

    # TODO frozendict, frozenclass

    def __init__(self, interval: dict):
        self.interval = types.MappingProxyType(interval)
        self.candidates = frozenset(self.interval.keys())

        self.zero_cands: frozenset = frozenset()
        self.non_zero_cands: frozenset = frozenset()
        self._remove_zero_support_cands()
        self._normalize()

    @classmethod
    def from_dirichlet(cls, candidates: list[str], alpha: float):
        """
        Samples a PreferenceInterval from the Dirichlet distribution on the candidate simplex.
        Alpha tends to 0 is strong support, alpha tends to infinity is uniform support, alpha = 1
        is all bets are off.
        """
        probs = list(np.random.default_rng().dirichlet(alpha=[alpha] * len(candidates)))

        return cls({c: s for c, s in zip(candidates, probs)})

    def _normalize(self):
        """
        Normalize a PreferenceInterval so the support values sum to 1.
        """
        summ = sum(self.interval.values())

        if summ == 0:
            raise ZeroDivisionError("There are no candidates with non-zero support.")

        self.interval = types.MappingProxyType(
            {c: s / summ for c, s in self.interval.items()}
        )

    def _remove_zero_support_cands(self):
        """
        Remove candidates with zero support from the interval. Store candidates
        with zero support as a set in the attribute `zero_cands`.

        Should only be run once.
        """

        if not self.zero_cands and not self.non_zero_cands:
            self.zero_cands = frozenset([c for c, s in self.interval.items() if s == 0])
            self.interval = types.MappingProxyType(
                {c: s for c, s in self.interval.items() if s > 0}
            )
            self.non_zero_cands = frozenset(self.interval.keys())

    def __eq__(self, other):
        if not isinstance(other, PreferenceInterval):
            raise TypeError("Both types must be PreferenceInterval.")

        if not self.zero_cands == other.zero_cands:
            return False

        if not self.non_zero_cands == other.non_zero_cands:
            return False

        if not len(self.interval) == len(other.interval):
            return False

        else:
            return all(
                round(other.interval[key], 8) == round(value, 8)
                for key, value in self.interval.items()
            )

    def __repr__(self):
        printed_interval = {c: round(v, 4) for c, v in self.interval.items()}
        return str(printed_interval)

from_dirichlet(candidates, alpha) classmethod

Samples a PreferenceInterval from the Dirichlet distribution on the candidate simplex. Alpha tends to 0 is strong support, alpha tends to infinity is uniform support, alpha = 1 is all bets are off.

Source code in src/votekit/pref_interval.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
@classmethod
def from_dirichlet(cls, candidates: list[str], alpha: float):
    """
    Samples a PreferenceInterval from the Dirichlet distribution on the candidate simplex.
    Alpha tends to 0 is strong support, alpha tends to infinity is uniform support, alpha = 1
    is all bets are off.
    """
    probs = list(np.random.default_rng().dirichlet(alpha=[alpha] * len(candidates)))

    return cls({c: s for c, s in zip(candidates, probs)})

combine_preference_intervals(intervals, proportions)

Combine a list of preference intervals given a list of proportions used to reweight each
interval.

Arguments intervals : list. A list of PreferenceInterval objects to combine.

proportions : list. A list of floats used to reweight the PreferenceInterval objects. Proportion \(i\) will reweight interval \(i\).

Source code in src/votekit/pref_interval.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def combine_preference_intervals(
    intervals: list[PreferenceInterval], proportions: list[float]
):
    """
        Combine a list of preference intervals given a list of proportions used to reweight each
        interval.

    **Arguments**
    `intervals`
    : list.  A list of PreferenceInterval objects to combine.

    `proportions`
    : list. A list of floats used to reweight the PreferenceInterval objects. Proportion $i$ will
    reweight interval $i$.
    """
    if not (
        len(frozenset.union(*[pi.candidates for pi in intervals]))
        == sum(len(pi.candidates) for pi in intervals)
    ):
        raise ValueError("Intervals must have disjoint candidate sets")

    if round(sum(proportions), 8) != 1:
        raise ValueError("Proportions must sum to 1.")

    sum_pi = PreferenceInterval(
        interval={
            key: value * prop
            for pi, prop in zip(intervals, proportions)
            for key, value in pi.interval.items()
        }
    )

    # carry along the candidates with zero support
    zero_cands = frozenset.union(*[pi.zero_cands for pi in intervals])

    # need to union to ensure that if one of the proportions is 0 those candidates are saved
    sum_pi.zero_cands = sum_pi.zero_cands.union(zero_cands)
    return sum_pi

PreferenceProfile

Bases: BaseModel

PreferenceProfile class, contains ballots and candidates for a given election.

Attributes

ballots : list of Ballot objects.

candidates : list of candidates.

Methods

Source code in src/votekit/pref_profile.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
class PreferenceProfile(BaseModel):
    """
    PreferenceProfile class, contains ballots and candidates for a given election.

    **Attributes**

    `ballots`
    :   list of `Ballot` objects.

    `candidates`
    :   list of candidates.

    **Methods**
    """

    ballots: list[Ballot] = []
    candidates: Optional[list] = None
    df: pd.DataFrame = pd.DataFrame()

    @validator("candidates")
    def cands_must_be_unique(cls, candidates: list) -> list:
        if not len(set(candidates)) == len(candidates):
            raise ValueError("all candidates must be unique")
        return candidates

    def get_ballots(self) -> list[Ballot]:
        """
        Returns:
         List of ballots.
        """
        return self.ballots[:]

    def get_candidates(self, received_votes: Optional[bool] = True) -> list:
        """
        Args:
            received_votes: If True, only return candidates that received votes. Defaults
                    to True.
        Returns:
          List of candidates.
        """

        if received_votes or not self.candidates:
            unique_cands: set = set()
            for ballot in self.ballots:
                unique_cands.update(*ballot.ranking)

            return list(unique_cands)
        else:
            return self.candidates

    # can also cache
    def num_ballots(self) -> Fraction:
        """
        Counts number of ballots based on assigned weight.

        Returns:
            Number of ballots cast.
        """
        num_ballots = Fraction(0)
        for ballot in self.ballots:
            num_ballots += ballot.weight

        return num_ballots

    def to_dict(self, standardize: bool = False) -> dict:
        """
        Converts to dictionary with keys = rankings and values = corresponding total weights.

        Args:
            standardize (Boolean): If True, divides the weight of each ballot
                            by the total weight. Defaults to False.

        Returns:
            A dictionary with ranking (keys) and corresponding total weights (values).
        """
        num_ballots = self.num_ballots()
        di: dict = {}
        for ballot in self.ballots:
            rank_tuple = tuple(next(iter(item)) for item in ballot.ranking)
            if standardize:
                weight = ballot.weight / num_ballots
            else:
                weight = ballot.weight
            if rank_tuple not in di.keys():
                di[rank_tuple] = weight
            else:
                di[rank_tuple] += weight
        return di

    class Config:
        arbitrary_types_allowed = True

    def to_csv(self, fpath: str):
        """
        Saves PreferenceProfile to CSV.

        Args:
            fpath: Path to the saved csv.
        """
        with open(fpath, "w", newline="") as csvfile:
            fieldnames = ["weight", "ranking"]
            writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
            writer.writeheader()
            for ballot in self.ballots:
                writer.writerow({"weight": ballot.weight, "ranking": ballot.ranking})

    def _create_df(self) -> pd.DataFrame:
        """
        Creates pandas DataFrame for display and building plots.
        """
        weights = []
        ballots = []
        for ballot in self.ballots:
            part = []
            for ranking in ballot.ranking:
                if len(ranking) == 1:
                    part.append(list(ranking)[0])

                else:
                    part.append(f"{ranking} (Tie)")

            ballots.append(tuple(part))
            weights.append(ballot.weight)

        df = pd.DataFrame({"Ballots": ballots, "Weight": weights})

        try:
            df["Percent"] = df["Weight"] / df["Weight"].sum()
        except ZeroDivisionError:
            df["Percent"] = np.nan

        # fill nans with zero for edge cases
        df["Percent"] = df["Percent"].fillna(0.0)

        def format_as_percent(frac):
            return f"{float(frac):.2%}"

        df["Percent"] = df["Percent"].apply(format_as_percent)
        return df.reset_index(drop=True)

    def head(
        self,
        n: int,
        sort_by_weight: Optional[bool] = True,
        percents: Optional[bool] = False,
        totals: Optional[bool] = False,
    ) -> pd.DataFrame:
        """
        Displays top-n ballots in profile.

        Args:
            n: Number of ballots to view.
            sort_by_weight: If True, rank ballot from most to least votes. Defaults to True.
            percents: If True, show voter share for a given ballot.
            totals: If True, show total values for Percent and Weight.

        Returns:
            A dataframe with top-n ballots.
        """
        if self.df.empty:
            self.df = self._create_df()

        if sort_by_weight:
            df = (
                self.df.sort_values(by="Weight", ascending=False)
                .head(n)
                .reset_index(drop=True)
            )
        else:
            df = self.df.head(n).reset_index(drop=True)

        if totals:
            df = self._sum_row(df)

        if not percents:
            return df.drop(columns="Percent")

        return df

    def tail(
        self,
        n: int,
        sort_by_weight: Optional[bool] = True,
        percents: Optional[bool] = False,
        totals: Optional[bool] = False,
    ) -> pd.DataFrame:
        """
        Displays bottom-n ballots in profile.

        Args:
            n: Number of ballots to view.
            sort_by_weight: If True, rank ballot from least to most votes. Defaults to True.
            percents: If True, show voter share for a given ballot.
            totals: If True, show total values for Percent and Weight.

        Returns:
            A data frame with bottom-n ballots.
        """

        if self.df.empty:
            self.df = self._create_df()

        if sort_by_weight:
            df = self.df.sort_values(by="Weight", ascending=True)
            df["New Index"] = [x for x in range(len(self.df) - 1, -1, -1)]
            df = df.set_index("New Index").head(n)
            df.index.name = None

        else:
            df = self.df.iloc[::-1].head(n)

        if totals:
            df = self._sum_row(df)

        if not percents:
            return df.drop(columns="Percent")

        return df

    def __str__(self) -> str:
        # Displays top 15 cast ballots or entire profile

        if self.df.empty:
            self.df = self._create_df()

        if len(self.df) < 15:
            return self.head(n=len(self.df), sort_by_weight=True).to_string(
                index=False, justify="justify"
            )

        print(
            f"PreferenceProfile too long, only showing 15 out of {len(self.df) } rows."
        )
        return self.head(n=15, sort_by_weight=True).to_string(
            index=False, justify="justify"
        )

    # set repr to print outputs
    __repr__ = __str__

    def condense_ballots(self) -> PreferenceProfile:
        """
        Groups ballots by rankings and updates weights.

        Returns:
            A PreferenceProfile object with condensed ballot list.
        """
        ranking_to_index: dict = {}
        weight_accumulator = {}

        for ballot in self.ballots:
            if ballot.ranking not in ranking_to_index:
                ranking_to_index[ballot.ranking] = len(ranking_to_index)
                weight_accumulator[ballot.ranking] = Fraction(0)

            weight_accumulator[ballot.ranking] += ballot.weight

        new_ballot_list = [
            Ballot(ranking=tuple(map(frozenset, ranking)), weight=weight)
            for ranking, weight in weight_accumulator.items()
        ]

        condensed_profile = PreferenceProfile(ballots=new_ballot_list)
        return condensed_profile

    def __eq__(self, other):
        if not isinstance(other, PreferenceProfile):
            return False
        pp_1 = self.condense_ballots()
        pp_2 = other.condense_ballots()
        for b in pp_1.ballots:
            if b not in pp_2.ballots:
                return False
        for b in pp_2.ballots:
            if b not in pp_1.ballots:
                return False
        return True

    def _sum_row(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Computes sum total for weight and percent column
        """

        def format_as_float(percent_str):
            return float(percent_str.split("%")[0])

        sum_row = {
            "Ballot": "",
            "Weight": f'{df["Weight"].sum()} out of {self.num_ballots()}',
            "Percent": f'{df["Percent"].apply(format_as_float).sum():.2f} out of 100%',
        }

        df.loc["Totals"] = sum_row  # type: ignore

        return df.fillna("")

    def __add__(self, other):
        """
        Add two PreferenceProfiles by combining their ballot lists.
        """
        if isinstance(other, PreferenceProfile):
            ballots = self.ballots + other.ballots
            pp = PreferenceProfile(ballots=ballots)
            pp.condense_ballots()
            return pp
        else:
            raise TypeError(
                "Unsupported operand type. Must be an instance of PreferenceProfile."
            )

__add__(other)

Add two PreferenceProfiles by combining their ballot lists.

Source code in src/votekit/pref_profile.py
307
308
309
310
311
312
313
314
315
316
317
318
319
def __add__(self, other):
    """
    Add two PreferenceProfiles by combining their ballot lists.
    """
    if isinstance(other, PreferenceProfile):
        ballots = self.ballots + other.ballots
        pp = PreferenceProfile(ballots=ballots)
        pp.condense_ballots()
        return pp
    else:
        raise TypeError(
            "Unsupported operand type. Must be an instance of PreferenceProfile."
        )

condense_ballots()

Groups ballots by rankings and updates weights.

Returns:

Type Description
PreferenceProfile

A PreferenceProfile object with condensed ballot list.

Source code in src/votekit/pref_profile.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
def condense_ballots(self) -> PreferenceProfile:
    """
    Groups ballots by rankings and updates weights.

    Returns:
        A PreferenceProfile object with condensed ballot list.
    """
    ranking_to_index: dict = {}
    weight_accumulator = {}

    for ballot in self.ballots:
        if ballot.ranking not in ranking_to_index:
            ranking_to_index[ballot.ranking] = len(ranking_to_index)
            weight_accumulator[ballot.ranking] = Fraction(0)

        weight_accumulator[ballot.ranking] += ballot.weight

    new_ballot_list = [
        Ballot(ranking=tuple(map(frozenset, ranking)), weight=weight)
        for ranking, weight in weight_accumulator.items()
    ]

    condensed_profile = PreferenceProfile(ballots=new_ballot_list)
    return condensed_profile

get_ballots()

Returns:

Type Description
list[Ballot]

List of ballots.

Source code in src/votekit/pref_profile.py
36
37
38
39
40
41
def get_ballots(self) -> list[Ballot]:
    """
    Returns:
     List of ballots.
    """
    return self.ballots[:]

get_candidates(received_votes=True)

Parameters:

Name Type Description Default
received_votes Optional[bool]

If True, only return candidates that received votes. Defaults to True.

True

Returns: List of candidates.

Source code in src/votekit/pref_profile.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def get_candidates(self, received_votes: Optional[bool] = True) -> list:
    """
    Args:
        received_votes: If True, only return candidates that received votes. Defaults
                to True.
    Returns:
      List of candidates.
    """

    if received_votes or not self.candidates:
        unique_cands: set = set()
        for ballot in self.ballots:
            unique_cands.update(*ballot.ranking)

        return list(unique_cands)
    else:
        return self.candidates

head(n, sort_by_weight=True, percents=False, totals=False)

Displays top-n ballots in profile.

Parameters:

Name Type Description Default
n int

Number of ballots to view.

required
sort_by_weight Optional[bool]

If True, rank ballot from most to least votes. Defaults to True.

True
percents Optional[bool]

If True, show voter share for a given ballot.

False
totals Optional[bool]

If True, show total values for Percent and Weight.

False

Returns:

Type Description
DataFrame

A dataframe with top-n ballots.

Source code in src/votekit/pref_profile.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def head(
    self,
    n: int,
    sort_by_weight: Optional[bool] = True,
    percents: Optional[bool] = False,
    totals: Optional[bool] = False,
) -> pd.DataFrame:
    """
    Displays top-n ballots in profile.

    Args:
        n: Number of ballots to view.
        sort_by_weight: If True, rank ballot from most to least votes. Defaults to True.
        percents: If True, show voter share for a given ballot.
        totals: If True, show total values for Percent and Weight.

    Returns:
        A dataframe with top-n ballots.
    """
    if self.df.empty:
        self.df = self._create_df()

    if sort_by_weight:
        df = (
            self.df.sort_values(by="Weight", ascending=False)
            .head(n)
            .reset_index(drop=True)
        )
    else:
        df = self.df.head(n).reset_index(drop=True)

    if totals:
        df = self._sum_row(df)

    if not percents:
        return df.drop(columns="Percent")

    return df

num_ballots()

Counts number of ballots based on assigned weight.

Returns:

Type Description
Fraction

Number of ballots cast.

Source code in src/votekit/pref_profile.py
62
63
64
65
66
67
68
69
70
71
72
73
def num_ballots(self) -> Fraction:
    """
    Counts number of ballots based on assigned weight.

    Returns:
        Number of ballots cast.
    """
    num_ballots = Fraction(0)
    for ballot in self.ballots:
        num_ballots += ballot.weight

    return num_ballots

tail(n, sort_by_weight=True, percents=False, totals=False)

Displays bottom-n ballots in profile.

Parameters:

Name Type Description Default
n int

Number of ballots to view.

required
sort_by_weight Optional[bool]

If True, rank ballot from least to most votes. Defaults to True.

True
percents Optional[bool]

If True, show voter share for a given ballot.

False
totals Optional[bool]

If True, show total values for Percent and Weight.

False

Returns:

Type Description
DataFrame

A data frame with bottom-n ballots.

Source code in src/votekit/pref_profile.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def tail(
    self,
    n: int,
    sort_by_weight: Optional[bool] = True,
    percents: Optional[bool] = False,
    totals: Optional[bool] = False,
) -> pd.DataFrame:
    """
    Displays bottom-n ballots in profile.

    Args:
        n: Number of ballots to view.
        sort_by_weight: If True, rank ballot from least to most votes. Defaults to True.
        percents: If True, show voter share for a given ballot.
        totals: If True, show total values for Percent and Weight.

    Returns:
        A data frame with bottom-n ballots.
    """

    if self.df.empty:
        self.df = self._create_df()

    if sort_by_weight:
        df = self.df.sort_values(by="Weight", ascending=True)
        df["New Index"] = [x for x in range(len(self.df) - 1, -1, -1)]
        df = df.set_index("New Index").head(n)
        df.index.name = None

    else:
        df = self.df.iloc[::-1].head(n)

    if totals:
        df = self._sum_row(df)

    if not percents:
        return df.drop(columns="Percent")

    return df

to_csv(fpath)

Saves PreferenceProfile to CSV.

Parameters:

Name Type Description Default
fpath str

Path to the saved csv.

required
Source code in src/votekit/pref_profile.py
103
104
105
106
107
108
109
110
111
112
113
114
115
def to_csv(self, fpath: str):
    """
    Saves PreferenceProfile to CSV.

    Args:
        fpath: Path to the saved csv.
    """
    with open(fpath, "w", newline="") as csvfile:
        fieldnames = ["weight", "ranking"]
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()
        for ballot in self.ballots:
            writer.writerow({"weight": ballot.weight, "ranking": ballot.ranking})

to_dict(standardize=False)

Converts to dictionary with keys = rankings and values = corresponding total weights.

Parameters:

Name Type Description Default
standardize Boolean

If True, divides the weight of each ballot by the total weight. Defaults to False.

False

Returns:

Type Description
dict

A dictionary with ranking (keys) and corresponding total weights (values).

Source code in src/votekit/pref_profile.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def to_dict(self, standardize: bool = False) -> dict:
    """
    Converts to dictionary with keys = rankings and values = corresponding total weights.

    Args:
        standardize (Boolean): If True, divides the weight of each ballot
                        by the total weight. Defaults to False.

    Returns:
        A dictionary with ranking (keys) and corresponding total weights (values).
    """
    num_ballots = self.num_ballots()
    di: dict = {}
    for ballot in self.ballots:
        rank_tuple = tuple(next(iter(item)) for item in ballot.ranking)
        if standardize:
            weight = ballot.weight / num_ballots
        else:
            weight = ballot.weight
        if rank_tuple not in di.keys():
            di[rank_tuple] = weight
        else:
            di[rank_tuple] += weight
    return di

ElectionState

Bases: BaseModel

Class for storing information on each round of an election and the final outcome.

Attributes curr_round : current round number. Defaults to 0.

elected : list of candidates who pass a threshold to win.

eliminated_cands : list of candidates who were eliminated.

remaining : list of candidates who are still in the running.

profile : an instance of a PreferenceProfile object.

previous : an instance of ElectionState representing the previous round.

Methods

Source code in src/votekit/election_state.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
class ElectionState(BaseModel):
    """
    Class for storing information on each round of an election and the final outcome.

    **Attributes**
    `curr_round`
    :   current round number. Defaults to 0.

    `elected`
    :   list of candidates who pass a threshold to win.

    `eliminated_cands`
    :   list of candidates who were eliminated.

    `remaining`
    :   list of candidates who are still in the running.

    `profile`
    :   an instance of a PreferenceProfile object.

    `previous`
    :   an instance of ElectionState representing the previous round.

    **Methods**
    """

    curr_round: int = 0
    elected: list[set[str]] = []
    eliminated_cands: list[set[str]] = []
    remaining: list[set[str]] = []
    profile: PreferenceProfile
    scores: dict = {}
    previous: Optional["ElectionState"] = None

    class Config:
        allow_mutation = False

    def winners(self) -> list[set[str]]:
        """
        Returns:
         A list of elected candidates ordered from first round to current round.
        """
        if self.previous:
            return self.previous.winners() + self.elected

        return self.elected

    def eliminated(self) -> list[set[str]]:
        """
        Returns:
          A list of eliminated candidates ordered from current round to first round.
        """
        if self.previous:
            return self.eliminated_cands + self.previous.eliminated()

        return self.eliminated_cands

    def rankings(self) -> list[set[str]]:
        """
        Returns:
          List of all candidates in order of their ranking after each round, first the winners,\
          then the eliminated candidates.
        """
        if self.remaining != [{}]:
            return self.winners() + self.remaining + self.eliminated()

        return self.winners() + self.eliminated()

    def round_outcome(self, round: int) -> dict:
        # {'elected':list[set[str]], 'eliminated':list[set[str]]}
        """
        Finds the outcome of a given round.

        Args:
            round (int): Round number.

        Returns:
          A dictionary with elected and eliminated candidates.
        """
        if self.curr_round == round:
            return {
                "Elected": self.elected,
                "Eliminated": self.eliminated_cands,
                "Remaining": self.remaining,
            }
        elif self.previous:
            return self.previous.round_outcome(round)
        else:
            raise ValueError("Round number out of range")

    def get_scores(self, round: int = curr_round) -> dict:
        """
        Returns a dictionary of the candidate scores for the inputted round.
        Defaults to the last round
        """
        if round == 0 or round > self.curr_round:
            raise ValueError('Round number out of range"')

        if round == self.curr_round:
            return self.scores

        return self.previous.get_scores(self.curr_round - 1)  # type: ignore

    def changed_rankings(self) -> dict:
        """
        Returns:
            A dictionary with keys = candidate(s) who changed \
                ranking from previous round and values = a tuple of (previous rank, new rank).
        """

        if not self.previous:
            raise ValueError("This is the first round, cannot compare previous ranking")

        prev_ranking: dict = candidate_position_dict(self.previous.rankings())
        curr_ranking: dict = candidate_position_dict(self.rankings())
        if curr_ranking == prev_ranking:
            return {}

        changes = {}
        for candidate, index in curr_ranking.items():
            if prev_ranking[candidate] != index:
                changes[candidate] = (prev_ranking[candidate], index)
        return changes

    def status(self) -> pd.DataFrame:
        """
        Returns:
          Data frame displaying candidate, status (elected, eliminated,
            remaining), and the round their status updated.
        """
        all_cands = [c for s in self.rankings() for c in s]
        status_df = pd.DataFrame(
            {
                "Candidate": all_cands,
                "Status": ["Remaining"] * len(all_cands),
                "Round": [self.curr_round] * len(all_cands),
            }
        )

        for round in range(1, self.curr_round + 1):
            results = self.round_outcome(round)
            for status, ranking in results.items():
                for s in ranking:
                    for cand in s:
                        tied_str = ""
                        # if tie
                        if len(s) > 1:
                            remaining_cands = ", ".join(list(s.difference(cand)))
                            tied_str = f" (tie with {remaining_cands})"

                        status_df.loc[status_df["Candidate"] == cand, "Status"] = (
                            status + tied_str
                        )
                        status_df.loc[status_df["Candidate"] == cand, "Round"] = round

        return status_df

    def to_dict(self, keep: list = []) -> dict:
        """
        Returns election results as a dictionary.

        Args:
            keep (list, optional): List of information to store in dictionary, should be subset of
                "elected", "eliminated", "remaining", "ranking". Defaults to empty list,
                which stores all information.

        """
        keys = ["elected", "eliminated", "remaining", "ranking"]
        values: list = [
            self.winners(),
            self.eliminated(),
            self.remaining,
            self.rankings(),
        ]

        rv = {}
        for key, values in zip(keys, values):
            if keep and key not in keep:
                continue
            # pull out candidates from sets, if tied adds tuple of tied candidates
            temp_lst = []
            for cand_set in values:
                if len(cand_set) > 1:
                    temp_lst.append(tuple(cand_set))
                else:
                    temp_lst += [cand for cand in cand_set]
            rv[key] = temp_lst

        return rv

    def to_json(self, file_path: Path, keep: list = []):
        """
        Saves election state object as a JSON file:

        Args:
            keep (list, optional): List of information to store in dictionary, should be subset of
                "elected", "eliminated", "remaining", "ranking". Defaults to empty list,
                which stores all information.
        """

        json_dict = json.dumps(self.to_dict(keep=keep))
        with open(file_path, "w") as outfile:
            outfile.write(json_dict)

    def __str__(self):
        show = self.status()
        print(f"Current Round: {self.curr_round}")
        return show.to_string(index=False, justify="justify")

    __repr__ = __str__

changed_rankings()

Returns:

Type Description
dict

A dictionary with keys = candidate(s) who changed ranking from previous round and values = a tuple of (previous rank, new rank).

Source code in src/votekit/election_state.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def changed_rankings(self) -> dict:
    """
    Returns:
        A dictionary with keys = candidate(s) who changed \
            ranking from previous round and values = a tuple of (previous rank, new rank).
    """

    if not self.previous:
        raise ValueError("This is the first round, cannot compare previous ranking")

    prev_ranking: dict = candidate_position_dict(self.previous.rankings())
    curr_ranking: dict = candidate_position_dict(self.rankings())
    if curr_ranking == prev_ranking:
        return {}

    changes = {}
    for candidate, index in curr_ranking.items():
        if prev_ranking[candidate] != index:
            changes[candidate] = (prev_ranking[candidate], index)
    return changes

eliminated()

Returns:

Type Description
list[set[str]]

A list of eliminated candidates ordered from current round to first round.

Source code in src/votekit/election_state.py
60
61
62
63
64
65
66
67
68
def eliminated(self) -> list[set[str]]:
    """
    Returns:
      A list of eliminated candidates ordered from current round to first round.
    """
    if self.previous:
        return self.eliminated_cands + self.previous.eliminated()

    return self.eliminated_cands

get_scores(round=curr_round)

Returns a dictionary of the candidate scores for the inputted round. Defaults to the last round

Source code in src/votekit/election_state.py
103
104
105
106
107
108
109
110
111
112
113
114
def get_scores(self, round: int = curr_round) -> dict:
    """
    Returns a dictionary of the candidate scores for the inputted round.
    Defaults to the last round
    """
    if round == 0 or round > self.curr_round:
        raise ValueError('Round number out of range"')

    if round == self.curr_round:
        return self.scores

    return self.previous.get_scores(self.curr_round - 1)  # type: ignore

rankings()

Returns:

Type Description
list[set[str]]

List of all candidates in order of their ranking after each round, first the winners, then the eliminated candidates.

Source code in src/votekit/election_state.py
70
71
72
73
74
75
76
77
78
79
def rankings(self) -> list[set[str]]:
    """
    Returns:
      List of all candidates in order of their ranking after each round, first the winners,\
      then the eliminated candidates.
    """
    if self.remaining != [{}]:
        return self.winners() + self.remaining + self.eliminated()

    return self.winners() + self.eliminated()

round_outcome(round)

Finds the outcome of a given round.

Parameters:

Name Type Description Default
round int

Round number.

required

Returns:

Type Description
dict

A dictionary with elected and eliminated candidates.

Source code in src/votekit/election_state.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def round_outcome(self, round: int) -> dict:
    # {'elected':list[set[str]], 'eliminated':list[set[str]]}
    """
    Finds the outcome of a given round.

    Args:
        round (int): Round number.

    Returns:
      A dictionary with elected and eliminated candidates.
    """
    if self.curr_round == round:
        return {
            "Elected": self.elected,
            "Eliminated": self.eliminated_cands,
            "Remaining": self.remaining,
        }
    elif self.previous:
        return self.previous.round_outcome(round)
    else:
        raise ValueError("Round number out of range")

status()

Returns:

Type Description
DataFrame

Data frame displaying candidate, status (elected, eliminated, remaining), and the round their status updated.

Source code in src/votekit/election_state.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def status(self) -> pd.DataFrame:
    """
    Returns:
      Data frame displaying candidate, status (elected, eliminated,
        remaining), and the round their status updated.
    """
    all_cands = [c for s in self.rankings() for c in s]
    status_df = pd.DataFrame(
        {
            "Candidate": all_cands,
            "Status": ["Remaining"] * len(all_cands),
            "Round": [self.curr_round] * len(all_cands),
        }
    )

    for round in range(1, self.curr_round + 1):
        results = self.round_outcome(round)
        for status, ranking in results.items():
            for s in ranking:
                for cand in s:
                    tied_str = ""
                    # if tie
                    if len(s) > 1:
                        remaining_cands = ", ".join(list(s.difference(cand)))
                        tied_str = f" (tie with {remaining_cands})"

                    status_df.loc[status_df["Candidate"] == cand, "Status"] = (
                        status + tied_str
                    )
                    status_df.loc[status_df["Candidate"] == cand, "Round"] = round

    return status_df

to_dict(keep=[])

Returns election results as a dictionary.

Parameters:

Name Type Description Default
keep list

List of information to store in dictionary, should be subset of "elected", "eliminated", "remaining", "ranking". Defaults to empty list, which stores all information.

[]
Source code in src/votekit/election_state.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def to_dict(self, keep: list = []) -> dict:
    """
    Returns election results as a dictionary.

    Args:
        keep (list, optional): List of information to store in dictionary, should be subset of
            "elected", "eliminated", "remaining", "ranking". Defaults to empty list,
            which stores all information.

    """
    keys = ["elected", "eliminated", "remaining", "ranking"]
    values: list = [
        self.winners(),
        self.eliminated(),
        self.remaining,
        self.rankings(),
    ]

    rv = {}
    for key, values in zip(keys, values):
        if keep and key not in keep:
            continue
        # pull out candidates from sets, if tied adds tuple of tied candidates
        temp_lst = []
        for cand_set in values:
            if len(cand_set) > 1:
                temp_lst.append(tuple(cand_set))
            else:
                temp_lst += [cand for cand in cand_set]
        rv[key] = temp_lst

    return rv

to_json(file_path, keep=[])

Saves election state object as a JSON file:

Parameters:

Name Type Description Default
keep list

List of information to store in dictionary, should be subset of "elected", "eliminated", "remaining", "ranking". Defaults to empty list, which stores all information.

[]
Source code in src/votekit/election_state.py
203
204
205
206
207
208
209
210
211
212
213
214
215
def to_json(self, file_path: Path, keep: list = []):
    """
    Saves election state object as a JSON file:

    Args:
        keep (list, optional): List of information to store in dictionary, should be subset of
            "elected", "eliminated", "remaining", "ranking". Defaults to empty list,
            which stores all information.
    """

    json_dict = json.dumps(self.to_dict(keep=keep))
    with open(file_path, "w") as outfile:
        outfile.write(json_dict)

winners()

Returns:

Type Description
list[set[str]]

A list of elected candidates ordered from first round to current round.

Source code in src/votekit/election_state.py
50
51
52
53
54
55
56
57
58
def winners(self) -> list[set[str]]:
    """
    Returns:
     A list of elected candidates ordered from first round to current round.
    """
    if self.previous:
        return self.previous.winners() + self.elected

    return self.elected

BallotGraph

Bases: Graph

Class to build ballot graphs.

Attributes

source : data to create graph from, either PreferenceProfile object, number of candidates, or list of candidates.

allow_partial : if True, builds graph using all possible ballots, if False, only uses total linear ordered ballots. If building from a PreferenceProfile, defaults to True.

fix_short : if True, auto completes ballots of length n-1 to n.

Methods

Source code in src/votekit/graphs/ballot_graph.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
class BallotGraph(Graph):
    """
    Class to build ballot graphs.

    **Attributes**

    `source`
    :   data to create graph from, either PreferenceProfile object, number of
            candidates, or list of candidates.

    `allow_partial`
    :   if True, builds graph using all possible ballots,
        if False, only uses total linear ordered ballots.
        If building from a PreferenceProfile, defaults to True.

    `fix_short`
    : if True, auto completes ballots of length n-1 to n.

    **Methods**
    """

    def __init__(
        self,
        source: Union[PreferenceProfile, int, list],
        allow_partial: Optional[bool] = True,
        fix_short: Optional[bool] = True,
    ):
        super().__init__()

        self.profile = None
        self.candidates = None
        self.allow_partial = allow_partial

        if isinstance(source, int):
            self.num_cands = source
            self.graph = self.build_graph(source)

        if isinstance(source, list):
            self.num_cands = len(source)
            self.graph = self.build_graph(len(source))
            self.candidates = source

        if isinstance(source, PreferenceProfile):
            self.profile = source
            self.num_voters = source.num_ballots()
            self.num_cands = len(source.get_candidates())
            self.allow_partial = True
            if not self.graph:
                self.graph = self.build_graph(len(source.get_candidates()))
            self.graph = self.from_profile(source, fix_short=fix_short)

        self.num_voters = sum(self.node_weights.values())

        # if no partial ballots allowed, create induced subgraph
        if not self.allow_partial:
            total_ballots = [n for n in self.graph.nodes() if len(n) == self.num_cands]
            self.graph = self.graph.subgraph(total_ballots)

        if not self.node_weights:
            self.node_weights = {ballot: 0 for ballot in self.graph.nodes}

    def _relabel(self, gr: nx.Graph, new_label: int, num_cands: int) -> nx.Graph:
        """
        Relabels nodes in gr based on new_label
        """
        node_map = {}
        graph_nodes = list(gr.nodes)

        for k in graph_nodes:
            # add the value of new_label to every entry in every ballot
            tmp = [new_label + y for y in k]

            # reduce everything mod new_label
            for i in range(len(tmp)):
                if tmp[i] > num_cands:
                    tmp[i] = tmp[i] - num_cands
            node_map[k] = tuple([new_label] + tmp)

        return nx.relabel_nodes(gr, node_map)

    def build_graph(self, n: int) -> nx.Graph:  # ask Gabe about optimizing?
        """
        Builds graph of all possible ballots given a number of candiates.

        Args:
            n: Number of candidates in an election.

        Returns:
            A networkx graph.
        """
        Gc = nx.Graph()
        # base cases
        if n == 1:
            Gc.add_nodes_from([(1)], weight=0, cast=False)

        elif n == 2:
            Gc.add_nodes_from([(1, 2), (2, 1)], weight=0, cast=False)
            Gc.add_edges_from([((1, 2), (2, 1))])

        elif n > 2:
            G_prev = self.build_graph(n - 1)
            for i in range(1, n + 1):
                # add the node for the bullet vote i
                Gc.add_node((i,), weight=0, cast=False)

                # make the subgraph for the ballots where i is ranked first
                G_corner = self._relabel(G_prev, i, n)

                # add the components from that graph to the larger graph
                Gc.add_nodes_from(G_corner.nodes, weight=0, cast=False)
                Gc.add_edges_from(G_corner.edges)

                # connect the bullet vote node to the appropriate vertices
                if n == 3:
                    Gc.add_edges_from([(k, (i,)) for k in G_corner.nodes])
                else:
                    Gc.add_edges_from(
                        [(k, (i,)) for k in G_corner.nodes if len(k) == 2]
                    )

            nodes = Gc.nodes

            new_edges = [
                (bal, (bal[1], bal[0]) + bal[2:]) for bal in nodes if len(bal) >= 2
            ]
            Gc.add_edges_from(new_edges)

        return Gc

    def from_profile(
        self, profile: PreferenceProfile, fix_short: Optional[bool] = True
    ) -> nx.Graph:
        """
        Updates existing graph based on cast ballots from a PreferenceProfile,
        or creates graph based on PreferenceProfile.

        Args:
            profile: PreferenceProfile assigned to graph.


        Returns:
            Graph based on PreferenceProfile, 'cast' node attribute indicates
                    ballots cast in PreferenceProfile.
        """
        if not self.profile:
            self.profile = profile

        if not self.num_voters:
            self.num_voters = profile.num_ballots()

        self.candidates = profile.get_candidates()
        ballots = profile.get_ballots()
        self.cand_num = self._number_cands(tuple(self.candidates))
        self.node_weights = {ballot: 0 for ballot in self.graph.nodes}

        for ballot in ballots:
            ballot_node = []
            for position in ballot.ranking:
                if len(position) > 1:
                    raise ValueError(
                        "ballots must be cleaned to resolve ties"
                    )  # still unsure about ties
                for cand in position:
                    ballot_node.append(self.cand_num[cand])
            if len(ballot_node) == len(self.candidates) - 1 and fix_short:
                ballot_node = self.fix_short_ballot(
                    ballot_node, list(self.cand_num.values())
                )

            if tuple(ballot_node) in self.graph.nodes:
                self.graph.nodes[tuple(ballot_node)]["weight"] += ballot.weight
                self.graph.nodes[tuple(ballot_node)]["cast"] = True
                self.node_weights[tuple(ballot_node)] += ballot.weight

        return self.graph

    def fix_short_ballot(self, ballot: list, candidates: list) -> list:
        """
        Adds missing candidates to a short ballot.

        Args:
            ballot: A list of candidates on the ballot.
            candidates: A list of all candidates.

        Returns:
            A new list with the missing candidates added to the end of the ballot.

        """
        missing = set(candidates).difference(set(ballot))

        return ballot + list(missing)

    def label_cands(self, candidates, to_display: Callable = all_nodes):
        """
        Assigns candidate labels to ballot graph for plotting.

        Args:
            candidates (list): A list of candidates.
            to_display: A Boolean callable that takes in a graph and node,
                        returns True if node should be displayed.
        """

        candidate_numbers = self._number_cands(tuple(candidates))

        cand_dict = {value: key for key, value in candidate_numbers.items()}

        cand_labels = {}
        for node in self.graph.nodes:
            if to_display(self.graph, node):
                ballot = []
                for num in node:
                    ballot.append(cand_dict[num])

                # label the ballot and give the number of votes
                cand_labels[node] = (
                    str(tuple(ballot)) + ": " + str(self.graph.nodes[node]["weight"])
                )

        return cand_labels

    def label_weights(self, to_display: Callable = all_nodes):
        """
        Assigns weight labels to ballot graph for plotting.
        Only shows weight if non-zero.

        Args:
            to_display: A Boolean callable that takes in a graph and node,
                        returns True if node should be displayed.
        """
        node_labels = {}
        for node in self.graph.nodes:
            if to_display(self.graph, node):
                # label the ballot and give the number of votes
                if self.graph.nodes[node]["weight"] > 0:
                    node_labels[node] = (
                        str(node) + ": " + str(self.graph.nodes[node]["weight"])
                    )
                else:
                    node_labels[node] = str(node)

        return node_labels

    @cache
    def _number_cands(self, cands: tuple) -> dict:
        """
        Assigns numerical marker to candidates
        """
        legend = {}
        for idx, cand in enumerate(cands):
            legend[cand] = idx + 1

        return legend

    def draw(
        self,
        to_display: Callable = all_nodes,
        neighborhoods: Optional[list[tuple]] = [],
        show_cast: Optional[bool] = False,
        labels: Optional[bool] = False,
    ):
        """
        Visualize the graph.

        Args:
            to_display: A boolean function that takes the graph and a node as input,
                returns True if you want that node displayed. Defaults to showing all nodes.
            neighborhoods: A list of neighborhoods to display, given as tuple (node, radius).
                            (ex. (n,1) gives all nodes within one step of n).
            show_cast: If True, show only nodes with "cast" attribute = True.
                        If False, show all nodes.
            labels: If True, labels nodes with candidate names and vote totals.
        """

        def cast_nodes(graph, node):
            return graph.nodes[node]["cast"]

        def in_neighborhoods(graph, node):
            centers = [node for node, radius in neighborhoods]
            radii = [radius for node, radius in neighborhoods]

            distances = [nx.shortest_path_length(graph, node, x) for x in centers]

            return True in [d <= r for d, r in zip(distances, radii)]

        if show_cast:
            to_display = cast_nodes

        if neighborhoods:
            to_display = in_neighborhoods

        ballots = [n for n in self.graph.nodes if to_display(self.graph, n)]

        if labels:
            if not self.candidates:
                raise ValueError("no candidate names assigned")
            node_labels = self.label_cands(self.candidates, to_display)

        else:
            node_labels = self.label_weights(to_display)

            # if not labeling the nodes with candidates and graph is drawn from profile,
            # print labeling dictionary
            if self.profile and self.candidates:
                print("The candidates are labeled as follows.")
                cand_dict = self._number_cands(cands=tuple(self.candidates))
                for cand, value in cand_dict.items():
                    print(value, cand)

        subgraph = self.graph.subgraph(ballots)

        pos = nx.spring_layout(subgraph)
        nx.draw_networkx(subgraph, pos=pos, with_labels=True, labels=node_labels)

        # handles labels overlapping with margins
        x_values, y_values = zip(*pos.values())
        x_max, y_max = max(x_values), max(y_values)
        x_min, y_min = min(x_values), min(y_values)
        x_margin = (x_max - x_min) * 0.25
        y_margin = (y_max - y_min) * 0.25
        plt.xlim(x_min - x_margin, x_max + x_margin)
        plt.ylim(y_min - y_margin, y_max + y_margin)
        plt.show()

build_graph(n)

Builds graph of all possible ballots given a number of candiates.

Parameters:

Name Type Description Default
n int

Number of candidates in an election.

required

Returns:

Type Description
Graph

A networkx graph.

Source code in src/votekit/graphs/ballot_graph.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def build_graph(self, n: int) -> nx.Graph:  # ask Gabe about optimizing?
    """
    Builds graph of all possible ballots given a number of candiates.

    Args:
        n: Number of candidates in an election.

    Returns:
        A networkx graph.
    """
    Gc = nx.Graph()
    # base cases
    if n == 1:
        Gc.add_nodes_from([(1)], weight=0, cast=False)

    elif n == 2:
        Gc.add_nodes_from([(1, 2), (2, 1)], weight=0, cast=False)
        Gc.add_edges_from([((1, 2), (2, 1))])

    elif n > 2:
        G_prev = self.build_graph(n - 1)
        for i in range(1, n + 1):
            # add the node for the bullet vote i
            Gc.add_node((i,), weight=0, cast=False)

            # make the subgraph for the ballots where i is ranked first
            G_corner = self._relabel(G_prev, i, n)

            # add the components from that graph to the larger graph
            Gc.add_nodes_from(G_corner.nodes, weight=0, cast=False)
            Gc.add_edges_from(G_corner.edges)

            # connect the bullet vote node to the appropriate vertices
            if n == 3:
                Gc.add_edges_from([(k, (i,)) for k in G_corner.nodes])
            else:
                Gc.add_edges_from(
                    [(k, (i,)) for k in G_corner.nodes if len(k) == 2]
                )

        nodes = Gc.nodes

        new_edges = [
            (bal, (bal[1], bal[0]) + bal[2:]) for bal in nodes if len(bal) >= 2
        ]
        Gc.add_edges_from(new_edges)

    return Gc

draw(to_display=all_nodes, neighborhoods=[], show_cast=False, labels=False)

Visualize the graph.

Parameters:

Name Type Description Default
to_display Callable

A boolean function that takes the graph and a node as input, returns True if you want that node displayed. Defaults to showing all nodes.

all_nodes
neighborhoods Optional[list[tuple]]

A list of neighborhoods to display, given as tuple (node, radius). (ex. (n,1) gives all nodes within one step of n).

[]
show_cast Optional[bool]

If True, show only nodes with "cast" attribute = True. If False, show all nodes.

False
labels Optional[bool]

If True, labels nodes with candidate names and vote totals.

False
Source code in src/votekit/graphs/ballot_graph.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
def draw(
    self,
    to_display: Callable = all_nodes,
    neighborhoods: Optional[list[tuple]] = [],
    show_cast: Optional[bool] = False,
    labels: Optional[bool] = False,
):
    """
    Visualize the graph.

    Args:
        to_display: A boolean function that takes the graph and a node as input,
            returns True if you want that node displayed. Defaults to showing all nodes.
        neighborhoods: A list of neighborhoods to display, given as tuple (node, radius).
                        (ex. (n,1) gives all nodes within one step of n).
        show_cast: If True, show only nodes with "cast" attribute = True.
                    If False, show all nodes.
        labels: If True, labels nodes with candidate names and vote totals.
    """

    def cast_nodes(graph, node):
        return graph.nodes[node]["cast"]

    def in_neighborhoods(graph, node):
        centers = [node for node, radius in neighborhoods]
        radii = [radius for node, radius in neighborhoods]

        distances = [nx.shortest_path_length(graph, node, x) for x in centers]

        return True in [d <= r for d, r in zip(distances, radii)]

    if show_cast:
        to_display = cast_nodes

    if neighborhoods:
        to_display = in_neighborhoods

    ballots = [n for n in self.graph.nodes if to_display(self.graph, n)]

    if labels:
        if not self.candidates:
            raise ValueError("no candidate names assigned")
        node_labels = self.label_cands(self.candidates, to_display)

    else:
        node_labels = self.label_weights(to_display)

        # if not labeling the nodes with candidates and graph is drawn from profile,
        # print labeling dictionary
        if self.profile and self.candidates:
            print("The candidates are labeled as follows.")
            cand_dict = self._number_cands(cands=tuple(self.candidates))
            for cand, value in cand_dict.items():
                print(value, cand)

    subgraph = self.graph.subgraph(ballots)

    pos = nx.spring_layout(subgraph)
    nx.draw_networkx(subgraph, pos=pos, with_labels=True, labels=node_labels)

    # handles labels overlapping with margins
    x_values, y_values = zip(*pos.values())
    x_max, y_max = max(x_values), max(y_values)
    x_min, y_min = min(x_values), min(y_values)
    x_margin = (x_max - x_min) * 0.25
    y_margin = (y_max - y_min) * 0.25
    plt.xlim(x_min - x_margin, x_max + x_margin)
    plt.ylim(y_min - y_margin, y_max + y_margin)
    plt.show()

fix_short_ballot(ballot, candidates)

Adds missing candidates to a short ballot.

Parameters:

Name Type Description Default
ballot list

A list of candidates on the ballot.

required
candidates list

A list of all candidates.

required

Returns:

Type Description
list

A new list with the missing candidates added to the end of the ballot.

Source code in src/votekit/graphs/ballot_graph.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def fix_short_ballot(self, ballot: list, candidates: list) -> list:
    """
    Adds missing candidates to a short ballot.

    Args:
        ballot: A list of candidates on the ballot.
        candidates: A list of all candidates.

    Returns:
        A new list with the missing candidates added to the end of the ballot.

    """
    missing = set(candidates).difference(set(ballot))

    return ballot + list(missing)

from_profile(profile, fix_short=True)

Updates existing graph based on cast ballots from a PreferenceProfile, or creates graph based on PreferenceProfile.

Parameters:

Name Type Description Default
profile PreferenceProfile

PreferenceProfile assigned to graph.

required

Returns:

Type Description
Graph

Graph based on PreferenceProfile, 'cast' node attribute indicates ballots cast in PreferenceProfile.

Source code in src/votekit/graphs/ballot_graph.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def from_profile(
    self, profile: PreferenceProfile, fix_short: Optional[bool] = True
) -> nx.Graph:
    """
    Updates existing graph based on cast ballots from a PreferenceProfile,
    or creates graph based on PreferenceProfile.

    Args:
        profile: PreferenceProfile assigned to graph.


    Returns:
        Graph based on PreferenceProfile, 'cast' node attribute indicates
                ballots cast in PreferenceProfile.
    """
    if not self.profile:
        self.profile = profile

    if not self.num_voters:
        self.num_voters = profile.num_ballots()

    self.candidates = profile.get_candidates()
    ballots = profile.get_ballots()
    self.cand_num = self._number_cands(tuple(self.candidates))
    self.node_weights = {ballot: 0 for ballot in self.graph.nodes}

    for ballot in ballots:
        ballot_node = []
        for position in ballot.ranking:
            if len(position) > 1:
                raise ValueError(
                    "ballots must be cleaned to resolve ties"
                )  # still unsure about ties
            for cand in position:
                ballot_node.append(self.cand_num[cand])
        if len(ballot_node) == len(self.candidates) - 1 and fix_short:
            ballot_node = self.fix_short_ballot(
                ballot_node, list(self.cand_num.values())
            )

        if tuple(ballot_node) in self.graph.nodes:
            self.graph.nodes[tuple(ballot_node)]["weight"] += ballot.weight
            self.graph.nodes[tuple(ballot_node)]["cast"] = True
            self.node_weights[tuple(ballot_node)] += ballot.weight

    return self.graph

label_cands(candidates, to_display=all_nodes)

Assigns candidate labels to ballot graph for plotting.

Parameters:

Name Type Description Default
candidates list

A list of candidates.

required
to_display Callable

A Boolean callable that takes in a graph and node, returns True if node should be displayed.

all_nodes
Source code in src/votekit/graphs/ballot_graph.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def label_cands(self, candidates, to_display: Callable = all_nodes):
    """
    Assigns candidate labels to ballot graph for plotting.

    Args:
        candidates (list): A list of candidates.
        to_display: A Boolean callable that takes in a graph and node,
                    returns True if node should be displayed.
    """

    candidate_numbers = self._number_cands(tuple(candidates))

    cand_dict = {value: key for key, value in candidate_numbers.items()}

    cand_labels = {}
    for node in self.graph.nodes:
        if to_display(self.graph, node):
            ballot = []
            for num in node:
                ballot.append(cand_dict[num])

            # label the ballot and give the number of votes
            cand_labels[node] = (
                str(tuple(ballot)) + ": " + str(self.graph.nodes[node]["weight"])
            )

    return cand_labels

label_weights(to_display=all_nodes)

Assigns weight labels to ballot graph for plotting. Only shows weight if non-zero.

Parameters:

Name Type Description Default
to_display Callable

A Boolean callable that takes in a graph and node, returns True if node should be displayed.

all_nodes
Source code in src/votekit/graphs/ballot_graph.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def label_weights(self, to_display: Callable = all_nodes):
    """
    Assigns weight labels to ballot graph for plotting.
    Only shows weight if non-zero.

    Args:
        to_display: A Boolean callable that takes in a graph and node,
                    returns True if node should be displayed.
    """
    node_labels = {}
    for node in self.graph.nodes:
        if to_display(self.graph, node):
            # label the ballot and give the number of votes
            if self.graph.nodes[node]["weight"] > 0:
                node_labels[node] = (
                    str(node) + ": " + str(self.graph.nodes[node]["weight"])
                )
            else:
                node_labels[node] = str(node)

    return node_labels

PairwiseComparisonGraph

Bases: Graph

Class to construct the pairwise comparison graph where nodes are candidates and edges are pairwise preferences.

Attributes

profile : PreferenceProfile to construct graph from.

ballot_length : (optional) max length of ballot, defaults to longest possible ballot length.

Methods

Source code in src/votekit/graphs/pairwise_comparison_graph.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
class PairwiseComparisonGraph(Graph):
    """
    Class to construct the pairwise comparison graph where nodes are candidates
    and edges are pairwise preferences.

    **Attributes**

    `profile`
    :   PreferenceProfile to construct graph from.

    `ballot_length`
    :   (optional) max length of ballot, defaults to longest possible ballot length.

    **Methods**
    """

    def __init__(self, profile: PreferenceProfile, ballot_length=None):
        self.ballot_length = ballot_length
        if ballot_length is None:
            self.ballot_length = len(profile.get_candidates())
        full_profile = self.ballot_fill(profile, self.ballot_length)
        self.profile = full_profile
        self.candidates = self.profile.get_candidates()
        self.pairwise_dict = self.compute_pairwise_dict()
        self.pairwise_graph = self.build_graph()

    def ballot_fill(self, profile: PreferenceProfile, ballot_length: int):
        """
        Fills incomplete ballots for pairwise comparison.

        Args:
            profile: PreferenceProfile to fill.
            ballot_length: How long a ballot is.

        Returns:
            PreferenceProfile (PreferenceProfile): A PreferenceProfile with incomplete
                ballots filled in.
        """
        cand_list = [{cand} for cand in profile.get_candidates()]
        updated_ballot_list = []

        for ballot in profile.get_ballots():
            if len(ballot.ranking) < ballot_length:
                missing_cands = [
                    cand for cand in cand_list if cand not in ballot.ranking
                ]
                missing_cands_perms = list(
                    permutations(missing_cands, len(missing_cands))
                )
                frac_freq = ballot.weight / (len(missing_cands_perms))
                for perm in missing_cands_perms:
                    updated_rank = ballot.ranking + tuple([frozenset(c) for c in perm])
                    updated_ballot = Ballot(
                        ranking=updated_rank, weight=Fraction(frac_freq, 1)
                    )
                    updated_ballot_list.append(updated_ballot)
            else:
                updated_ballot_list.append(ballot)
        return PreferenceProfile(ballots=updated_ballot_list)

    # Helper functions to make pairwise comparison graph
    def head2head_count(self, cand1, cand2) -> Fraction:
        """
        Counts head to head comparisons between two candidates. Note that the given order
        of the candidates matters here.

        Args:
            cand1 (str): The first candidate to compare.
            cand2 (str): The second candidate to compare.

        Returns:
            A count of the number of times cand1 is preferred to cand2.
        """
        count = 0
        ballots_list = self.profile.get_ballots()
        for ballot in ballots_list:
            rank_list = ballot.ranking
            for s in rank_list:
                if cand1 in s:
                    count += ballot.weight
                    break
                elif cand2 in s:
                    break
        return Fraction(count)

    def compute_pairwise_dict(self) -> dict:
        """
        Constructs dictionary where keys are tuples (cand_a, cand_b) containing
        two candidates and values is the frequency cand_a is preferred to
        cand_b.

        Returns:
            A dictionary with keys = (cand_a, cand_b) and values = frequency cand_a is preferred
                to cand_b.
        """
        pairwise_dict = {}  # {(cand_a, cand_b): freq cand_a is preferred over cand_b}
        cand_pairs = combinations(self.candidates, 2)

        for pair in cand_pairs:
            cand_a, cand_b = pair[0], pair[1]
            head_2_head_dict = {
                (cand_a, cand_b): self.head2head_count(cand_a, cand_b),
                (cand_b, cand_a): self.head2head_count(cand_b, cand_a),
            }
            max_pair = max(zip(head_2_head_dict.values(), head_2_head_dict.keys()))
            pairwise_dict[max_pair[1]] = abs(
                self.head2head_count(cand_a, cand_b)
                - self.head2head_count(cand_b, cand_a)
            )

            ## would display x:y instead of abs(x-y)
            # winner, loser = max_pair[1]
            # pairwise_dict[max_pair[1]] = f"{head_2_head_dict[(winner, loser)]}: \
            # {head_2_head_dict[(loser, winner)]}"

        return pairwise_dict

    def build_graph(self) -> nx.DiGraph:
        """
        Builds the networkx pairwise comparison graph.

        Returns:
            The networkx digraph representing the pairwise comparison graph.
        """
        G = nx.DiGraph()
        G.add_nodes_from(self.candidates)
        for e in self.pairwise_dict.keys():
            G.add_edge(e[0], e[1], weight=self.pairwise_dict[e])
        return G

    def draw(self, outfile=None):
        """
        Draws pairwise comparison graph.

        Args:
            outfile (str): The filepath to save the graph. Defaults to not saving.
        """
        G = self.pairwise_graph

        pos = nx.circular_layout(G)
        nx.draw_networkx(
            G,
            pos,
            with_labels=True,
            node_size=500,
            node_color="skyblue",
            edgelist=list(),
        )
        nx.draw_networkx_edges(
            G,
            pos,
            edgelist=G.edges,
            width=1.5,
            edge_color="b",
            arrows=True,
            alpha=1,
            node_size=1000,
            arrowsize=25,
        )
        edge_labels = {(i, j): G[i][j]["weight"] for i, j in G.edges()}
        nx.draw_networkx_edge_labels(
            G, pos, edge_labels=edge_labels, label_pos=1 / 3, font_size=10
        )
        # Out stuff
        if outfile is not None:
            plt.savefig(outfile)
        else:
            plt.show()
        plt.close()

    # More complicated Requests
    def has_condorcet_winner(self) -> bool:
        """
        Checks if graph has a condorcet winner.

        Returns:
            True if condorcet winner exists, False otherwise.
        """
        dominating_tiers = self.dominating_tiers()
        if len(dominating_tiers[0]) == 1:
            return True
        return False

    def get_condorcet_winner(self) -> str:
        """
        Returns the condorcet winner. Raises a ValueError if no condorcet winner.

        Returns:
            The condorcet winner.
        """

        if self.has_condorcet_winner():
            return list(self.dominating_tiers()[0])[0]

        else:
            raise ValueError("There is no condorcet winner.")

    @cache
    def dominating_tiers(self) -> list[set]:
        """
        Finds dominating tiers within an election.

        Returns:
            A list of dominating tiers.
        """
        beat_set_size_dict = {}
        for i, cand in enumerate(self.candidates):
            beat_set = set()
            for j, other_cand in enumerate(self.candidates):
                if i != j:
                    if nx.has_path(self.pairwise_graph, cand, other_cand):
                        beat_set.add(other_cand)
            beat_set_size_dict[cand] = len(beat_set)

        # We want to return candidates sorted and grouped by beat set size
        tier_dict: dict = {}
        for k, v in beat_set_size_dict.items():
            if v in tier_dict.keys():
                tier_dict[v].add(k)
            else:
                tier_dict[v] = {k}
        tier_list = [tier_dict[k] for k in sorted(tier_dict.keys(), reverse=True)]
        return tier_list

    def has_condorcet_cycles(self) -> bool:
        """
        Checks if graph has any condorcet cycles, which we define as any cycle of length
            greater than 2 in the graph.

        Returns:
            True if condorcet cycles exists, False otherwise.
        """

        if len(self.get_condorcet_cycles()) > 0:
            return True

        else:
            return False

    @cache
    def get_condorcet_cycles(self) -> list:
        """
        Returns a list of condorcet cycles in the graph, which we define as any cycle of length
            greater than 2.

        Returns:
            List of condorcet cycles sorted by length.
        """

        G = self.pairwise_graph
        list_of_cycles = nx.recursive_simple_cycles(G)
        return sorted(list_of_cycles, key=lambda x: len(x))

ballot_fill(profile, ballot_length)

Fills incomplete ballots for pairwise comparison.

Parameters:

Name Type Description Default
profile PreferenceProfile

PreferenceProfile to fill.

required
ballot_length int

How long a ballot is.

required

Returns:

Name Type Description
PreferenceProfile PreferenceProfile

A PreferenceProfile with incomplete ballots filled in.

Source code in src/votekit/graphs/pairwise_comparison_graph.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def ballot_fill(self, profile: PreferenceProfile, ballot_length: int):
    """
    Fills incomplete ballots for pairwise comparison.

    Args:
        profile: PreferenceProfile to fill.
        ballot_length: How long a ballot is.

    Returns:
        PreferenceProfile (PreferenceProfile): A PreferenceProfile with incomplete
            ballots filled in.
    """
    cand_list = [{cand} for cand in profile.get_candidates()]
    updated_ballot_list = []

    for ballot in profile.get_ballots():
        if len(ballot.ranking) < ballot_length:
            missing_cands = [
                cand for cand in cand_list if cand not in ballot.ranking
            ]
            missing_cands_perms = list(
                permutations(missing_cands, len(missing_cands))
            )
            frac_freq = ballot.weight / (len(missing_cands_perms))
            for perm in missing_cands_perms:
                updated_rank = ballot.ranking + tuple([frozenset(c) for c in perm])
                updated_ballot = Ballot(
                    ranking=updated_rank, weight=Fraction(frac_freq, 1)
                )
                updated_ballot_list.append(updated_ballot)
        else:
            updated_ballot_list.append(ballot)
    return PreferenceProfile(ballots=updated_ballot_list)

build_graph()

Builds the networkx pairwise comparison graph.

Returns:

Type Description
DiGraph

The networkx digraph representing the pairwise comparison graph.

Source code in src/votekit/graphs/pairwise_comparison_graph.py
129
130
131
132
133
134
135
136
137
138
139
140
def build_graph(self) -> nx.DiGraph:
    """
    Builds the networkx pairwise comparison graph.

    Returns:
        The networkx digraph representing the pairwise comparison graph.
    """
    G = nx.DiGraph()
    G.add_nodes_from(self.candidates)
    for e in self.pairwise_dict.keys():
        G.add_edge(e[0], e[1], weight=self.pairwise_dict[e])
    return G

compute_pairwise_dict()

Constructs dictionary where keys are tuples (cand_a, cand_b) containing two candidates and values is the frequency cand_a is preferred to cand_b.

Returns:

Type Description
dict

A dictionary with keys = (cand_a, cand_b) and values = frequency cand_a is preferred to cand_b.

Source code in src/votekit/graphs/pairwise_comparison_graph.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def compute_pairwise_dict(self) -> dict:
    """
    Constructs dictionary where keys are tuples (cand_a, cand_b) containing
    two candidates and values is the frequency cand_a is preferred to
    cand_b.

    Returns:
        A dictionary with keys = (cand_a, cand_b) and values = frequency cand_a is preferred
            to cand_b.
    """
    pairwise_dict = {}  # {(cand_a, cand_b): freq cand_a is preferred over cand_b}
    cand_pairs = combinations(self.candidates, 2)

    for pair in cand_pairs:
        cand_a, cand_b = pair[0], pair[1]
        head_2_head_dict = {
            (cand_a, cand_b): self.head2head_count(cand_a, cand_b),
            (cand_b, cand_a): self.head2head_count(cand_b, cand_a),
        }
        max_pair = max(zip(head_2_head_dict.values(), head_2_head_dict.keys()))
        pairwise_dict[max_pair[1]] = abs(
            self.head2head_count(cand_a, cand_b)
            - self.head2head_count(cand_b, cand_a)
        )

        ## would display x:y instead of abs(x-y)
        # winner, loser = max_pair[1]
        # pairwise_dict[max_pair[1]] = f"{head_2_head_dict[(winner, loser)]}: \
        # {head_2_head_dict[(loser, winner)]}"

    return pairwise_dict

dominating_tiers() cached

Finds dominating tiers within an election.

Returns:

Type Description
list[set]

A list of dominating tiers.

Source code in src/votekit/graphs/pairwise_comparison_graph.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
@cache
def dominating_tiers(self) -> list[set]:
    """
    Finds dominating tiers within an election.

    Returns:
        A list of dominating tiers.
    """
    beat_set_size_dict = {}
    for i, cand in enumerate(self.candidates):
        beat_set = set()
        for j, other_cand in enumerate(self.candidates):
            if i != j:
                if nx.has_path(self.pairwise_graph, cand, other_cand):
                    beat_set.add(other_cand)
        beat_set_size_dict[cand] = len(beat_set)

    # We want to return candidates sorted and grouped by beat set size
    tier_dict: dict = {}
    for k, v in beat_set_size_dict.items():
        if v in tier_dict.keys():
            tier_dict[v].add(k)
        else:
            tier_dict[v] = {k}
    tier_list = [tier_dict[k] for k in sorted(tier_dict.keys(), reverse=True)]
    return tier_list

draw(outfile=None)

Draws pairwise comparison graph.

Parameters:

Name Type Description Default
outfile str

The filepath to save the graph. Defaults to not saving.

None
Source code in src/votekit/graphs/pairwise_comparison_graph.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def draw(self, outfile=None):
    """
    Draws pairwise comparison graph.

    Args:
        outfile (str): The filepath to save the graph. Defaults to not saving.
    """
    G = self.pairwise_graph

    pos = nx.circular_layout(G)
    nx.draw_networkx(
        G,
        pos,
        with_labels=True,
        node_size=500,
        node_color="skyblue",
        edgelist=list(),
    )
    nx.draw_networkx_edges(
        G,
        pos,
        edgelist=G.edges,
        width=1.5,
        edge_color="b",
        arrows=True,
        alpha=1,
        node_size=1000,
        arrowsize=25,
    )
    edge_labels = {(i, j): G[i][j]["weight"] for i, j in G.edges()}
    nx.draw_networkx_edge_labels(
        G, pos, edge_labels=edge_labels, label_pos=1 / 3, font_size=10
    )
    # Out stuff
    if outfile is not None:
        plt.savefig(outfile)
    else:
        plt.show()
    plt.close()

get_condorcet_cycles() cached

Returns a list of condorcet cycles in the graph, which we define as any cycle of length greater than 2.

Returns:

Type Description
list

List of condorcet cycles sorted by length.

Source code in src/votekit/graphs/pairwise_comparison_graph.py
251
252
253
254
255
256
257
258
259
260
261
262
263
@cache
def get_condorcet_cycles(self) -> list:
    """
    Returns a list of condorcet cycles in the graph, which we define as any cycle of length
        greater than 2.

    Returns:
        List of condorcet cycles sorted by length.
    """

    G = self.pairwise_graph
    list_of_cycles = nx.recursive_simple_cycles(G)
    return sorted(list_of_cycles, key=lambda x: len(x))

get_condorcet_winner()

Returns the condorcet winner. Raises a ValueError if no condorcet winner.

Returns:

Type Description
str

The condorcet winner.

Source code in src/votekit/graphs/pairwise_comparison_graph.py
195
196
197
198
199
200
201
202
203
204
205
206
207
def get_condorcet_winner(self) -> str:
    """
    Returns the condorcet winner. Raises a ValueError if no condorcet winner.

    Returns:
        The condorcet winner.
    """

    if self.has_condorcet_winner():
        return list(self.dominating_tiers()[0])[0]

    else:
        raise ValueError("There is no condorcet winner.")

has_condorcet_cycles()

Checks if graph has any condorcet cycles, which we define as any cycle of length greater than 2 in the graph.

Returns:

Type Description
bool

True if condorcet cycles exists, False otherwise.

Source code in src/votekit/graphs/pairwise_comparison_graph.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
def has_condorcet_cycles(self) -> bool:
    """
    Checks if graph has any condorcet cycles, which we define as any cycle of length
        greater than 2 in the graph.

    Returns:
        True if condorcet cycles exists, False otherwise.
    """

    if len(self.get_condorcet_cycles()) > 0:
        return True

    else:
        return False

has_condorcet_winner()

Checks if graph has a condorcet winner.

Returns:

Type Description
bool

True if condorcet winner exists, False otherwise.

Source code in src/votekit/graphs/pairwise_comparison_graph.py
183
184
185
186
187
188
189
190
191
192
193
def has_condorcet_winner(self) -> bool:
    """
    Checks if graph has a condorcet winner.

    Returns:
        True if condorcet winner exists, False otherwise.
    """
    dominating_tiers = self.dominating_tiers()
    if len(dominating_tiers[0]) == 1:
        return True
    return False

head2head_count(cand1, cand2)

Counts head to head comparisons between two candidates. Note that the given order of the candidates matters here.

Parameters:

Name Type Description Default
cand1 str

The first candidate to compare.

required
cand2 str

The second candidate to compare.

required

Returns:

Type Description
Fraction

A count of the number of times cand1 is preferred to cand2.

Source code in src/votekit/graphs/pairwise_comparison_graph.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def head2head_count(self, cand1, cand2) -> Fraction:
    """
    Counts head to head comparisons between two candidates. Note that the given order
    of the candidates matters here.

    Args:
        cand1 (str): The first candidate to compare.
        cand2 (str): The second candidate to compare.

    Returns:
        A count of the number of times cand1 is preferred to cand2.
    """
    count = 0
    ballots_list = self.profile.get_ballots()
    for ballot in ballots_list:
        rank_list = ballot.ranking
        for s in rank_list:
            if cand1 in s:
                count += ballot.weight
                break
            elif cand2 in s:
                break
    return Fraction(count)

CVR Loaders

load_csv(fpath, rank_cols=[], *, weight_col=None, delimiter=None, id_col=None)

Given a file path, loads cast vote records (cvr) with ranks as columns and voters as rows. Empty cells are treated as None.

Parameters:

Name Type Description Default
fpath str

Path to cvr file.

required
rank_cols list[int]

List of column indexes that contain rankings. Indexing starts from 0, in order from top to bottom rank. Default implies that all columns contain rankings.

[]
weight_col Optional[int]

The column position for ballot weights.

None
delimiter Optional[str]

The character that breaks up rows.

None
id_col Optional[int]

Index for the column with voter ids.

None

Raises:

Type Description
FileNotFoundError

If fpath is invalid.

EmptyDataError

If dataset is empty.

ValueError

If the voter id column has missing values.

DataError

If the voter id column has duplicate values.

Returns:

Type Description
PreferenceProfile

A PreferenceProfile that represents all the ballots in the election.

Source code in src/votekit/cvr_loaders.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def load_csv(
    fpath: str,
    rank_cols: list[int] = [],
    *,
    weight_col: Optional[int] = None,
    delimiter: Optional[str] = None,
    id_col: Optional[int] = None,
) -> PreferenceProfile:
    """
    Given a file path, loads cast vote records (cvr) with ranks as columns and voters as rows.
    Empty cells are treated as None.

    Args:
        fpath: Path to cvr file.
        rank_cols: List of column indexes that contain rankings. Indexing starts from 0,
                    in order from top to bottom rank.
                    Default implies that all columns contain rankings.
        weight_col: The column position for ballot weights.
        delimiter: The character that breaks up rows.
        id_col: Index for the column with voter ids.

    Raises:
        FileNotFoundError: If fpath is invalid.
        EmptyDataError: If dataset is empty.
        ValueError: If the voter id column has missing values.
        DataError: If the voter id column has duplicate values.

    Returns:
        A PreferenceProfile that represents all the ballots in the election.
    """
    if not os.path.isfile(fpath):
        raise FileNotFoundError(f"File with path {fpath} cannot be found")

    cvr_path = pathlib.Path(fpath)
    df = pd.read_csv(
        cvr_path,
        on_bad_lines="error",
        encoding="utf8",
        index_col=False,
        delimiter=delimiter,
    )

    if df.empty:
        raise EmptyDataError("Dataset cannot be empty")
    if id_col is not None and df.iloc[:, id_col].isnull().values.any():  # type: ignore
        raise ValueError(f"Missing value(s) in column at index {id_col}")
    if id_col is not None and not df.iloc[:, id_col].is_unique:
        raise DataError(f"Duplicate value(s) in column at index {id_col}")

    if rank_cols:
        if id_col is not None:
            df = df.iloc[:, rank_cols + [id_col]]
        else:
            df = df.iloc[:, rank_cols]

    ranks = list(df.columns)
    if id_col is not None:
        ranks.remove(df.columns[id_col])
    grouped = df.groupby(ranks, dropna=False)
    ballots = []

    for group, group_df in grouped:
        ranking = tuple(
            [frozenset({None}) if pd.isnull(c) else frozenset({c}) for c in group]
        )

        voter_set = None
        if id_col is not None:
            voter_set = set(group_df.iloc[:, id_col])
        weight = len(group_df)
        if weight_col is not None:
            weight = sum(group_df.iloc[:, weight_col])
        b = Ballot(ranking=ranking, weight=Fraction(weight), voter_set=voter_set)
        ballots.append(b)

    return PreferenceProfile(ballots=ballots)

load_scottish(fpath)

Given a file path, loads cvr from format used for Scottish election data.

Parameters:

Name Type Description Default
fpath str

Path to cvr file.

required

Raises:

Type Description
FileNotFoundError

If fpath is invalid.

EmptyDataError

If dataset is empty.

DataError

If there is missing or incorrect metadata or candidate data.

Returns:

Type Description
tuple

A tuple (PreferenceProfile, seats) representing the election and the number of seats in the election.

Source code in src/votekit/cvr_loaders.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def load_scottish(fpath: str) -> tuple[PreferenceProfile, int]:
    """
    Given a file path, loads cvr from format used for Scottish election data.

    Args:
        fpath: Path to cvr file.

    Raises:
        FileNotFoundError: If fpath is invalid.
        EmptyDataError: If dataset is empty.
        DataError: If there is missing or incorrect metadata or candidate data.

    Returns:
        (tuple): A tuple (PreferenceProfile, seats) representing the election and the
                number of seats in the election.
    """
    ballots = []
    names = []
    name_map = {}
    numbers = True
    cands_included = False

    if not os.path.isfile(fpath):
        raise FileNotFoundError(f"File with path {fpath} cannot be found")
    if os.path.getsize(fpath) == 0:
        raise EmptyDataError("Dataset cannot be empty")

    with open(fpath, "r") as file:
        for i, line in enumerate(file):
            s = line.rstrip("\n").rstrip()
            if i == 0:
                # first number is number of candidates, second is number of seats to elect
                metadata = [int(data) for data in s.split(" ")]
                if len(metadata) != 2:
                    raise DataError(
                        "metadata (first line) should have two parameters"
                        " (number of candidates, number of seats)"
                    )
                seats = metadata[1]
            # read in ballots, cleaning out rankings labeled '0' (designating end of line)
            elif numbers:
                ballot = [int(vote) for vote in s.split(" ")]
                num_votes = ballot[0]
                # ballots terminate with a single row with the character '0'
                if num_votes == 0:
                    numbers = False
                else:
                    ranking = [rank for rank in list(ballot[1:]) if rank != 0]
                    b = (ranking, num_votes)
                    ballots.append(b)  # this is converted to the PP format later
            # read in candidates
            elif "(" in s:
                cands_included = True
                name_parts = s.strip('"').split(" ")
                first_name = " ".join(name_parts[:-2])
                last_name = name_parts[-2]
                party = name_parts[-1].strip("(").strip(")")
                names.append(str((first_name, last_name, party)))
            else:
                if len(names) != metadata[0]:
                    err_message = (
                        f"Number of candidates listed, {len(names)}," + f" differs from"
                        f"number of candidates recorded in metadata, {metadata[0]}"
                    )
                    raise DataError(err_message)
                # read in election location (do we need this?)
                # location = s.strip("\"")
                if not cands_included:
                    raise DataError("Candidates missing from file")
                # map candidate numbers onto their names and convert ballots to PP format
                for i, name in enumerate(names):
                    name_map[i + 1] = name
                clean_ballots = [
                    Ballot(
                        ranking=tuple(
                            [frozenset({name_map[cand]}) for cand in ballot[0]]
                        ),
                        weight=Fraction(ballot[1]),
                    )
                    for ballot in ballots
                ]

        return PreferenceProfile(ballots=clean_ballots, candidates=names), seats

Ballot Generators

BallotGenerator

Base class for ballot generation models that use the candidate simplex (e.g. Plackett-Luce, Bradley-Terry, etc.).

Attributes

candidates : list of candidates in the election.

cohesion_parameters : dictionary of dictionaries mapping of bloc to cohesion parameters. (ex. {bloc_1: {bloc_1: .7, bloc_2: .2, bloc_3:.1}})

pref_intervals_by_bloc : dictionary of dictionaries mapping of bloc to preference intervals. (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

bloc_voter_prop : dictionary mapping of bloc to voter proportions (ex. {bloc: voter proportion}).

Note
  • Voter proportion for blocs must sum to 1.
  • Preference interval for candidates must sum to 1.
  • Must have same blocs in pref_intervals_by_bloc and bloc_voter_prop.

Methods

Source code in src/votekit/ballot_generator.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
class BallotGenerator:
    """
    Base class for ballot generation models that use the candidate simplex
    (e.g. Plackett-Luce, Bradley-Terry, etc.).

    **Attributes**

    `candidates`
    :   list of candidates in the election.

    `cohesion_parameters`
    : dictionary of dictionaries mapping of bloc to cohesion parameters.
        (ex. {bloc_1: {bloc_1: .7, bloc_2: .2, bloc_3:.1}})

    `pref_intervals_by_bloc`
    :   dictionary of dictionaries mapping of bloc to preference intervals.
        (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

    `bloc_voter_prop`
    :   dictionary mapping of bloc to voter proportions (ex. {bloc: voter proportion}).


    ???+ note
        * Voter proportion for blocs must sum to 1.
        * Preference interval for candidates must sum to 1.
        * Must have same blocs in `pref_intervals_by_bloc` and `bloc_voter_prop`.

    **Methods**
    """

    def __init__(
        self,
        **kwargs,
    ):
        if "candidates" not in kwargs and "slate_to_candidates" not in kwargs:
            raise ValueError(
                "At least one of candidates or slate_to_candidates must be provided."
            )

        if "candidates" in kwargs:
            self.candidates = kwargs["candidates"]

        if "slate_to_candidates" in kwargs:
            self.slate_to_candidates = kwargs["slate_to_candidates"]
            self.candidates = [
                c for c_list in self.slate_to_candidates.values() for c in c_list
            ]

        nec_parameters = [
            "pref_intervals_by_bloc",
            "cohesion_parameters",
            "bloc_voter_prop",
        ]

        if any(x in kwargs for x in nec_parameters):
            if not all(x in kwargs for x in nec_parameters):
                raise ValueError(
                    f"If one of {nec_parameters} is provided, all must be provided."
                )

            bloc_voter_prop = kwargs["bloc_voter_prop"]
            pref_intervals_by_bloc = kwargs["pref_intervals_by_bloc"]
            cohesion_parameters = kwargs["cohesion_parameters"]

            if round(sum(bloc_voter_prop.values()), 8) != 1.0:
                raise ValueError("Voter proportion for blocs must sum to 1")

            if bloc_voter_prop.keys() != pref_intervals_by_bloc.keys():
                raise ValueError(
                    "Blocs are not the same between bloc_voter_prop and pref_intervals_by_bloc."
                )

            if bloc_voter_prop.keys() != cohesion_parameters.keys():
                raise ValueError(
                    "Blocs are not the same between bloc_voter_prop and cohesion_parameters."
                )

            if pref_intervals_by_bloc.keys() != cohesion_parameters.keys():
                raise ValueError(
                    "Blocs are not the same between pref_intervals_by_bloc and cohesion_parameters."
                )

            for bloc, cohesion_parameter_dict in cohesion_parameters.items():
                if round(sum(cohesion_parameter_dict.values()), 8) != 1.0:
                    raise ValueError(
                        f"Cohesion parameters for bloc {bloc} must sum to 1."
                    )

            self.pref_intervals_by_bloc = pref_intervals_by_bloc
            self.bloc_voter_prop = bloc_voter_prop
            self.blocs = list(self.bloc_voter_prop.keys())
            self.cohesion_parameters = cohesion_parameters

    @classmethod
    def from_params(
        cls,
        slate_to_candidates: dict,
        bloc_voter_prop: dict,
        cohesion_parameters: dict,
        alphas: dict,
        **data,
    ):
        """
        Initializes a BallotGenerator by constructing a preference interval
        from parameters; the prior parameters (if inputted) will be overwritten.

        Args:
            slate_to_candidates (dict): A mapping of blocs to candidates
                (ex. {bloc: [candidate]})
            bloc_voter_prop (dict): A mapping of the percentage of total voters
                 per bloc (ex. {bloc: 0.7})
            cohesion_parameters (dict): Cohension factors for each bloc (ex. {bloc_1: {bloc_1: .9,
                                                                                        bloc_2:.1})
            alphas (dict): Alpha for the Dirichlet distribution of each bloc
                            (ex. {bloc: {bloc: 1, opposing_bloc: 1/2}}).

        Raises:
            ValueError: If the voter proportion for blocs don't sum to 1.
            ValueError: Blocs are not the same.

        Returns:
            (BallotGenerator): Initialized ballot generator.

        ???+ note
            * Voter proportion for blocs must sum to 1.
            * Each cohesion parameter must be in the interval [0,1].
            * Dirichlet parameters are in the interval $(0,\infty)$.
        """
        if round(sum(bloc_voter_prop.values()), 8) != 1.0:
            raise ValueError("Voter proportion for blocs must sum to 1")

        if slate_to_candidates.keys() != bloc_voter_prop.keys():
            raise ValueError("Blocs are not the same")

        pref_intervals_by_bloc = {}
        for current_bloc in bloc_voter_prop:
            intervals = {}
            for b in bloc_voter_prop:
                interval = PreferenceInterval.from_dirichlet(
                    candidates=slate_to_candidates[b], alpha=alphas[current_bloc][b]
                )
                intervals[b] = interval

            pref_intervals_by_bloc[current_bloc] = intervals

        if "candidates" not in data:
            cands = [cand for cands in slate_to_candidates.values() for cand in cands]
            data["candidates"] = cands

        data["pref_intervals_by_bloc"] = pref_intervals_by_bloc
        data["bloc_voter_prop"] = bloc_voter_prop
        data["cohesion_parameters"] = cohesion_parameters

        if cls in [
            AlternatingCrossover,
            slate_PlackettLuce,
            slate_BradleyTerry,
            CambridgeSampler,
        ]:
            generator = cls(
                slate_to_candidates=slate_to_candidates,
                **data,
            )

        else:
            generator = cls(**data)

        return generator

    @abstractmethod
    def generate_profile(
        self, number_of_ballots: int, by_bloc: bool = False
    ) -> Union[PreferenceProfile, Tuple, dict]:
        """
        Generates a `PreferenceProfile`.

        Args:
            number_of_ballots (int): Number of ballots to generate.
            by_bloc (bool): True if you want a tuple (pp_by_bloc, pp), which is a dictionary of
                            PreferenceProfiles with keys = blocs and the aggregated profile.
                    False if you want the aggregated profile. Defaults to False.

        Returns:
            (PreferenceProfile): A generated `PreferenceProfile`.
        """
        pass

    @staticmethod
    def _round_num(num: float) -> int:
        """
        Rounds up or down a float randomly.

        Args:
            num (float): Number to round.

        Returns:
            int: A whole number.
        """
        rand = np.random.random()
        return math.ceil(num) if rand > 0.5 else math.floor(num)

    @staticmethod
    def ballot_pool_to_profile(ballot_pool, candidates) -> PreferenceProfile:
        """
        Given a list of ballots and candidates, convert them into a `PreferenceProfile.`

        Args:
            ballot_pool (list of tuple): A list of ballots, where each ballot is a tuple
                    of candidates indicating their ranking from top to bottom.
            candidates (list): A list of candidates.

        Returns:
            (PreferenceProfile): A PreferenceProfile representing the ballots in the election.
        """
        ranking_counts: dict[tuple, int] = {}
        ballot_list: list[Ballot] = []

        for ranking in ballot_pool:
            tuple_rank = tuple(ranking)
            ranking_counts[tuple_rank] = (
                ranking_counts[tuple_rank] + 1 if tuple_rank in ranking_counts else 1
            )

        for ranking, count in ranking_counts.items():
            rank = tuple([frozenset([cand]) for cand in ranking])
            b = Ballot(ranking=rank, weight=Fraction(count))
            ballot_list.append(b)

        return PreferenceProfile(ballots=ballot_list, candidates=candidates)

ballot_pool_to_profile(ballot_pool, candidates) staticmethod

Given a list of ballots and candidates, convert them into a PreferenceProfile.

Parameters:

Name Type Description Default
ballot_pool list of tuple

A list of ballots, where each ballot is a tuple of candidates indicating their ranking from top to bottom.

required
candidates list

A list of candidates.

required

Returns:

Type Description
PreferenceProfile

A PreferenceProfile representing the ballots in the election.

Source code in src/votekit/ballot_generator.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
@staticmethod
def ballot_pool_to_profile(ballot_pool, candidates) -> PreferenceProfile:
    """
    Given a list of ballots and candidates, convert them into a `PreferenceProfile.`

    Args:
        ballot_pool (list of tuple): A list of ballots, where each ballot is a tuple
                of candidates indicating their ranking from top to bottom.
        candidates (list): A list of candidates.

    Returns:
        (PreferenceProfile): A PreferenceProfile representing the ballots in the election.
    """
    ranking_counts: dict[tuple, int] = {}
    ballot_list: list[Ballot] = []

    for ranking in ballot_pool:
        tuple_rank = tuple(ranking)
        ranking_counts[tuple_rank] = (
            ranking_counts[tuple_rank] + 1 if tuple_rank in ranking_counts else 1
        )

    for ranking, count in ranking_counts.items():
        rank = tuple([frozenset([cand]) for cand in ranking])
        b = Ballot(ranking=rank, weight=Fraction(count))
        ballot_list.append(b)

    return PreferenceProfile(ballots=ballot_list, candidates=candidates)

from_params(slate_to_candidates, bloc_voter_prop, cohesion_parameters, alphas, **data) classmethod

Initializes a BallotGenerator by constructing a preference interval from parameters; the prior parameters (if inputted) will be overwritten.

Parameters:

Name Type Description Default
slate_to_candidates dict

A mapping of blocs to candidates (ex. {bloc: [candidate]})

required
bloc_voter_prop dict

A mapping of the percentage of total voters per bloc (ex. {bloc: 0.7})

required
cohesion_parameters dict

Cohension factors for each bloc (ex. {bloc_1: {bloc_1: .9, bloc_2:.1})

required
alphas dict

Alpha for the Dirichlet distribution of each bloc (ex. {bloc: {bloc: 1, opposing_bloc: 1/2}}).

required

Raises:

Type Description
ValueError

If the voter proportion for blocs don't sum to 1.

ValueError

Blocs are not the same.

Returns:

Type Description
BallotGenerator

Initialized ballot generator.

Note
  • Voter proportion for blocs must sum to 1.
  • Each cohesion parameter must be in the interval [0,1].
  • Dirichlet parameters are in the interval \((0,\infty)\).
Source code in src/votekit/ballot_generator.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
@classmethod
def from_params(
    cls,
    slate_to_candidates: dict,
    bloc_voter_prop: dict,
    cohesion_parameters: dict,
    alphas: dict,
    **data,
):
    """
    Initializes a BallotGenerator by constructing a preference interval
    from parameters; the prior parameters (if inputted) will be overwritten.

    Args:
        slate_to_candidates (dict): A mapping of blocs to candidates
            (ex. {bloc: [candidate]})
        bloc_voter_prop (dict): A mapping of the percentage of total voters
             per bloc (ex. {bloc: 0.7})
        cohesion_parameters (dict): Cohension factors for each bloc (ex. {bloc_1: {bloc_1: .9,
                                                                                    bloc_2:.1})
        alphas (dict): Alpha for the Dirichlet distribution of each bloc
                        (ex. {bloc: {bloc: 1, opposing_bloc: 1/2}}).

    Raises:
        ValueError: If the voter proportion for blocs don't sum to 1.
        ValueError: Blocs are not the same.

    Returns:
        (BallotGenerator): Initialized ballot generator.

    ???+ note
        * Voter proportion for blocs must sum to 1.
        * Each cohesion parameter must be in the interval [0,1].
        * Dirichlet parameters are in the interval $(0,\infty)$.
    """
    if round(sum(bloc_voter_prop.values()), 8) != 1.0:
        raise ValueError("Voter proportion for blocs must sum to 1")

    if slate_to_candidates.keys() != bloc_voter_prop.keys():
        raise ValueError("Blocs are not the same")

    pref_intervals_by_bloc = {}
    for current_bloc in bloc_voter_prop:
        intervals = {}
        for b in bloc_voter_prop:
            interval = PreferenceInterval.from_dirichlet(
                candidates=slate_to_candidates[b], alpha=alphas[current_bloc][b]
            )
            intervals[b] = interval

        pref_intervals_by_bloc[current_bloc] = intervals

    if "candidates" not in data:
        cands = [cand for cands in slate_to_candidates.values() for cand in cands]
        data["candidates"] = cands

    data["pref_intervals_by_bloc"] = pref_intervals_by_bloc
    data["bloc_voter_prop"] = bloc_voter_prop
    data["cohesion_parameters"] = cohesion_parameters

    if cls in [
        AlternatingCrossover,
        slate_PlackettLuce,
        slate_BradleyTerry,
        CambridgeSampler,
    ]:
        generator = cls(
            slate_to_candidates=slate_to_candidates,
            **data,
        )

    else:
        generator = cls(**data)

    return generator

generate_profile(number_of_ballots, by_bloc=False) abstractmethod

Generates a PreferenceProfile.

Parameters:

Name Type Description Default
number_of_ballots int

Number of ballots to generate.

required
by_bloc bool

True if you want a tuple (pp_by_bloc, pp), which is a dictionary of PreferenceProfiles with keys = blocs and the aggregated profile. False if you want the aggregated profile. Defaults to False.

False

Returns:

Type Description
PreferenceProfile

A generated PreferenceProfile.

Source code in src/votekit/ballot_generator.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
@abstractmethod
def generate_profile(
    self, number_of_ballots: int, by_bloc: bool = False
) -> Union[PreferenceProfile, Tuple, dict]:
    """
    Generates a `PreferenceProfile`.

    Args:
        number_of_ballots (int): Number of ballots to generate.
        by_bloc (bool): True if you want a tuple (pp_by_bloc, pp), which is a dictionary of
                        PreferenceProfiles with keys = blocs and the aggregated profile.
                False if you want the aggregated profile. Defaults to False.

    Returns:
        (PreferenceProfile): A generated `PreferenceProfile`.
    """
    pass

BallotSimplex

Bases: BallotGenerator

Base class for ballot generation models that use the ballot simplex (e.g. ImpartialCulture, ImpartialAnonymousCulture).

Attributes

alpha : (float) alpha parameter for ballot simplex. Defaults to None.

point : dictionary representing a point in the ballot simplex with candidate as keys and electoral support as values. Defaults to None.

Note

Point or alpha arguments must be included to initialize.

Methods

Source code in src/votekit/ballot_generator.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
class BallotSimplex(BallotGenerator):
    """
    Base class for ballot generation models that use the ballot simplex
    (e.g. ImpartialCulture, ImpartialAnonymousCulture).

    **Attributes**

    `alpha`
    :   (float) alpha parameter for ballot simplex. Defaults to None.

    `point`
    :   dictionary representing a point in the ballot simplex with candidate as
        keys and electoral support as values. Defaults to None.

    ???+ note

        Point or alpha arguments must be included to initialize.

    **Methods**
    """

    def __init__(
        self, alpha: Optional[float] = None, point: Optional[dict] = None, **data
    ):
        if alpha is None and point is None:
            raise AttributeError("point or alpha must be initialized")
        self.alpha = alpha
        if alpha == float("inf"):
            self.alpha = 1e20
        if alpha == 0:
            self.alpha = 1e-10
        self.point = point
        super().__init__(**data)

    @classmethod
    def from_point(cls, point: dict, **data):
        """
        Initializes a Ballot Simplex model from a point in the Dirichlet distribution.

        Args:
            point (dict): A mapping of candidate to candidate support.

        Raises:
            ValueError: If the candidate support does not sum to 1.

        Returns:
            (BallotSimplex): Initialized from point.
        """
        if sum(point.values()) != 1.0:
            raise ValueError(
                f"probability distribution from point ({point.values()}) does not sum to 1"
            )
        return cls(point=point, **data)

    @classmethod
    def from_alpha(cls, alpha: float, **data):
        """
        Initializes a Ballot Simplex model from an alpha value for the Dirichlet
        distribution.

        Args:
            alpha (float): An alpha parameter for the Dirichlet distribution.

        Returns:
            (BallotSimplex): Initialized from alpha.
        """

        return cls(alpha=alpha, **data)

    def generate_profile(
        self, number_of_ballots, by_bloc: bool = False
    ) -> Union[PreferenceProfile, dict]:
        """
        Generates a PreferenceProfile from the ballot simplex.
        """

        perm_set = it.permutations(self.candidates, len(self.candidates))

        perm_rankings = [list(value) for value in perm_set]

        if self.alpha is not None:
            draw_probabilities = list(
                np.random.default_rng().dirichlet([self.alpha] * len(perm_rankings))
            )

        elif self.point:
            # calculates probabilities for each ranking
            # using probability distribution for candidate support
            draw_probabilities = [
                reduce(
                    lambda prod, cand: prod * self.point[cand] if self.point else 0,
                    ranking,
                    1.0,
                )
                for ranking in perm_rankings
            ]
            draw_probabilities = [
                prob / sum(draw_probabilities) for prob in draw_probabilities
            ]

        indices = np.random.choice(
            a=len(perm_rankings), size=number_of_ballots, p=draw_probabilities
        )
        ballot_pool = [perm_rankings[indices[i]] for i in range(number_of_ballots)]

        return self.ballot_pool_to_profile(ballot_pool, self.candidates)

from_alpha(alpha, **data) classmethod

Initializes a Ballot Simplex model from an alpha value for the Dirichlet distribution.

Parameters:

Name Type Description Default
alpha float

An alpha parameter for the Dirichlet distribution.

required

Returns:

Type Description
BallotSimplex

Initialized from alpha.

Source code in src/votekit/ballot_generator.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
@classmethod
def from_alpha(cls, alpha: float, **data):
    """
    Initializes a Ballot Simplex model from an alpha value for the Dirichlet
    distribution.

    Args:
        alpha (float): An alpha parameter for the Dirichlet distribution.

    Returns:
        (BallotSimplex): Initialized from alpha.
    """

    return cls(alpha=alpha, **data)

from_point(point, **data) classmethod

Initializes a Ballot Simplex model from a point in the Dirichlet distribution.

Parameters:

Name Type Description Default
point dict

A mapping of candidate to candidate support.

required

Raises:

Type Description
ValueError

If the candidate support does not sum to 1.

Returns:

Type Description
BallotSimplex

Initialized from point.

Source code in src/votekit/ballot_generator.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
@classmethod
def from_point(cls, point: dict, **data):
    """
    Initializes a Ballot Simplex model from a point in the Dirichlet distribution.

    Args:
        point (dict): A mapping of candidate to candidate support.

    Raises:
        ValueError: If the candidate support does not sum to 1.

    Returns:
        (BallotSimplex): Initialized from point.
    """
    if sum(point.values()) != 1.0:
        raise ValueError(
            f"probability distribution from point ({point.values()}) does not sum to 1"
        )
    return cls(point=point, **data)

generate_profile(number_of_ballots, by_bloc=False)

Generates a PreferenceProfile from the ballot simplex.

Source code in src/votekit/ballot_generator.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
def generate_profile(
    self, number_of_ballots, by_bloc: bool = False
) -> Union[PreferenceProfile, dict]:
    """
    Generates a PreferenceProfile from the ballot simplex.
    """

    perm_set = it.permutations(self.candidates, len(self.candidates))

    perm_rankings = [list(value) for value in perm_set]

    if self.alpha is not None:
        draw_probabilities = list(
            np.random.default_rng().dirichlet([self.alpha] * len(perm_rankings))
        )

    elif self.point:
        # calculates probabilities for each ranking
        # using probability distribution for candidate support
        draw_probabilities = [
            reduce(
                lambda prod, cand: prod * self.point[cand] if self.point else 0,
                ranking,
                1.0,
            )
            for ranking in perm_rankings
        ]
        draw_probabilities = [
            prob / sum(draw_probabilities) for prob in draw_probabilities
        ]

    indices = np.random.choice(
        a=len(perm_rankings), size=number_of_ballots, p=draw_probabilities
    )
    ballot_pool = [perm_rankings[indices[i]] for i in range(number_of_ballots)]

    return self.ballot_pool_to_profile(ballot_pool, self.candidates)

slate_PlackettLuce

Bases: BallotGenerator

Class for generating ballots using a slate-PlackettLuce model. This model first samples a ballot type by flipping a cohesion parameter weighted coin. It then fills out the ballot type via sampling with out replacement from the interval.

Can be initialized with an interval or can be constructed with the Dirichlet distribution using the from_params method in the BallotGenerator class.

Attributes

slate_to_candidates : dictionary mapping of slate to candidates (ex. {bloc: [candidate]}).

pref_intervals_by_bloc : dictionary of dictionaries mapping of bloc to preference intervals. (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

bloc_voter_prop : dictionary mapping of bloc to voter proportions (ex. {bloc: proportion}).

cohesion_parameters : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

Methods

See BallotGenerator base class

Source code in src/votekit/ballot_generator.py
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
class slate_PlackettLuce(BallotGenerator):
    """
    Class for generating ballots using a slate-PlackettLuce model.
    This model first samples a ballot type by flipping a cohesion parameter weighted coin.
    It then fills out the ballot type via sampling with out replacement from the interval.

    Can be initialized with an interval or can be
    constructed with the Dirichlet distribution using the `from_params` method in the
    `BallotGenerator` class.

    **Attributes**

    `slate_to_candidates`
    :   dictionary mapping of slate to candidates (ex. {bloc: [candidate]}).

    `pref_intervals_by_bloc`
    :   dictionary of dictionaries mapping of bloc to preference intervals.
        (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

    `bloc_voter_prop`
    :   dictionary mapping of bloc to voter proportions (ex. {bloc: proportion}).

    `cohesion_parameters`
    : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

    **Methods**

    See `BallotGenerator` base class
    """

    def __init__(self, cohesion_parameters: dict, **data):
        # Call the parent class's __init__ method to handle common parameters
        super().__init__(cohesion_parameters=cohesion_parameters, **data)

    def generate_profile(
        self, number_of_ballots: int, by_bloc: bool = False
    ) -> Union[PreferenceProfile, Tuple]:
        """
        Args:
        `number_of_ballots`: The number of ballots to generate.

        `by_bloc`: True if you want to return a dictionary of PreferenceProfiles by bloc.
                    False if you want the full, aggregated PreferenceProfile.
        """
        bloc_props = list(self.bloc_voter_prop.values())
        ballots_per_block = dict(
            zip(
                self.blocs,
                apportion.compute("huntington", bloc_props, number_of_ballots),
            )
        )

        pref_profile_by_bloc = {}

        for i, bloc in enumerate(self.blocs):
            # number of voters in this bloc
            num_ballots = ballots_per_block[bloc]
            ballot_pool = [Ballot()] * num_ballots
            pref_intervals = self.pref_intervals_by_bloc[bloc]
            zero_cands = set(
                it.chain(*[pi.zero_cands for pi in pref_intervals.values()])
            )

            slate_to_non_zero_candidates = {
                s: [c for c in c_list if c not in zero_cands]
                for s, c_list in self.slate_to_candidates.items()
            }

            ballot_types = sample_cohesion_ballot_types(
                slate_to_non_zero_candidates=slate_to_non_zero_candidates,
                num_ballots=num_ballots,
                cohesion_parameters_for_bloc=self.cohesion_parameters[bloc],
            )

            for j, bt in enumerate(ballot_types):
                cand_ordering_by_bloc = {}

                for b in self.blocs:
                    # create a pref interval dict of only this blocs candidates
                    bloc_cand_pref_interval = pref_intervals[b].interval
                    cands = pref_intervals[b].non_zero_cands

                    # if there are no non-zero candidates, skip this bloc
                    if len(cands) == 0:
                        continue

                    distribution = [bloc_cand_pref_interval[c] for c in cands]

                    # sample
                    cand_ordering = np.random.choice(
                        a=list(cands), size=len(cands), p=distribution, replace=False
                    )
                    cand_ordering_by_bloc[b] = list(cand_ordering)

                ranking = [frozenset({-1})] * len(bt)
                for i, b in enumerate(bt):
                    # append the current first candidate, then remove them from the ordering
                    ranking[i] = frozenset({cand_ordering_by_bloc[b][0]})
                    cand_ordering_by_bloc[b].pop(0)

                if len(zero_cands) > 0:
                    ranking.append(frozenset(zero_cands))
                ballot_pool[j] = Ballot(ranking=tuple(ranking), weight=Fraction(1, 1))

            pp = PreferenceProfile(ballots=ballot_pool)
            pp = pp.condense_ballots()
            pref_profile_by_bloc[bloc] = pp

        # combine the profiles
        pp = PreferenceProfile(ballots=[])
        for profile in pref_profile_by_bloc.values():
            pp += profile

        if by_bloc:
            return (pref_profile_by_bloc, pp)

        # else return the combined profiles
        else:
            return pp

generate_profile(number_of_ballots, by_bloc=False)

Args: number_of_ballots: The number of ballots to generate.

by_bloc: True if you want to return a dictionary of PreferenceProfiles by bloc. False if you want the full, aggregated PreferenceProfile.

Source code in src/votekit/ballot_generator.py
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
def generate_profile(
    self, number_of_ballots: int, by_bloc: bool = False
) -> Union[PreferenceProfile, Tuple]:
    """
    Args:
    `number_of_ballots`: The number of ballots to generate.

    `by_bloc`: True if you want to return a dictionary of PreferenceProfiles by bloc.
                False if you want the full, aggregated PreferenceProfile.
    """
    bloc_props = list(self.bloc_voter_prop.values())
    ballots_per_block = dict(
        zip(
            self.blocs,
            apportion.compute("huntington", bloc_props, number_of_ballots),
        )
    )

    pref_profile_by_bloc = {}

    for i, bloc in enumerate(self.blocs):
        # number of voters in this bloc
        num_ballots = ballots_per_block[bloc]
        ballot_pool = [Ballot()] * num_ballots
        pref_intervals = self.pref_intervals_by_bloc[bloc]
        zero_cands = set(
            it.chain(*[pi.zero_cands for pi in pref_intervals.values()])
        )

        slate_to_non_zero_candidates = {
            s: [c for c in c_list if c not in zero_cands]
            for s, c_list in self.slate_to_candidates.items()
        }

        ballot_types = sample_cohesion_ballot_types(
            slate_to_non_zero_candidates=slate_to_non_zero_candidates,
            num_ballots=num_ballots,
            cohesion_parameters_for_bloc=self.cohesion_parameters[bloc],
        )

        for j, bt in enumerate(ballot_types):
            cand_ordering_by_bloc = {}

            for b in self.blocs:
                # create a pref interval dict of only this blocs candidates
                bloc_cand_pref_interval = pref_intervals[b].interval
                cands = pref_intervals[b].non_zero_cands

                # if there are no non-zero candidates, skip this bloc
                if len(cands) == 0:
                    continue

                distribution = [bloc_cand_pref_interval[c] for c in cands]

                # sample
                cand_ordering = np.random.choice(
                    a=list(cands), size=len(cands), p=distribution, replace=False
                )
                cand_ordering_by_bloc[b] = list(cand_ordering)

            ranking = [frozenset({-1})] * len(bt)
            for i, b in enumerate(bt):
                # append the current first candidate, then remove them from the ordering
                ranking[i] = frozenset({cand_ordering_by_bloc[b][0]})
                cand_ordering_by_bloc[b].pop(0)

            if len(zero_cands) > 0:
                ranking.append(frozenset(zero_cands))
            ballot_pool[j] = Ballot(ranking=tuple(ranking), weight=Fraction(1, 1))

        pp = PreferenceProfile(ballots=ballot_pool)
        pp = pp.condense_ballots()
        pref_profile_by_bloc[bloc] = pp

    # combine the profiles
    pp = PreferenceProfile(ballots=[])
    for profile in pref_profile_by_bloc.values():
        pp += profile

    if by_bloc:
        return (pref_profile_by_bloc, pp)

    # else return the combined profiles
    else:
        return pp

name_PlackettLuce

Bases: short_name_PlackettLuce

Class for generating full ballots with name-PlackettLuce. This model samples without replacement from a preference interval. Can be initialized with an interval or can be constructed with the Dirichlet distribution using the from_params method in the BallotGenerator class.

Attributes

candidates : a list of candidates.

pref_intervals_by_bloc : dictionary of dictionaries mapping of bloc to preference intervals. (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

cohesion_parameters : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

bloc_voter_prop : dictionary mapping of bloc to voter proportions (ex. {bloc: proportion}).

Methods

See BallotGenerator base class

Source code in src/votekit/ballot_generator.py
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
class name_PlackettLuce(short_name_PlackettLuce):
    """
    Class for generating full ballots with name-PlackettLuce. This model samples without
    replacement from a preference interval. Can be initialized with an interval or can be
    constructed with the Dirichlet distribution using the `from_params` method in the
    `BallotGenerator` class.

    **Attributes**

    `candidates`
    : a list of candidates.

    `pref_intervals_by_bloc`
    :   dictionary of dictionaries mapping of bloc to preference intervals.
        (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

    `cohesion_parameters`
    : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

    `bloc_voter_prop`
    :   dictionary mapping of bloc to voter proportions (ex. {bloc: proportion}).


    **Methods**

    See `BallotGenerator` base class
    """

    def __init__(self, cohesion_parameters: dict, **data):
        if "candidates" in data:
            ballot_length = len(data["candidates"])
        elif "slate_to_candidates" in data:
            ballot_length = sum(
                len(c_list) for c_list in data["slate_to_candidates"].values()
            )
        else:
            raise ValueError("One of candidates or slate_to_candidates must be passed.")

        # Call the parent class's __init__ method to handle common parameters
        super().__init__(
            ballot_length=ballot_length, cohesion_parameters=cohesion_parameters, **data
        )

slate_BradleyTerry

Bases: BallotGenerator

Class for generating ballots using a slate-BradleyTerry model. It presamples ballot types by checking all pairwise comparisons, then fills out candidate ordering by sampling without replacement from preference intervals.

Only works with 2 blocs at the moment.

Can be initialized with an interval or can be constructed with the Dirichlet distribution using the from_params method in the BallotGenerator class.

Attributes

slate_to_candidates : dictionary mapping of slate to candidates (ex. {bloc: [candidate]}).

pref_intervals_by_bloc : dictionary of dictionaries mapping of bloc to preference intervals. (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

bloc_voter_prop : dictionary mapping of bloc to voter proportions (ex. {bloc: proportion}).

cohesion_parameters : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

Methods

See BallotGenerator base class

Source code in src/votekit/ballot_generator.py
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
class slate_BradleyTerry(BallotGenerator):
    """
    Class for generating ballots using a slate-BradleyTerry model. It
    presamples ballot types by checking all pairwise comparisons, then fills out candidate
    ordering by sampling without replacement from preference intervals.

    Only works with 2 blocs at the moment.

    Can be initialized with an interval or can be
    constructed with the Dirichlet distribution using the `from_params` method in the
    `BallotGenerator` class.

    **Attributes**

    `slate_to_candidates`
    :   dictionary mapping of slate to candidates (ex. {bloc: [candidate]}).

    `pref_intervals_by_bloc`
    :   dictionary of dictionaries mapping of bloc to preference intervals.
        (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

    `bloc_voter_prop`
    :   dictionary mapping of bloc to voter proportions (ex. {bloc: proportion}).

    `cohesion_parameters`
    : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

    **Methods**

    See `BallotGenerator` base class
    """

    def __init__(self, cohesion_parameters: dict, **data):
        # Call the parent class's __init__ method to handle common parameters
        super().__init__(cohesion_parameters=cohesion_parameters, **data)

        if len(self.slate_to_candidates.keys()) > 2:
            raise UserWarning(
                f"This model currently only supports at most two blocs, but you \
                              passed {len(self.slate_to_candidates.keys())}"
            )

        self.ballot_type_pdf = {
            b: self._compute_ballot_type_dist(b, self.blocs[(i + 1) % 2])
            for i, b in enumerate(self.blocs)
        }

    def _compute_ballot_type_dist(self, bloc, opp_bloc):
        """
        Return a dictionary with keys ballot types and values equal to probability of sampling.
        """
        blocs_to_sample = [
            b for b in self.blocs for _ in range(len(self.slate_to_candidates[b]))
        ]
        total_comparisons = np.prod(
            [len(l_of_c) for l_of_c in self.slate_to_candidates.values()]
        )
        cohesion = self.cohesion_parameters[bloc][bloc]

        def prob_of_type(b_type):
            success = sum(
                b_type[i + 1 :].count(opp_bloc)
                for i, b in enumerate(b_type)
                if b == bloc
            )
            return pow(cohesion, success) * pow(
                1 - cohesion, total_comparisons - success
            )

        pdf = {
            b: prob_of_type(b)
            for b in set(it.permutations(blocs_to_sample, len(blocs_to_sample)))
        }

        summ = sum(pdf.values())
        return {b: v / summ for b, v in pdf.items()}

    def _sample_ballot_types_deterministic(
        self, bloc: str, opp_bloc: str, num_ballots: int
    ):
        """
        Used to generate bloc orderings for deliberative.

        Returns a list of lists, where each sublist contains the bloc names in order they appear
        on the ballot.
        """
        # pdf = self._compute_ballot_type_dist(bloc=bloc, opp_bloc=opp_bloc)
        pdf = self.ballot_type_pdf[bloc]
        b_types = list(pdf.keys())
        probs = list(pdf.values())

        sampled_indices = np.random.choice(len(b_types), size=num_ballots, p=probs)

        return [b_types[i] for i in sampled_indices]

    def _sample_ballot_types_MCMC(
        self, bloc: str, num_ballots: int, verbose: bool = False
    ):
        """
        Generate ballot types using MCMC that has desired stationary distribution.
        """

        seed_ballot_type = [
            b for b in self.blocs for _ in range(len(self.slate_to_candidates[b]))
        ]

        ballots = [[-1]] * num_ballots
        accept = 0
        current_ranking = seed_ballot_type

        cohesion = self.cohesion_parameters[bloc][bloc]

        # presample swap indices
        swap_indices = [
            (j1, j1 + 1)
            for j1 in np.random.choice(len(seed_ballot_type) - 1, size=num_ballots)
        ]

        odds = (1 - cohesion) / cohesion
        # generate MCMC sample
        for i in range(num_ballots):
            # choose adjacent pair to propose a swap
            j1, j2 = swap_indices[i]

            # if swap reduces number of voters bloc above opposing bloc
            if (
                current_ranking[j1] != current_ranking[j2]
                and current_ranking[j1] == bloc
            ):
                acceptance_prob = odds

            # if swap increases number of voters bloc above opposing or swaps two of same bloc
            else:
                acceptance_prob = 1

            # if you accept, make the swap
            if random.random() < acceptance_prob:
                current_ranking[j1], current_ranking[j2] = (
                    current_ranking[j2],
                    current_ranking[j1],
                )
                accept += 1

            ballots[i] = current_ranking.copy()

        if verbose:
            print(
                f"Acceptance ratio as number accepted / total steps: {accept/num_ballots:.2}"
            )

        if -1 in ballots:
            raise ValueError("Some element of ballots list is not a ballot.")

        return ballots

    def generate_profile(
        self, number_of_ballots: int, by_bloc: bool = False, deterministic: bool = True
    ) -> Union[PreferenceProfile, Tuple]:
        """
        Args:
        `number_of_ballots`: The number of ballots to generate.

        `by_bloc`: True if you want to return a dictionary of PreferenceProfiles by bloc.
                    False if you want the full, aggregated PreferenceProfile.

        `deterministic`: True if you want to use the computed pdf for the slate-BT model,
                        False if you want to use MCMC approximation. Defaults to True.
        """
        # the number of ballots per bloc is determined by Huntington-Hill apportionment
        bloc_props = list(self.bloc_voter_prop.values())
        ballots_per_block = dict(
            zip(
                self.blocs,
                apportion.compute("huntington", bloc_props, number_of_ballots),
            )
        )

        pref_profile_by_bloc = {}

        for i, bloc in enumerate(self.blocs):
            # number of voters in this bloc
            num_ballots = ballots_per_block[bloc]
            ballot_pool = [Ballot()] * num_ballots
            pref_intervals = self.pref_intervals_by_bloc[bloc]
            zero_cands = set(
                it.chain(*[pi.zero_cands for pi in pref_intervals.values()])
            )

            if deterministic:
                ballot_types = self._sample_ballot_types_deterministic(
                    bloc=bloc, opp_bloc=self.blocs[(i + 1) % 2], num_ballots=num_ballots
                )
            else:
                ballot_types = self._sample_ballot_types_MCMC(
                    bloc=bloc, num_ballots=num_ballots
                )

            for j, bt in enumerate(ballot_types):
                cand_ordering_by_bloc = {}

                for b in self.blocs:
                    # create a pref interval dict of only this blocs candidates
                    bloc_cand_pref_interval = pref_intervals[b].interval
                    cands = pref_intervals[b].non_zero_cands

                    # if there are no non-zero candidates, skip this bloc
                    if len(cands) == 0:
                        continue

                    distribution = [bloc_cand_pref_interval[c] for c in cands]

                    # sample
                    cand_ordering = np.random.choice(
                        a=list(cands), size=len(cands), p=distribution, replace=False
                    )

                    cand_ordering_by_bloc[b] = list(cand_ordering)

                ranking = [frozenset({-1})] * len(bt)
                for i, b in enumerate(bt):
                    # append the current first candidate, then remove them from the ordering
                    ranking[i] = frozenset({cand_ordering_by_bloc[b][0]})
                    cand_ordering_by_bloc[b].pop(0)

                if len(zero_cands) > 0:
                    ranking.append(frozenset(zero_cands))
                ballot_pool[j] = Ballot(ranking=tuple(ranking), weight=Fraction(1, 1))

            pp = PreferenceProfile(ballots=ballot_pool)
            pp = pp.condense_ballots()
            pref_profile_by_bloc[bloc] = pp

        # combine the profiles
        pp = PreferenceProfile(ballots=[])
        for profile in pref_profile_by_bloc.values():
            pp += profile

        if by_bloc:
            return (pref_profile_by_bloc, pp)

        # else return the combined profiles
        else:
            return pp

generate_profile(number_of_ballots, by_bloc=False, deterministic=True)

Args: number_of_ballots: The number of ballots to generate.

by_bloc: True if you want to return a dictionary of PreferenceProfiles by bloc. False if you want the full, aggregated PreferenceProfile.

deterministic: True if you want to use the computed pdf for the slate-BT model, False if you want to use MCMC approximation. Defaults to True.

Source code in src/votekit/ballot_generator.py
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
def generate_profile(
    self, number_of_ballots: int, by_bloc: bool = False, deterministic: bool = True
) -> Union[PreferenceProfile, Tuple]:
    """
    Args:
    `number_of_ballots`: The number of ballots to generate.

    `by_bloc`: True if you want to return a dictionary of PreferenceProfiles by bloc.
                False if you want the full, aggregated PreferenceProfile.

    `deterministic`: True if you want to use the computed pdf for the slate-BT model,
                    False if you want to use MCMC approximation. Defaults to True.
    """
    # the number of ballots per bloc is determined by Huntington-Hill apportionment
    bloc_props = list(self.bloc_voter_prop.values())
    ballots_per_block = dict(
        zip(
            self.blocs,
            apportion.compute("huntington", bloc_props, number_of_ballots),
        )
    )

    pref_profile_by_bloc = {}

    for i, bloc in enumerate(self.blocs):
        # number of voters in this bloc
        num_ballots = ballots_per_block[bloc]
        ballot_pool = [Ballot()] * num_ballots
        pref_intervals = self.pref_intervals_by_bloc[bloc]
        zero_cands = set(
            it.chain(*[pi.zero_cands for pi in pref_intervals.values()])
        )

        if deterministic:
            ballot_types = self._sample_ballot_types_deterministic(
                bloc=bloc, opp_bloc=self.blocs[(i + 1) % 2], num_ballots=num_ballots
            )
        else:
            ballot_types = self._sample_ballot_types_MCMC(
                bloc=bloc, num_ballots=num_ballots
            )

        for j, bt in enumerate(ballot_types):
            cand_ordering_by_bloc = {}

            for b in self.blocs:
                # create a pref interval dict of only this blocs candidates
                bloc_cand_pref_interval = pref_intervals[b].interval
                cands = pref_intervals[b].non_zero_cands

                # if there are no non-zero candidates, skip this bloc
                if len(cands) == 0:
                    continue

                distribution = [bloc_cand_pref_interval[c] for c in cands]

                # sample
                cand_ordering = np.random.choice(
                    a=list(cands), size=len(cands), p=distribution, replace=False
                )

                cand_ordering_by_bloc[b] = list(cand_ordering)

            ranking = [frozenset({-1})] * len(bt)
            for i, b in enumerate(bt):
                # append the current first candidate, then remove them from the ordering
                ranking[i] = frozenset({cand_ordering_by_bloc[b][0]})
                cand_ordering_by_bloc[b].pop(0)

            if len(zero_cands) > 0:
                ranking.append(frozenset(zero_cands))
            ballot_pool[j] = Ballot(ranking=tuple(ranking), weight=Fraction(1, 1))

        pp = PreferenceProfile(ballots=ballot_pool)
        pp = pp.condense_ballots()
        pref_profile_by_bloc[bloc] = pp

    # combine the profiles
    pp = PreferenceProfile(ballots=[])
    for profile in pref_profile_by_bloc.values():
        pp += profile

    if by_bloc:
        return (pref_profile_by_bloc, pp)

    # else return the combined profiles
    else:
        return pp

name_BradleyTerry

Bases: BallotGenerator

Class for generating ballots using a name-BradleyTerry model. The probability of sampling the ranking \(X>Y>Z\) is proportional to \(P(X>Y)*P(X>Z)*P(Y>Z)\). These individual probabilities are based on the preference interval: \(P(X>Y) = x/(x+y)\). Can be initialized with an interval or can be constructed with the Dirichlet distribution using the from_params method in the BallotGenerator class.

Attributes

candidates : a list of candidates.

pref_intervals_by_bloc : dictionary of dictionaries mapping of bloc to preference intervals or dictionary of PIs. (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

bloc_voter_prop : dictionary mapping of slate to voter proportions (ex. {race: voter proportion}).

cohesion_parameters : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

Methods

See BallotGenerator base class.

Source code in src/votekit/ballot_generator.py
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
class name_BradleyTerry(BallotGenerator):
    """
    Class for generating ballots using a name-BradleyTerry model. The probability of sampling
    the ranking $X>Y>Z$ is proportional to $P(X>Y)*P(X>Z)*P(Y>Z)$.
    These individual probabilities are based on the preference interval: $P(X>Y) = x/(x+y)$.
    Can be initialized with an interval or can be constructed with the Dirichlet distribution using
    the `from_params` method in the `BallotGenerator` class.

    **Attributes**

    `candidates`
    : a list of candidates.

    `pref_intervals_by_bloc`
    : dictionary of dictionaries mapping of bloc to preference intervals or dictionary of PIs.
        (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

    `bloc_voter_prop`
    :   dictionary mapping of slate to voter proportions (ex. {race: voter proportion}).

    `cohesion_parameters`
    : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

    **Methods**

    See `BallotGenerator` base class.
    """

    def __init__(self, cohesion_parameters: dict, **data):
        # Call the parent class's __init__ method to handle common parameters
        super().__init__(cohesion_parameters=cohesion_parameters, **data)

        # if dictionary of pref intervals
        if isinstance(
            list(self.pref_intervals_by_bloc.values())[0], PreferenceInterval
        ):
            self.pref_interval_by_bloc = self.pref_intervals_by_bloc

        # if nested dictionary of pref intervals, combine by cohesion
        else:
            self.pref_interval_by_bloc = {
                bloc: combine_preference_intervals(
                    [self.pref_intervals_by_bloc[bloc][b] for b in self.blocs],
                    [self.cohesion_parameters[bloc][b] for b in self.blocs],
                )
                for bloc in self.blocs
            }

        if len(self.candidates) < 12:
            # precompute pdfs for sampling
            self.pdfs_by_bloc = {
                bloc: self._BT_pdf(self.pref_interval_by_bloc[bloc].interval)
                for bloc in self.blocs
            }
        else:
            warnings.warn(
                "For 12 or more candidates, exact sampling is computationally infeasible. \
                    Please only use the built in generate_profile_MCMC method."
            )

    def _calc_prob(self, permutations: list[tuple], cand_support_dict: dict) -> dict:
        """
        given a list of (possibly incomplete) rankings and the preference interval, \
        calculates the probability of observing each ranking

        Args:
            permutations (list[tuple]): a list of permuted rankings
            cand_support_dict (dict): a mapping from candidate to their \
            support (preference interval)

        Returns:
            dict: a mapping of the rankings to their probability
        """
        ranking_to_prob = {}
        for ranking in permutations:
            prob = 1
            for i in range(len(ranking)):
                cand_i = ranking[i]
                greater_cand_support = cand_support_dict[cand_i]
                for j in range(i + 1, len(ranking)):
                    cand_j = ranking[j]
                    cand_support = cand_support_dict[cand_j]
                    prob *= greater_cand_support / (greater_cand_support + cand_support)
            ranking_to_prob[ranking] = prob
        return ranking_to_prob

    def _make_pow(self, lst):
        """
        Helper method for _BT_pdf.
        Takes is a list representing the preference lengths of each candidate
        in a permutation.
        Computes the numerator of BT probability.
        """
        ret = 1
        m = len(lst)
        for i, val in enumerate(lst):
            if i < m - 1:
                ret *= val ** (m - i - 1)
        return ret

    def _BT_pdf(self, dct):
        """
        Construct the BT pdf as a dictionary (ballot, probability) given a preference
        interval as a dictionary (candidate, preference).
        """

        # gives PI lengths for each candidate in permutation
        def pull_perm(lst):
            nonlocal dct
            return [dct[i] for i in lst]

        new_dct = {
            perm: self._make_pow(pull_perm(perm))
            for perm in it.permutations(dct.keys(), len(dct))
        }
        summ = sum(new_dct.values())
        return {key: value / summ for key, value in new_dct.items()}

    def generate_profile(
        self, number_of_ballots, by_bloc: bool = False
    ) -> Union[PreferenceProfile, Tuple]:
        # the number of ballots per bloc is determined by Huntington-Hill apportionment

        bloc_props = list(self.bloc_voter_prop.values())
        ballots_per_block = dict(
            zip(
                self.blocs,
                apportion.compute("huntington", bloc_props, number_of_ballots),
            )
        )

        pp_by_bloc = {b: PreferenceProfile() for b in self.blocs}

        for bloc in self.blocs:
            num_ballots = ballots_per_block[bloc]

            # Directly initialize the list using good memory trick
            ballot_pool = [Ballot()] * num_ballots
            zero_cands = self.pref_interval_by_bloc[bloc].zero_cands
            pdf_dict = self.pdfs_by_bloc[bloc]

            # Directly use the keys and values from the dictionary for sampling
            rankings, probs = zip(*pdf_dict.items())

            # The return of this will be a numpy array, so we don't need to make it into a list
            sampled_indices = np.array(
                np.random.choice(
                    a=len(rankings),
                    size=num_ballots,
                    p=probs,
                ),
                ndmin=1,
            )

            for j, index in enumerate(sampled_indices):
                ranking = [frozenset({cand}) for cand in rankings[index]]

                # Add any zero candidates as ties only if they exist
                if zero_cands:
                    ranking.append(frozenset(zero_cands))

                ballot_pool[j] = Ballot(ranking=tuple(ranking), weight=Fraction(1, 1))

            pp = PreferenceProfile(ballots=ballot_pool)
            pp = pp.condense_ballots()
            pp_by_bloc[bloc] = pp

        # combine the profiles
        pp = PreferenceProfile(ballots=[])
        for profile in pp_by_bloc.values():
            pp += profile

        if by_bloc:
            return (pp_by_bloc, pp)

        # else return the combined profiles
        else:
            return pp

    def _BT_mcmc(
        self, num_ballots, pref_interval, seed_ballot, zero_cands={}, verbose=False
    ):
        """
        Sample from BT distribution for a given preference interval using MCMC.

        num_ballots (int): the number of ballots to sample
        pref_interval (dict): the preference interval to determine BT distribution
        sub_sample_length (int): how many attempts at swaps to make before saving ballot
        seed_ballot: Ballot, the seed ballot for the Markov chain
        verbose: bool, if True, print the acceptance ratio of the chain
        """

        # check that seed ballot has no ties
        for s in seed_ballot.ranking:
            if len(s) > 1:
                raise ValueError("Seed ballot contains ties")

        ballots = [-1] * num_ballots
        accept = 0
        current_ranking = list(seed_ballot.ranking)
        num_candidates = len(current_ranking)

        # presample swap indices
        swap_indices = [
            (j1, j1 + 1)
            for j1 in random.choices(range(num_candidates - 1), k=num_ballots)
        ]

        # generate MCMC sample
        for i in range(num_ballots):
            # choose adjacent pair to propose a swap
            j1, j2 = swap_indices[i]
            acceptance_prob = min(
                1,
                pref_interval[next(iter(current_ranking[j2]))]
                / pref_interval[next(iter(current_ranking[j1]))],
            )

            # if you accept, make the swap
            if random.random() < acceptance_prob:
                current_ranking[j1], current_ranking[j2] = (
                    current_ranking[j2],
                    current_ranking[j1],
                )
                accept += 1

            if len(zero_cands) > 0:
                ballots[i] = Ballot(ranking=current_ranking + [zero_cands])
            else:
                ballots[i] = Ballot(ranking=current_ranking)

        if verbose:
            print(
                f"Acceptance ratio as number accepted / total steps: {accept/num_ballots:.2}"
            )

        if -1 in ballots:
            raise ValueError("Some element of ballots list is not a ballot.")

        pp = PreferenceProfile(ballots=ballots)
        pp = pp.condense_ballots()
        return pp

    def generate_profile_MCMC(
        self, number_of_ballots: int, verbose=False, by_bloc: bool = False
    ) -> Union[PreferenceProfile, Tuple]:
        """
        Sample from the BT distribution using Markov Chain Monte Carlo. `number_of_ballots` should
        be sufficiently large to allow for convergence of the chain.

        Args:
            number_of_ballots (int): Number of ballots to generate.
            verbose (bool, optional): If True, print the acceptance ratio of the chain. Default
                                        is False.
            by_bloc (bool, optional): True if you want a tuple (pp_by_bloc, pp), which is a
                                    dictionary of  PreferenceProfiles with keys = blocs and the
                                    aggregated profile. False if you want the aggregated profile.
                                    Defaults to False.

        Returns:
            Generated ballots as a PreferenceProfile or tuple (dict, PreferenceProfile).
        """

        # the number of ballots per bloc is determined by Huntington-Hill apportionment
        bloc_props = list(self.bloc_voter_prop.values())
        ballots_per_block = dict(
            zip(
                self.blocs,
                apportion.compute("huntington", bloc_props, number_of_ballots),
            )
        )

        pp_by_bloc = {b: PreferenceProfile() for b in self.blocs}

        for bloc in self.blocs:
            num_ballots = ballots_per_block[bloc]
            pref_interval = self.pref_interval_by_bloc[bloc]
            pref_interval_dict = pref_interval.interval
            non_zero_cands = pref_interval.non_zero_cands
            zero_cands = pref_interval.zero_cands

            seed_ballot = Ballot(
                ranking=tuple([frozenset({c}) for c in non_zero_cands])
            )
            pp = self._BT_mcmc(
                num_ballots,
                pref_interval_dict,
                seed_ballot,
                zero_cands=zero_cands,
                verbose=verbose,
            )

            pp_by_bloc[bloc] = pp

        # combine the profiles
        pp = PreferenceProfile(ballots=[])
        for profile in pp_by_bloc.values():
            pp += profile

        if by_bloc:
            return (pp_by_bloc, pp)

        # else return the combined profiles
        else:
            return pp

generate_profile_MCMC(number_of_ballots, verbose=False, by_bloc=False)

Sample from the BT distribution using Markov Chain Monte Carlo. number_of_ballots should be sufficiently large to allow for convergence of the chain.

Parameters:

Name Type Description Default
number_of_ballots int

Number of ballots to generate.

required
verbose bool

If True, print the acceptance ratio of the chain. Default is False.

False
by_bloc bool

True if you want a tuple (pp_by_bloc, pp), which is a dictionary of PreferenceProfiles with keys = blocs and the aggregated profile. False if you want the aggregated profile. Defaults to False.

False

Returns:

Type Description
Union[PreferenceProfile, Tuple]

Generated ballots as a PreferenceProfile or tuple (dict, PreferenceProfile).

Source code in src/votekit/ballot_generator.py
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
def generate_profile_MCMC(
    self, number_of_ballots: int, verbose=False, by_bloc: bool = False
) -> Union[PreferenceProfile, Tuple]:
    """
    Sample from the BT distribution using Markov Chain Monte Carlo. `number_of_ballots` should
    be sufficiently large to allow for convergence of the chain.

    Args:
        number_of_ballots (int): Number of ballots to generate.
        verbose (bool, optional): If True, print the acceptance ratio of the chain. Default
                                    is False.
        by_bloc (bool, optional): True if you want a tuple (pp_by_bloc, pp), which is a
                                dictionary of  PreferenceProfiles with keys = blocs and the
                                aggregated profile. False if you want the aggregated profile.
                                Defaults to False.

    Returns:
        Generated ballots as a PreferenceProfile or tuple (dict, PreferenceProfile).
    """

    # the number of ballots per bloc is determined by Huntington-Hill apportionment
    bloc_props = list(self.bloc_voter_prop.values())
    ballots_per_block = dict(
        zip(
            self.blocs,
            apportion.compute("huntington", bloc_props, number_of_ballots),
        )
    )

    pp_by_bloc = {b: PreferenceProfile() for b in self.blocs}

    for bloc in self.blocs:
        num_ballots = ballots_per_block[bloc]
        pref_interval = self.pref_interval_by_bloc[bloc]
        pref_interval_dict = pref_interval.interval
        non_zero_cands = pref_interval.non_zero_cands
        zero_cands = pref_interval.zero_cands

        seed_ballot = Ballot(
            ranking=tuple([frozenset({c}) for c in non_zero_cands])
        )
        pp = self._BT_mcmc(
            num_ballots,
            pref_interval_dict,
            seed_ballot,
            zero_cands=zero_cands,
            verbose=verbose,
        )

        pp_by_bloc[bloc] = pp

    # combine the profiles
    pp = PreferenceProfile(ballots=[])
    for profile in pp_by_bloc.values():
        pp += profile

    if by_bloc:
        return (pp_by_bloc, pp)

    # else return the combined profiles
    else:
        return pp

AlternatingCrossover

Bases: BallotGenerator

Class for Alternating Crossover style of generating ballots. AC assumes that voters either rank all of their own blocs candidates above the other bloc, or the voters "crossover" and rank a candidate from the other bloc first, then alternate between candidates from their own bloc and the opposing. Should only be used when there are two blocs.

Can be initialized with an interval or can be constructed with the Dirichlet distribution using the from_params method in the BallotGenerator class.

Attributes

pref_intervals_by_bloc : dictionary of dictionaries mapping of bloc to preference intervals. (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

bloc_voter_prop : dictionary mapping of slate to voter proportions (ex. {bloc: voter proportion}).

slate_to_candidates : dictionary mapping of slate to candidates (ex. {bloc: [candidate1, candidate2]}).

cohesion_parameters : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

Methods

See BallotGenerator base class.

Source code in src/votekit/ballot_generator.py
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
class AlternatingCrossover(BallotGenerator):
    """
    Class for Alternating Crossover style of generating ballots.
    AC assumes that voters either rank all of their own blocs candidates above the other bloc,
    or the voters "crossover" and rank a candidate from the other bloc first, then alternate
    between candidates from their own bloc and the opposing.
    Should only be used when there are two blocs.

    Can be initialized with an interval or can be
    constructed with the Dirichlet distribution using the `from_params` method in the
    `BallotGenerator` class.

    **Attributes**

    `pref_intervals_by_bloc`
    :   dictionary of dictionaries mapping of bloc to preference intervals.
        (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

    `bloc_voter_prop`
    :   dictionary mapping of slate to voter proportions (ex. {bloc: voter proportion}).

    `slate_to_candidates`
    :   dictionary mapping of slate to candidates (ex. {bloc: [candidate1, candidate2]}).

    `cohesion_parameters`
    : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

    **Methods**

    See `BallotGenerator` base class.
    """

    def __init__(
        self,
        cohesion_parameters: dict,
        **data,
    ):
        # Call the parent class's __init__ method to handle common parameters
        super().__init__(cohesion_parameters=cohesion_parameters, **data)

    def generate_profile(
        self, number_of_ballots: int, by_bloc: bool = False
    ) -> Union[PreferenceProfile, Tuple]:
        # compute the number of bloc and crossover voters in each bloc using Huntington Hill
        cohesion_parameters = {
            b: self.cohesion_parameters[b][b] for b in self.cohesion_parameters
        }

        voter_types = [(b, type) for b in self.blocs for type in ["bloc", "cross"]]

        voter_props = [
            cohesion_parameters[b] * self.bloc_voter_prop[b]
            if t == "bloc"
            else (1 - cohesion_parameters[b]) * self.bloc_voter_prop[b]
            for b, t in voter_types
        ]

        ballots_per_type = dict(
            zip(
                voter_types,
                apportion.compute("huntington", voter_props, number_of_ballots),
            )
        )

        pp_by_bloc = {b: PreferenceProfile() for b in self.blocs}

        for i, bloc in enumerate(self.blocs):
            ballot_pool = []
            num_bloc_ballots = ballots_per_type[(bloc, "bloc")]
            num_cross_ballots = ballots_per_type[(bloc, "cross")]

            pref_interval_dict = self.pref_intervals_by_bloc[bloc]

            opposing_slate = self.blocs[(i + 1) % 2]

            opposing_cands = list(pref_interval_dict[opposing_slate].interval.keys())
            bloc_cands = list(pref_interval_dict[bloc].interval.keys())

            pref_for_opposing = list(
                pref_interval_dict[opposing_slate].interval.values()
            )
            pref_for_bloc = list(pref_interval_dict[bloc].interval.values())

            for i in range(num_cross_ballots + num_bloc_ballots):
                bloc_cands = list(
                    np.random.choice(
                        bloc_cands,
                        p=pref_for_bloc,
                        size=len(bloc_cands),
                        replace=False,
                    )
                )
                opposing_cands = list(
                    np.random.choice(
                        opposing_cands,
                        p=pref_for_opposing,
                        size=len(opposing_cands),
                        replace=False,
                    )
                )

                if i < num_cross_ballots:
                    # alternate the bloc and opposing bloc candidates to create crossover ballots
                    ranking = [
                        frozenset({cand})
                        for pair in zip(opposing_cands, bloc_cands)
                        for cand in pair
                    ]
                else:
                    ranking = [frozenset({c}) for c in bloc_cands] + [
                        frozenset({c}) for c in opposing_cands
                    ]

                ballot = Ballot(ranking=tuple(ranking), weight=Fraction(1, 1))
                ballot_pool.append(ballot)

            pp = PreferenceProfile(ballots=ballot_pool)
            pp = pp.condense_ballots()
            pp_by_bloc[bloc] = pp

        # combine the profiles
        pp = PreferenceProfile(ballots=[])
        for profile in pp_by_bloc.values():
            pp += profile

        if by_bloc:
            return (pp_by_bloc, pp)

        # else return the combined profiles
        else:
            return pp

CambridgeSampler

Bases: BallotGenerator

Class for generating ballots based on historical RCV elections occurring in Cambridge. Alternative election data can be used if specified. Assumes that there are two blocs, a majority and a minority bloc, and determines this based on the bloc_voter_prop attr.

Based on cohesion parameters, decides if a voter casts their top choice within their bloc or in the opposing bloc. Then uses historical data; given their first choice, choose a ballot type from the historical distribution.

Attributes

slate_to_candidates : dictionary mapping of slate to candidates (ex. {bloc: [candidate]}).

bloc_voter_prop : dictionary mapping of bloc to voter proportions (ex. {bloc: voter proportion}).

cohesion_parameters : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

pref_intervals_by_bloc : dictionary of dictionaries mapping of bloc to preference intervals. (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

historical_majority : name of majority bloc in historical data, defaults to W for Cambridge.

historical_minority : name of minority bloc in historical data, defaults to C for Cambridge.

path : file path to an election data file to sample from. Defaults to Cambridge elections.

Methods

See BallotGenerator base class.

Source code in src/votekit/ballot_generator.py
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
class CambridgeSampler(BallotGenerator):
    """
    Class for generating ballots based on historical RCV elections occurring
    in Cambridge. Alternative election data can be used if specified. Assumes that there are two
    blocs, a majority and a minority bloc, and determines this based on the bloc_voter_prop attr.

    Based on cohesion parameters, decides if a voter casts their top choice within their bloc
    or in the opposing bloc. Then uses historical data; given their first choice, choose a
    ballot type from the historical distribution.


    **Attributes**

    `slate_to_candidates`
    :   dictionary mapping of slate to candidates (ex. {bloc: [candidate]}).

    `bloc_voter_prop`
    :   dictionary mapping of bloc to voter proportions (ex. {bloc: voter proportion}).

    `cohesion_parameters`
    : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

    `pref_intervals_by_bloc`
    :   dictionary of dictionaries mapping of bloc to preference intervals.
        (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

    `historical_majority`
    : name of majority bloc in historical data, defaults to W for Cambridge.

    `historical_minority`
    : name of minority bloc in historical data, defaults to C for Cambridge.

    `path`
    :   file path to an election data file to sample from. Defaults to Cambridge elections.

    **Methods**

    See `BallotGenerator` base class.
    """

    def __init__(
        self,
        cohesion_parameters: dict,
        path: Optional[Path] = None,
        historical_majority: Optional[str] = "W",
        historical_minority: Optional[str] = "C",
        **data,
    ):
        # Call the parent class's __init__ method to handle common parameters
        super().__init__(cohesion_parameters=cohesion_parameters, **data)

        self.historical_majority = historical_majority
        self.historical_minority = historical_minority

        if len(self.slate_to_candidates.keys()) > 2:
            raise UserWarning(
                f"This model currently only supports at two blocs, but you \
                              passed {len(self.slate_to_candidates.keys())}"
            )

        self.majority_bloc = [
            bloc for bloc, prop in self.bloc_voter_prop.items() if prop >= 0.5
        ][0]

        self.minority_bloc = [
            bloc for bloc in self.bloc_voter_prop.keys() if bloc != self.majority_bloc
        ][0]

        self.bloc_to_historical = {
            self.majority_bloc: self.historical_majority,
            self.minority_bloc: self.historical_minority,
        }

        # # changing names to match historical data, if statement handles generating from_params
        # # only want to run this now if generating from init
        # if len(self.cohesion_parameters) > 0:
        #     self._rename_blocs()

        if path:
            self.path = path
        else:
            BASE_DIR = Path(__file__).resolve().parent
            DATA_DIR = BASE_DIR / "data/"
            self.path = Path(DATA_DIR, "Cambridge_09to17_ballot_types.p")

    def generate_profile(
        self, number_of_ballots: int, by_bloc: bool = False
    ) -> Union[PreferenceProfile, Tuple]:
        with open(self.path, "rb") as pickle_file:
            ballot_frequencies = pickle.load(pickle_file)

        cohesion_parameters = {b: self.cohesion_parameters[b][b] for b in self.blocs}

        # compute the number of bloc and crossover voters in each bloc using Huntington Hill
        voter_types = [
            (b, t) for b in list(self.bloc_voter_prop.keys()) for t in ["bloc", "cross"]
        ]

        voter_props = [
            cohesion_parameters[b] * self.bloc_voter_prop[b]
            if t == "bloc"
            else (1 - cohesion_parameters[b]) * self.bloc_voter_prop[b]
            for b, t in voter_types
        ]

        ballots_per_type = dict(
            zip(
                voter_types,
                apportion.compute("huntington", voter_props, number_of_ballots),
            )
        )

        pp_by_bloc = {b: PreferenceProfile() for b in self.blocs}

        for i, bloc in enumerate(self.blocs):
            bloc_voters = ballots_per_type[(bloc, "bloc")]
            cross_voters = ballots_per_type[(bloc, "cross")]
            ballot_pool = [Ballot()] * (bloc_voters + cross_voters)

            # store the opposition bloc
            opp_bloc = self.blocs[(i + 1) % 2]

            # find total number of ballots that start with bloc and opp_bloc
            bloc_first_count = sum(
                [
                    freq
                    for ballot, freq in ballot_frequencies.items()
                    if ballot[0] == self.bloc_to_historical[bloc]
                ]
            )

            opp_bloc_first_count = sum(
                [
                    freq
                    for ballot, freq in ballot_frequencies.items()
                    if ballot[0] == self.bloc_to_historical[opp_bloc]
                ]
            )

            # Compute the pref interval for this bloc
            pref_interval_dict = combine_preference_intervals(
                list(self.pref_intervals_by_bloc[bloc].values()),
                [cohesion_parameters[bloc], 1 - cohesion_parameters[bloc]],
            )

            # compute the relative probabilities of each ballot
            # sorted by ones where the ballot lists the bloc first
            # and those that list the opp first
            prob_ballot_given_bloc_first = {
                ballot: freq / bloc_first_count
                for ballot, freq in ballot_frequencies.items()
                if ballot[0] == self.bloc_to_historical[bloc]
            }

            prob_ballot_given_opp_first = {
                ballot: freq / opp_bloc_first_count
                for ballot, freq in ballot_frequencies.items()
                if ballot[0] == self.bloc_to_historical[opp_bloc]
            }

            bloc_voter_ordering = random.choices(
                list(prob_ballot_given_bloc_first.keys()),
                weights=list(prob_ballot_given_bloc_first.values()),
                k=bloc_voters,
            )
            cross_voter_ordering = random.choices(
                list(prob_ballot_given_opp_first.keys()),
                weights=list(prob_ballot_given_opp_first.values()),
                k=cross_voters,
            )

            # Generate ballots
            for i in range(bloc_voters + cross_voters):
                # Based on first choice, randomly choose
                # ballots weighted by Cambridge frequency
                if i < bloc_voters:
                    bloc_ordering = bloc_voter_ordering[i]
                else:
                    bloc_ordering = cross_voter_ordering[i - bloc_voters]

                # Now turn bloc ordering into candidate ordering
                pl_ordering = list(
                    np.random.choice(
                        list(pref_interval_dict.interval.keys()),
                        len(pref_interval_dict.interval),
                        p=list(pref_interval_dict.interval.values()),
                        replace=False,
                    )
                )
                ordered_bloc_slate = [
                    c for c in pl_ordering if c in self.slate_to_candidates[bloc]
                ]
                ordered_opp_slate = [
                    c for c in pl_ordering if c in self.slate_to_candidates[opp_bloc]
                ]

                # Fill in the bloc slots as determined
                # With the candidate ordering generated with PL
                full_ballot = []
                for b in bloc_ordering:
                    if b == self.bloc_to_historical[bloc]:
                        if ordered_bloc_slate:
                            full_ballot.append(ordered_bloc_slate.pop(0))
                    else:
                        if ordered_opp_slate:
                            full_ballot.append(ordered_opp_slate.pop(0))

                ranking = tuple([frozenset({cand}) for cand in full_ballot])
                ballot_pool[i] = Ballot(ranking=ranking, weight=Fraction(1, 1))

            pp = PreferenceProfile(ballots=ballot_pool)
            pp = pp.condense_ballots()
            pp_by_bloc[bloc] = pp

        # combine the profiles
        pp = PreferenceProfile(ballots=[])
        for profile in pp_by_bloc.values():
            pp += profile

        if by_bloc:
            return (pp_by_bloc, pp)

        # else return the combined profiles
        else:
            return pp

OneDimSpatial

Bases: BallotGenerator

1-D spatial model for ballot generation. Assumes the candidates are normally distributed on the real line. Then voters are also normally distributed, and vote based on Euclidean distance to the candidates.

Attributes candidates : a list of candidates.

See BallotGenerator base class.

Methods

See BallotGenerator base class.

Source code in src/votekit/ballot_generator.py
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
class OneDimSpatial(BallotGenerator):
    """
    1-D spatial model for ballot generation. Assumes the candidates are normally distributed on
    the real line. Then voters are also normally distributed, and vote based on Euclidean distance
    to the candidates.

    **Attributes**
    `candidates`
        : a list of candidates.

    See `BallotGenerator` base class.

    **Methods**

    See `BallotGenerator` base class.
    """

    def generate_profile(
        self, number_of_ballots: int, by_bloc: bool = False
    ) -> Union[PreferenceProfile, Tuple]:
        candidate_position_dict = {c: np.random.normal(0, 1) for c in self.candidates}
        voter_positions = np.random.normal(0, 1, number_of_ballots)

        ballot_pool = []

        for vp in voter_positions:
            distance_dict = {
                c: abs(v - vp) for c, v, in candidate_position_dict.items()
            }
            candidate_order = sorted(distance_dict, key=distance_dict.__getitem__)
            ballot_pool.append(candidate_order)

        return self.ballot_pool_to_profile(ballot_pool, self.candidates)

ImpartialCulture

Bases: BallotSimplex

Impartial Culture model with an alpha value of 1e10 (should be infinity theoretically). This model is uniform on all linear rankings.

Attributes

candidates : (list) a list of candidates

alpha : (float) alpha parameter for ballot simplex. Defaults to None.

point : dictionary representing a point in the ballot simplex with candidate as keys and electoral support as values. Defaults to None.

Methods

See BallotSimplex object.

Note

Point or alpha arguments must be included to initialize. For details see BallotSimplex and BallotGenerator object.

Source code in src/votekit/ballot_generator.py
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
class ImpartialCulture(BallotSimplex):
    """
    Impartial Culture model with an alpha value of 1e10 (should be infinity theoretically).
    This model is uniform on all linear rankings.


    **Attributes**

    `candidates`
    : (list) a list of candidates

    `alpha`
    :   (float) alpha parameter for ballot simplex. Defaults to None.

    `point`
    :   dictionary representing a point in the ballot simplex with candidate as
        keys and electoral support as values. Defaults to None.



    **Methods**

    See `BallotSimplex` object.

    ???+ note

        Point or alpha arguments must be included to initialize. For details see
        `BallotSimplex` and `BallotGenerator` object.
    """

    def __init__(self, **data):
        super().__init__(alpha=float("inf"), **data)

ImpartialAnonymousCulture

Bases: BallotSimplex

Impartial Anonymous Culture model with an alpha value of 1. This model choose uniformly from among all distributions on full linear rankings, and then draws according to the chosen distribution.

Attributes

candidates : (list) a list of candidates

alpha : (float) alpha parameter for ballot simplex. Defaults to None.

point : dictionary representing a point in the ballot simplex with candidate as keys and electoral support as values. Defaults to None.

Methods

See BallotSimplex base class.

Note

Point or alpha arguments must be included to initialize. For details see BallotSimplex and BallotGenerator object.

Source code in src/votekit/ballot_generator.py
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
class ImpartialAnonymousCulture(BallotSimplex):
    """
    Impartial Anonymous Culture model with an alpha value of 1. This model choose uniformly
        from among all distributions on full linear rankings, and then draws according to the
        chosen distribution.

    **Attributes**

    `candidates`
    : (list) a list of candidates

    `alpha`
    :   (float) alpha parameter for ballot simplex. Defaults to None.

    `point`
    :   dictionary representing a point in the ballot simplex with candidate as
        keys and electoral support as values. Defaults to None.

    **Methods**

    See `BallotSimplex` base class.

    ???+ note

        Point or alpha arguments must be included to initialize. For details see
        `BallotSimplex` and `BallotGenerator` object.
    """

    def __init__(self, **data):
        super().__init__(alpha=1, **data)

name_Cumulative

Bases: BallotGenerator

Class for generating cumulative ballots. This model samples with replacement from a combined preference interval and counts candidates with multiplicity. Can be initialized with an interval or can be constructed with the Dirichlet distribution using the from_params method in the BallotGenerator class.

Attributes

candidates : a list of candidates.

pref_intervals_by_bloc : dictionary of dictionaries mapping of bloc to preference intervals. (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

cohesion_parameters : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

bloc_voter_prop : dictionary mapping of bloc to voter proportions (ex. {bloc: proportion}).

num_votes : the number of votes allowed per ballot.

Methods

See BallotGenerator base class

Source code in src/votekit/ballot_generator.py
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
class name_Cumulative(BallotGenerator):
    """
    Class for generating cumulative ballots. This model samples with
    replacement from a combined preference interval and counts candidates with multiplicity.
    Can be initialized with an interval or can be constructed with the Dirichlet distribution
    using the `from_params` method in the `BallotGenerator` class.

    **Attributes**

    `candidates`
    : a list of candidates.

    `pref_intervals_by_bloc`
    :   dictionary of dictionaries mapping of bloc to preference intervals.
        (ex. {bloc_1: {bloc_1 : PI, bloc_2: PI}}).

    `cohesion_parameters`
    : dictionary of dictionaries of cohesion parameters (ex. {bloc_1: {bloc_1:.7, bloc_2: .3}})

    `bloc_voter_prop`
    :   dictionary mapping of bloc to voter proportions (ex. {bloc: proportion}).

    `num_votes`
    : the number of votes allowed per ballot.

    **Methods**

    See `BallotGenerator` base class
    """

    def __init__(self, cohesion_parameters: dict, num_votes: int, **data):
        # Call the parent class's __init__ method to handle common parameters
        super().__init__(cohesion_parameters=cohesion_parameters, **data)
        self.num_votes = num_votes

        # if dictionary of pref intervals is passed
        if isinstance(
            list(self.pref_intervals_by_bloc.values())[0], PreferenceInterval
        ):
            self.pref_interval_by_bloc = self.pref_intervals_by_bloc

        # if nested dictionary of pref intervals, combine by cohesion
        else:
            self.pref_interval_by_bloc = {
                bloc: combine_preference_intervals(
                    [self.pref_intervals_by_bloc[bloc][b] for b in self.blocs],
                    [self.cohesion_parameters[bloc][b] for b in self.blocs],
                )
                for bloc in self.blocs
            }

    def generate_profile(
        self, number_of_ballots: int, by_bloc: bool = False
    ) -> Union[PreferenceProfile, Tuple]:
        """
        Args:
        `number_of_ballots`: The number of ballots to generate.

        `by_bloc`: True if you want to return a dictionary of PreferenceProfiles by bloc.
                    False if you want the full, aggregated PreferenceProfile.
        """
        # the number of ballots per bloc is determined by Huntington-Hill apportionment
        bloc_props = list(self.bloc_voter_prop.values())
        ballots_per_block = dict(
            zip(
                self.blocs,
                apportion.compute("huntington", bloc_props, number_of_ballots),
            )
        )

        pp_by_bloc = {b: PreferenceProfile() for b in self.blocs}

        for bloc in self.bloc_voter_prop.keys():
            ballot_pool = []
            # number of voters in this bloc
            num_ballots = ballots_per_block[bloc]
            pref_interval = self.pref_interval_by_bloc[bloc]

            # finds candidates with non-zero preference
            non_zero_cands = list(pref_interval.non_zero_cands)
            # creates the interval of probabilities for candidates supported by this block
            cand_support_vec = [pref_interval.interval[cand] for cand in non_zero_cands]

            for _ in range(num_ballots):
                # generates ranking based on probability distribution of non zero candidate support
                list_ranking = list(
                    np.random.choice(
                        non_zero_cands,
                        self.num_votes,
                        p=cand_support_vec,
                        replace=True,
                    )
                )

                ranking = tuple([frozenset({cand}) for cand in list_ranking])

                ballot_pool.append(Ballot(ranking=ranking, weight=Fraction(1, 1)))

            pp = PreferenceProfile(ballots=ballot_pool)
            pp = pp.condense_ballots()
            pp_by_bloc[bloc] = pp

        # combine the profiles
        pp = PreferenceProfile(ballots=[])
        for profile in pp_by_bloc.values():
            pp += profile

        if by_bloc:
            return (pp_by_bloc, pp)

        # else return the combined profiles
        else:
            return pp

generate_profile(number_of_ballots, by_bloc=False)

Args: number_of_ballots: The number of ballots to generate.

by_bloc: True if you want to return a dictionary of PreferenceProfiles by bloc. False if you want the full, aggregated PreferenceProfile.

Source code in src/votekit/ballot_generator.py
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
def generate_profile(
    self, number_of_ballots: int, by_bloc: bool = False
) -> Union[PreferenceProfile, Tuple]:
    """
    Args:
    `number_of_ballots`: The number of ballots to generate.

    `by_bloc`: True if you want to return a dictionary of PreferenceProfiles by bloc.
                False if you want the full, aggregated PreferenceProfile.
    """
    # the number of ballots per bloc is determined by Huntington-Hill apportionment
    bloc_props = list(self.bloc_voter_prop.values())
    ballots_per_block = dict(
        zip(
            self.blocs,
            apportion.compute("huntington", bloc_props, number_of_ballots),
        )
    )

    pp_by_bloc = {b: PreferenceProfile() for b in self.blocs}

    for bloc in self.bloc_voter_prop.keys():
        ballot_pool = []
        # number of voters in this bloc
        num_ballots = ballots_per_block[bloc]
        pref_interval = self.pref_interval_by_bloc[bloc]

        # finds candidates with non-zero preference
        non_zero_cands = list(pref_interval.non_zero_cands)
        # creates the interval of probabilities for candidates supported by this block
        cand_support_vec = [pref_interval.interval[cand] for cand in non_zero_cands]

        for _ in range(num_ballots):
            # generates ranking based on probability distribution of non zero candidate support
            list_ranking = list(
                np.random.choice(
                    non_zero_cands,
                    self.num_votes,
                    p=cand_support_vec,
                    replace=True,
                )
            )

            ranking = tuple([frozenset({cand}) for cand in list_ranking])

            ballot_pool.append(Ballot(ranking=ranking, weight=Fraction(1, 1)))

        pp = PreferenceProfile(ballots=ballot_pool)
        pp = pp.condense_ballots()
        pp_by_bloc[bloc] = pp

    # combine the profiles
    pp = PreferenceProfile(ballots=[])
    for profile in pp_by_bloc.values():
        pp += profile

    if by_bloc:
        return (pp_by_bloc, pp)

    # else return the combined profiles
    else:
        return pp

Elections

Bloc

Bases: Election

Elects m candidates with the highest m-approval scores. The m-approval score of a candidate is equal to the number of voters who rank this candidate among their m top ranked candidates.

Attributes

profile : PreferenceProfile to run election on.

seats : number of seats to be elected.

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
class Bloc(Election):
    """
    Elects m candidates with the highest m-approval scores. The m-approval
    score of a candidate is equal to the number of voters who rank this
    candidate among their m top ranked candidates.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `seats`
    :   number of seats to be elected.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.

     `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        seats: int,
        ballot_ties: bool = True,
        tiebreak: Union[Callable, str] = "random",
    ):
        super().__init__(profile, ballot_ties)
        self.seats = seats
        self.tiebreak = tiebreak

    def run_step(self) -> ElectionState:
        """
        Conducts a Limited election to elect m-candidates.

        Returns:
           An ElectionState object for a Limited election.
        """
        limited_equivalent = Limited(
            profile=self.state.profile,
            seats=self.seats,
            k=self.seats,
            tiebreak=self.tiebreak,
        )
        outcome = limited_equivalent.run_election()
        self.state = outcome
        return outcome

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Runs complete Bloc election.

        Returns:
            An ElectionState object with results for a complete election.
        """
        self.run_step()
        return self.state

run_election() cached

Runs complete Bloc election.

Returns:

Type Description
ElectionState

An ElectionState object with results for a complete election.

Source code in src/votekit/elections/election_types.py
397
398
399
400
401
402
403
404
405
406
@lru_cache
def run_election(self) -> ElectionState:
    """
    Runs complete Bloc election.

    Returns:
        An ElectionState object with results for a complete election.
    """
    self.run_step()
    return self.state

run_step()

Conducts a Limited election to elect m-candidates.

Returns:

Type Description
ElectionState

An ElectionState object for a Limited election.

Source code in src/votekit/elections/election_types.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def run_step(self) -> ElectionState:
    """
    Conducts a Limited election to elect m-candidates.

    Returns:
       An ElectionState object for a Limited election.
    """
    limited_equivalent = Limited(
        profile=self.state.profile,
        seats=self.seats,
        k=self.seats,
        tiebreak=self.tiebreak,
    )
    outcome = limited_equivalent.run_election()
    self.state = outcome
    return outcome

Borda

Bases: Election

Positional voting system that assigns a decreasing number of points to candidates based on order and a score vector. The conventional score vector is \((n, n-1, \dots, 1)\), where \(n\) is the number of candidates. If a ballot is incomplete, the remaining points of the score vector are evenly distributed to the unlisted candidates (see borda_scores function in utils).

Attributes

profile : PreferenceProfile to run election on.

seats : number of seats to be elected.

score_vector : (optional) weights assigned to candidate ranking, should be a list of Fractions. Defaults to \((n,n-1,\dots,1)\).

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
class Borda(Election):
    """
    Positional voting system that assigns a decreasing number of points to
    candidates based on order and a score vector. The conventional score
    vector is $(n, n-1, \dots, 1)$, where $n$ is the number of candidates.
    If a ballot is incomplete, the remaining points of the score vector
    are evenly distributed to the unlisted candidates (see `borda_scores` function in `utils`).

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `seats`
    :   number of seats to be elected.

    `score_vector`
    :   (optional) weights assigned to candidate ranking, should be a list of `Fractions`.
                    Defaults to $(n,n-1,\dots,1)$.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.

    `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        seats: int,
        score_vector: Optional[list[Fraction]] = None,
        ballot_ties: bool = True,
        tiebreak: Union[Callable, str] = "random",
    ):
        super().__init__(profile, ballot_ties)
        self.seats = seats
        self.tiebreak = tiebreak
        self.score_vector = score_vector

    def run_step(self) -> ElectionState:
        """
        Simulates a complete Borda contest as Borda is not a round-by-round
        system.

        Returns:
            An ElectionState object for a complete election.
        """
        borda_dict = borda_scores(
            profile=self.state.profile, score_vector=self.score_vector
        )

        ranking = scores_into_set_list(borda_dict)

        if isinstance(self.tiebreak, str):
            ranking = tie_broken_ranking(
                ranking=ranking, profile=self.state.profile, tiebreak=self.tiebreak
            )
        else:
            ranking = self.tiebreak(ranking=ranking, profile=self.state.profile)

        elected, eliminated = elect_cands_from_set_ranking(
            ranking=ranking, seats=self.seats
        )

        new_state = ElectionState(
            curr_round=self.state.curr_round + 1,
            elected=elected,
            eliminated_cands=eliminated,
            remaining=list(),
            scores=borda_dict,
            profile=PreferenceProfile(),
            previous=self.state,
        )
        self.state = new_state
        return new_state

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Simulates a complete Borda contest.

        Returns:
            An ElectionState object for a complete election.
        """
        self.run_step()
        return self.state

run_election() cached

Simulates a complete Borda contest.

Returns:

Type Description
ElectionState

An ElectionState object for a complete election.

Source code in src/votekit/elections/election_types.py
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
@lru_cache
def run_election(self) -> ElectionState:
    """
    Simulates a complete Borda contest.

    Returns:
        An ElectionState object for a complete election.
    """
    self.run_step()
    return self.state

run_step()

Simulates a complete Borda contest as Borda is not a round-by-round system.

Returns:

Type Description
ElectionState

An ElectionState object for a complete election.

Source code in src/votekit/elections/election_types.py
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
def run_step(self) -> ElectionState:
    """
    Simulates a complete Borda contest as Borda is not a round-by-round
    system.

    Returns:
        An ElectionState object for a complete election.
    """
    borda_dict = borda_scores(
        profile=self.state.profile, score_vector=self.score_vector
    )

    ranking = scores_into_set_list(borda_dict)

    if isinstance(self.tiebreak, str):
        ranking = tie_broken_ranking(
            ranking=ranking, profile=self.state.profile, tiebreak=self.tiebreak
        )
    else:
        ranking = self.tiebreak(ranking=ranking, profile=self.state.profile)

    elected, eliminated = elect_cands_from_set_ranking(
        ranking=ranking, seats=self.seats
    )

    new_state = ElectionState(
        curr_round=self.state.curr_round + 1,
        elected=elected,
        eliminated_cands=eliminated,
        remaining=list(),
        scores=borda_dict,
        profile=PreferenceProfile(),
        previous=self.state,
    )
    self.state = new_state
    return new_state

CondoBorda

Bases: Election

Elects candidates ordered by dominating set, but breaks ties between candidates with Borda.

Attributes

profile : PreferenceProfile to run election on.

seats : number of seats to be elected.

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
class CondoBorda(Election):
    """
    Elects candidates ordered by dominating set, but breaks ties
    between candidates with Borda.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `seats`
    :   number of seats to be elected.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                Defaults to True.

     `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        seats: int,
        ballot_ties: bool = True,
        tiebreak: Union[Callable, str] = "random",
    ):
        super().__init__(profile, ballot_ties)
        self.seats = seats
        self.tiebreak = tiebreak

    def run_step(self) -> ElectionState:
        """
        Conducts a complete Conda-Borda election as it is not a round-by-round
        system.

        Returns:
            An `ElectionState` object for a complete election.
        """
        pwc_graph = PairwiseComparisonGraph(self.state.profile)
        dominating_tiers = pwc_graph.dominating_tiers()

        if isinstance(self.tiebreak, str):
            ranking = tie_broken_ranking(
                ranking=dominating_tiers, profile=self.state.profile, tiebreak="borda"
            )
        else:
            ranking = self.tiebreak(
                ranking=dominating_tiers, profile=self.state.profile
            )

        elected, eliminated = elect_cands_from_set_ranking(
            ranking=ranking, seats=self.seats
        )

        new_state = ElectionState(
            curr_round=self.state.curr_round + 1,
            elected=elected,
            eliminated_cands=eliminated,
            remaining=list(),
            scores=pwc_graph.pairwise_dict,
            profile=PreferenceProfile(),
            previous=self.state,
        )
        self.state = new_state
        return new_state

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Simulates a complete Conda-Borda election.

        Returns:
            An ElectionState object for a complete election.
        """
        self.run_step()
        return self.state

run_election() cached

Simulates a complete Conda-Borda election.

Returns:

Type Description
ElectionState

An ElectionState object for a complete election.

Source code in src/votekit/elections/election_types.py
808
809
810
811
812
813
814
815
816
817
@lru_cache
def run_election(self) -> ElectionState:
    """
    Simulates a complete Conda-Borda election.

    Returns:
        An ElectionState object for a complete election.
    """
    self.run_step()
    return self.state

run_step()

Conducts a complete Conda-Borda election as it is not a round-by-round system.

Returns:

Type Description
ElectionState

An ElectionState object for a complete election.

Source code in src/votekit/elections/election_types.py
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
def run_step(self) -> ElectionState:
    """
    Conducts a complete Conda-Borda election as it is not a round-by-round
    system.

    Returns:
        An `ElectionState` object for a complete election.
    """
    pwc_graph = PairwiseComparisonGraph(self.state.profile)
    dominating_tiers = pwc_graph.dominating_tiers()

    if isinstance(self.tiebreak, str):
        ranking = tie_broken_ranking(
            ranking=dominating_tiers, profile=self.state.profile, tiebreak="borda"
        )
    else:
        ranking = self.tiebreak(
            ranking=dominating_tiers, profile=self.state.profile
        )

    elected, eliminated = elect_cands_from_set_ranking(
        ranking=ranking, seats=self.seats
    )

    new_state = ElectionState(
        curr_round=self.state.curr_round + 1,
        elected=elected,
        eliminated_cands=eliminated,
        remaining=list(),
        scores=pwc_graph.pairwise_dict,
        profile=PreferenceProfile(),
        previous=self.state,
    )
    self.state = new_state
    return new_state

Cumulative

Bases: HighestScore

Voting system where voters are allowed to vote for candidates with multiplicity. Each ranking position should have one candidate, and every candidate ranked will receive one point, i.e., the score vector is \((1,\dots,1)\). Attributes

profile : PreferenceProfile to run election on.

seats : number of seats to be elected.

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
class Cumulative(HighestScore):
    """
    Voting system where voters are allowed to vote for candidates with multiplicity.
    Each ranking position should have one candidate, and every candidate ranked will receive
    one point, i.e., the score vector is $(1,\dots,1)$.
    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `seats`
    :   number of seats to be elected.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.

    `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        seats: int,
        ballot_ties: bool = True,
        tiebreak: Union[str, Callable] = "random",
    ):
        longest_ballot = 0
        for ballot in profile.ballots:
            if len(ballot.ranking) > longest_ballot:
                longest_ballot = len(ballot.ranking)

        score_vector = [1.0 for _ in range(longest_ballot)]
        super().__init__(
            profile=profile,
            ballot_ties=ballot_ties,
            score_vector=score_vector,
            seats=seats,
            tiebreak=tiebreak,
        )

DominatingSets

Bases: Election

Finds tiers of candidates by dominating set, which is a set of candidates such that every candidate in the set wins head to head comparisons against candidates outside of it.

Attributes

profile : PreferenceProfile to run election on.

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

Methods

Source code in src/votekit/elections/election_types.py
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
class DominatingSets(Election):
    """
    Finds tiers of candidates by dominating set, which is a set of candidates
    such that every candidate in the set wins head to head comparisons against
    candidates outside of it.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.


    **Methods**
    """

    def __init__(self, profile: PreferenceProfile, ballot_ties: bool = True):
        super().__init__(profile, ballot_ties)

    def run_step(self) -> ElectionState:
        """
        Conducts a complete DominatingSets election as it is not a round-by-round
        system.

        Returns:
            An ElectionState object for a complete election.
        """
        pwc_graph = PairwiseComparisonGraph(self.state.profile)
        dominating_tiers = pwc_graph.dominating_tiers()
        if len(dominating_tiers) == 1:
            new_state = ElectionState(
                curr_round=self.state.curr_round + 1,
                elected=list(),
                eliminated_cands=dominating_tiers,
                remaining=list(),
                scores=pwc_graph.pairwise_dict,
                profile=PreferenceProfile(),
                previous=self.state,
            )
        else:
            new_state = ElectionState(
                curr_round=self.state.curr_round + 1,
                elected=[set(dominating_tiers[0])],
                eliminated_cands=dominating_tiers[1:],
                remaining=list(),
                scores=pwc_graph.pairwise_dict,
                profile=PreferenceProfile(),
                previous=self.state,
            )
        self.state = new_state
        return new_state

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Simulates a complete DominatingSets election.

        Returns:
            An ElectionState object for a complete election.
        """
        self.run_step()
        return self.state

run_election() cached

Simulates a complete DominatingSets election.

Returns:

Type Description
ElectionState

An ElectionState object for a complete election.

Source code in src/votekit/elections/election_types.py
721
722
723
724
725
726
727
728
729
730
@lru_cache
def run_election(self) -> ElectionState:
    """
    Simulates a complete DominatingSets election.

    Returns:
        An ElectionState object for a complete election.
    """
    self.run_step()
    return self.state

run_step()

Conducts a complete DominatingSets election as it is not a round-by-round system.

Returns:

Type Description
ElectionState

An ElectionState object for a complete election.

Source code in src/votekit/elections/election_types.py
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
def run_step(self) -> ElectionState:
    """
    Conducts a complete DominatingSets election as it is not a round-by-round
    system.

    Returns:
        An ElectionState object for a complete election.
    """
    pwc_graph = PairwiseComparisonGraph(self.state.profile)
    dominating_tiers = pwc_graph.dominating_tiers()
    if len(dominating_tiers) == 1:
        new_state = ElectionState(
            curr_round=self.state.curr_round + 1,
            elected=list(),
            eliminated_cands=dominating_tiers,
            remaining=list(),
            scores=pwc_graph.pairwise_dict,
            profile=PreferenceProfile(),
            previous=self.state,
        )
    else:
        new_state = ElectionState(
            curr_round=self.state.curr_round + 1,
            elected=[set(dominating_tiers[0])],
            eliminated_cands=dominating_tiers[1:],
            remaining=list(),
            scores=pwc_graph.pairwise_dict,
            profile=PreferenceProfile(),
            previous=self.state,
        )
    self.state = new_state
    return new_state

HighestScore

Bases: Election

Conducts an election based on points from score vector. Chooses the m candidates with highest scores. Ties are broken by randomly permuting the tied candidates.

Attributes

profile : PreferenceProfile to run election on.

seats : number of seats to be elected

score_vector : list of floats where ith entry denotes the number of points given to candidates ranked in position i.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

ballot_ties (optional) : resolves ties in ballots. Should only be set to True if you want ballots to have full linear rankings.

Source code in src/votekit/elections/election_types.py
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
class HighestScore(Election):
    """
    Conducts an election based on points from score vector.
    Chooses the m candidates with highest scores.
    Ties are broken by randomly permuting the tied candidates.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `seats`
    :   number of seats to be elected

    `score_vector`
    : list of floats where ith entry denotes the number of points given to candidates
        ranked in position i.

    `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    `ballot_ties` (optional)
    : resolves ties in ballots. Should only be set to True if you want ballots
        to have full linear rankings.


    """

    def __init__(
        self,
        profile: PreferenceProfile,
        seats: int,
        score_vector: list[float],
        tiebreak: Union[Callable, str] = "random",
        ballot_ties: bool = False,
    ):
        super().__init__(profile, ballot_ties)
        # check for valid score vector
        validate_score_vector(score_vector)

        self.seats = seats
        self.score_vector = score_vector
        self.tiebreak = tiebreak

    def run_step(self):
        # a dictionary whose keys are candidates and values are scores
        vote_tallies = compute_scores_from_vector(
            profile=self.state.profile, score_vector=self.score_vector
        )

        # translate scores into ranking of candidates, tie break
        ranking = scores_into_set_list(score_dict=vote_tallies)

        if isinstance(self.tiebreak, str):
            untied_ranking = tie_broken_ranking(
                ranking=ranking, profile=self.state.profile, tiebreak=self.tiebreak
            )
        else:
            untied_ranking = self.tiebreak(ranking=ranking, profile=self.state.profile)

        elected, eliminated = elect_cands_from_set_ranking(
            ranking=untied_ranking, seats=self.seats
        )

        self.state = ElectionState(
            curr_round=1,
            elected=elected,
            eliminated_cands=eliminated,
            remaining=[],
            profile=self.state.profile,
            previous=self.state,
        )
        return self.state

    @lru_cache
    def run_election(self):
        self.run_step()
        return self.state

IRV

Bases: STV

A class for conducting IRV elections, which are mathematically equivalent to STV for one seat.

Attributes

profile : PreferenceProfile to run election on.

quota : formula to calculate quota (defaults to droop).

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Source code in src/votekit/elections/election_types.py
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
class IRV(STV):
    """
    A class for conducting IRV elections, which are mathematically equivalent to STV for one seat.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.


    `quota`
    :   formula to calculate quota (defaults to droop).

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.

    `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        quota: str = "droop",
        ballot_ties: bool = True,
        tiebreak: Union[Callable, str] = "random",
    ):
        # let parent class handle the construction
        super().__init__(
            profile=profile,
            ballot_ties=ballot_ties,
            seats=1,
            tiebreak=tiebreak,
            quota=quota,
            transfer=fractional_transfer,
        )

Limited

Bases: Election

Elects m candidates with the highest k-approval scores. The k-approval score of a candidate is equal to the number of voters who rank this candidate among their k top ranked candidates.

Attributes

profile : PreferenceProfile to run election on.

k : value of an approval score.

seats : number of seats to be elected.

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
class Limited(Election):
    """
    Elects m candidates with the highest k-approval scores.
    The k-approval score of a candidate is equal to the number of voters who
    rank this candidate among their k top ranked candidates.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `k`
    :   value of an approval score.

    `seats`
    :   number of seats to be elected.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.

     `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        seats: int,
        k: int,
        ballot_ties: bool = True,
        tiebreak: Union[Callable, str] = "random",
    ):
        super().__init__(profile, ballot_ties)
        self.seats = seats
        self.k = k
        self.tiebreak = tiebreak

    def run_step(self) -> ElectionState:
        """
        Conducts Limited election in which m candidates are elected based
        on approval scores.

        Returns:
           An ElectionState object for a Limited election.
        """
        profile = self.state.profile
        candidates = profile.get_candidates()
        candidate_approvals = {c: Fraction(0) for c in candidates}

        for ballot in profile.get_ballots():
            # First we have to determine which candidates are approved
            # i.e. in first k ranks on a ballot
            approvals = []
            for i, cand_set in enumerate(ballot.ranking):
                # If list of total candidates before and including current set
                # are less than seat count, all candidates are approved
                if len(list(it.chain(*ballot.ranking[: i + 1]))) < self.k:
                    approvals.extend(list(cand_set))
                # If list of total candidates before current set
                # are greater than seat count, no candidates are approved
                elif len(list(it.chain(*ballot.ranking[:i]))) > self.k:
                    approvals.extend([])
                # Else we know the cutoff is in the set, we compute and randomly
                # select the number of candidates we can select
                else:
                    accepted = len(list(it.chain(*ballot.ranking[:i])))
                    num_to_allow = self.k - accepted
                    approvals.extend(
                        np.random.choice(list(cand_set), num_to_allow, replace=False)
                    )

            # Add approval votes equal to ballot weight (i.e. number of voters with this ballot)
            for cand in approvals:
                candidate_approvals[cand] += ballot.weight

        # Order candidates by number of approval votes received
        ranking = scores_into_set_list(candidate_approvals)

        if isinstance(self.tiebreak, str):
            ranking = tie_broken_ranking(
                ranking=ranking, profile=self.state.profile, tiebreak=self.tiebreak
            )
        else:
            ranking = self.tiebreak(ranking=ranking, profile=self.state.profile)

        elected, eliminated = elect_cands_from_set_ranking(
            ranking=ranking, seats=self.seats
        )
        new_state = ElectionState(
            curr_round=self.state.curr_round + 1,
            elected=elected,
            eliminated_cands=eliminated,
            remaining=list(),
            scores=candidate_approvals,
            profile=PreferenceProfile(),
            previous=self.state,
        )
        self.state = new_state
        return self.state

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Simulates a complete Limited election.

        Returns:
            An ElectionState object with results for a complete election.
        """
        self.run_step()
        return self.state

run_election() cached

Simulates a complete Limited election.

Returns:

Type Description
ElectionState

An ElectionState object with results for a complete election.

Source code in src/votekit/elections/election_types.py
328
329
330
331
332
333
334
335
336
337
@lru_cache
def run_election(self) -> ElectionState:
    """
    Simulates a complete Limited election.

    Returns:
        An ElectionState object with results for a complete election.
    """
    self.run_step()
    return self.state

run_step()

Conducts Limited election in which m candidates are elected based on approval scores.

Returns:

Type Description
ElectionState

An ElectionState object for a Limited election.

Source code in src/votekit/elections/election_types.py
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def run_step(self) -> ElectionState:
    """
    Conducts Limited election in which m candidates are elected based
    on approval scores.

    Returns:
       An ElectionState object for a Limited election.
    """
    profile = self.state.profile
    candidates = profile.get_candidates()
    candidate_approvals = {c: Fraction(0) for c in candidates}

    for ballot in profile.get_ballots():
        # First we have to determine which candidates are approved
        # i.e. in first k ranks on a ballot
        approvals = []
        for i, cand_set in enumerate(ballot.ranking):
            # If list of total candidates before and including current set
            # are less than seat count, all candidates are approved
            if len(list(it.chain(*ballot.ranking[: i + 1]))) < self.k:
                approvals.extend(list(cand_set))
            # If list of total candidates before current set
            # are greater than seat count, no candidates are approved
            elif len(list(it.chain(*ballot.ranking[:i]))) > self.k:
                approvals.extend([])
            # Else we know the cutoff is in the set, we compute and randomly
            # select the number of candidates we can select
            else:
                accepted = len(list(it.chain(*ballot.ranking[:i])))
                num_to_allow = self.k - accepted
                approvals.extend(
                    np.random.choice(list(cand_set), num_to_allow, replace=False)
                )

        # Add approval votes equal to ballot weight (i.e. number of voters with this ballot)
        for cand in approvals:
            candidate_approvals[cand] += ballot.weight

    # Order candidates by number of approval votes received
    ranking = scores_into_set_list(candidate_approvals)

    if isinstance(self.tiebreak, str):
        ranking = tie_broken_ranking(
            ranking=ranking, profile=self.state.profile, tiebreak=self.tiebreak
        )
    else:
        ranking = self.tiebreak(ranking=ranking, profile=self.state.profile)

    elected, eliminated = elect_cands_from_set_ranking(
        ranking=ranking, seats=self.seats
    )
    new_state = ElectionState(
        curr_round=self.state.curr_round + 1,
        elected=elected,
        eliminated_cands=eliminated,
        remaining=list(),
        scores=candidate_approvals,
        profile=PreferenceProfile(),
        previous=self.state,
    )
    self.state = new_state
    return self.state

Plurality

Bases: SNTV

Simulates a single or multi-winner plurality election. Inherits methods from SNTV to run election.

Source code in src/votekit/elections/election_types.py
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
class Plurality(SNTV):
    """
    Simulates a single or multi-winner plurality election. Inherits
    methods from `SNTV` to run election.
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        seats: int,
        ballot_ties: bool = True,
        tiebreak: Union[Callable, str] = "random",
    ):
        super().__init__(profile, ballot_ties)
        self.seats = seats
        self.tiebreak = tiebreak

SNTV

Bases: Election

Single nontransferable vote (SNTV): Elects k candidates with the highest Plurality scores.

Attributes

profile : PreferenceProfile to run election on.

seats : number of seats to be elected.

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
class SNTV(Election):
    """
    Single nontransferable vote (SNTV): Elects k candidates with the highest
    Plurality scores.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `seats`
    :   number of seats to be elected.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.

     `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        seats: int,
        ballot_ties: bool = True,
        tiebreak: Union[Callable, str] = "random",
    ):
        super().__init__(profile, ballot_ties)
        self.seats = seats
        self.tiebreak = tiebreak

    def run_step(self) -> ElectionState:
        """
        Conducts an SNTV election to elect candidates.

        Returns:
           An ElectionState object for a SNTV election.
        """
        limited_equivalent = Limited(
            profile=self.state.profile, seats=self.seats, k=1, tiebreak=self.tiebreak
        )
        outcome = limited_equivalent.run_election()
        self.state = outcome
        return outcome

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Runs complete SNTV election.

        Returns:
            An ElectionState object with results for a complete election.
        """
        self.run_step()
        return self.state

run_election() cached

Runs complete SNTV election.

Returns:

Type Description
ElectionState

An ElectionState object with results for a complete election.

Source code in src/votekit/elections/election_types.py
462
463
464
465
466
467
468
469
470
471
@lru_cache
def run_election(self) -> ElectionState:
    """
    Runs complete SNTV election.

    Returns:
        An ElectionState object with results for a complete election.
    """
    self.run_step()
    return self.state

run_step()

Conducts an SNTV election to elect candidates.

Returns:

Type Description
ElectionState

An ElectionState object for a SNTV election.

Source code in src/votekit/elections/election_types.py
448
449
450
451
452
453
454
455
456
457
458
459
460
def run_step(self) -> ElectionState:
    """
    Conducts an SNTV election to elect candidates.

    Returns:
       An ElectionState object for a SNTV election.
    """
    limited_equivalent = Limited(
        profile=self.state.profile, seats=self.seats, k=1, tiebreak=self.tiebreak
    )
    outcome = limited_equivalent.run_election()
    self.state = outcome
    return outcome

SNTV_STV_Hybrid

Bases: Election

Election method that first runs SNTV to a cutoff, then runs STV to pick a committee with a given number of seats.

Attributes

profile : PreferenceProfile to run election on.

transfer : transfer method (e.g. fractional transfer).

r1_cutoff : first-round cutoff value.

seats : number of seats to be elected.

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
class SNTV_STV_Hybrid(Election):
    """
    Election method that first runs SNTV to a cutoff, then runs STV to
    pick a committee with a given number of seats.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `transfer`
    :   transfer method (e.g. fractional transfer).

    `r1_cutoff`
    :   first-round cutoff value.

    `seats`
    :   number of seats to be elected.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.

     `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        transfer: Callable,
        r1_cutoff: int,
        seats: int,
        ballot_ties: bool = True,
        tiebreak: Union[Callable, str] = "random",
    ):
        super().__init__(profile, ballot_ties)
        self.transfer = transfer
        self.r1_cutoff = r1_cutoff
        self.seats = seats
        self.tiebreak = tiebreak
        self.stage = "SNTV"  # SNTV, switches to STV, then Complete

    def run_step(self, stage: str) -> ElectionState:
        """
        Simulates one round an SNTV_STV election.

        Args:
            stage: Stage of the hybrid election, can be SNTV or STV.

        Returns:
           An ElectionState object for a given round.
        """
        profile = self.state.profile

        new_state = None
        if stage == "SNTV":
            round_state = SNTV(
                profile=profile, seats=self.r1_cutoff, tiebreak=self.tiebreak
            ).run_election()

            # The STV election will be run on the new election state
            # Therefore we should not add any winners, but rather
            # set the SNTV winners as remaining candidates and update pref profiles
            new_profile = PreferenceProfile(
                ballots=remove_cand(
                    set().union(*round_state.eliminated_cands), profile.get_ballots()
                )
            )
            new_state = ElectionState(
                curr_round=self.state.curr_round + 1,
                elected=list(),
                eliminated_cands=round_state.eliminated_cands,
                remaining=[set(new_profile.get_candidates())],
                profile=new_profile,
                scores=round_state.get_scores(round_state.curr_round),
                previous=self.state,
            )
        elif stage == "STV":
            round_state = STV(
                profile=profile,
                transfer=self.transfer,
                seats=self.seats,
                tiebreak=self.tiebreak,
            ).run_election()

            new_state = ElectionState(
                curr_round=self.state.curr_round + 1,
                elected=round_state.winners(),
                eliminated_cands=round_state.eliminated(),
                remaining=round_state.remaining,
                scores=round_state.get_scores(round_state.curr_round),
                profile=round_state.profile,
                previous=self.state,
            )

        # Update election stage to cue next run step
        if stage == "SNTV":
            self.stage = "STV"
        elif stage == "STV":
            self.stage = "Complete"

        self.state = new_state  # type: ignore
        return new_state  # type: ignore

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Runs complete SNTV_STV election.

        Returns:
            An ElectionState object with results for a complete election.
        """
        while self.stage != "Complete":
            self.run_step(self.stage)
        return self.state  # type: ignore

run_election() cached

Runs complete SNTV_STV election.

Returns:

Type Description
ElectionState

An ElectionState object with results for a complete election.

Source code in src/votekit/elections/election_types.py
586
587
588
589
590
591
592
593
594
595
596
@lru_cache
def run_election(self) -> ElectionState:
    """
    Runs complete SNTV_STV election.

    Returns:
        An ElectionState object with results for a complete election.
    """
    while self.stage != "Complete":
        self.run_step(self.stage)
    return self.state  # type: ignore

run_step(stage)

Simulates one round an SNTV_STV election.

Parameters:

Name Type Description Default
stage str

Stage of the hybrid election, can be SNTV or STV.

required

Returns:

Type Description
ElectionState

An ElectionState object for a given round.

Source code in src/votekit/elections/election_types.py
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
def run_step(self, stage: str) -> ElectionState:
    """
    Simulates one round an SNTV_STV election.

    Args:
        stage: Stage of the hybrid election, can be SNTV or STV.

    Returns:
       An ElectionState object for a given round.
    """
    profile = self.state.profile

    new_state = None
    if stage == "SNTV":
        round_state = SNTV(
            profile=profile, seats=self.r1_cutoff, tiebreak=self.tiebreak
        ).run_election()

        # The STV election will be run on the new election state
        # Therefore we should not add any winners, but rather
        # set the SNTV winners as remaining candidates and update pref profiles
        new_profile = PreferenceProfile(
            ballots=remove_cand(
                set().union(*round_state.eliminated_cands), profile.get_ballots()
            )
        )
        new_state = ElectionState(
            curr_round=self.state.curr_round + 1,
            elected=list(),
            eliminated_cands=round_state.eliminated_cands,
            remaining=[set(new_profile.get_candidates())],
            profile=new_profile,
            scores=round_state.get_scores(round_state.curr_round),
            previous=self.state,
        )
    elif stage == "STV":
        round_state = STV(
            profile=profile,
            transfer=self.transfer,
            seats=self.seats,
            tiebreak=self.tiebreak,
        ).run_election()

        new_state = ElectionState(
            curr_round=self.state.curr_round + 1,
            elected=round_state.winners(),
            eliminated_cands=round_state.eliminated(),
            remaining=round_state.remaining,
            scores=round_state.get_scores(round_state.curr_round),
            profile=round_state.profile,
            previous=self.state,
        )

    # Update election stage to cue next run step
    if stage == "SNTV":
        self.stage = "STV"
    elif stage == "STV":
        self.stage = "Complete"

    self.state = new_state  # type: ignore
    return new_state  # type: ignore

STV

Bases: Election

Class for single-winner IRV and multi-winner STV elections.

Attributes

profile : PreferenceProfile to run election on.

transfer : transfer method (e.g. fractional transfer).

seats : number of seats to be elected.

quota : formula to calculate quota (defaults to droop).

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
class STV(Election):
    """
    Class for single-winner IRV and multi-winner STV elections.

     **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `transfer`
    :   transfer method (e.g. fractional transfer).

    `seats`
    :   number of seats to be elected.

    `quota`
    :   formula to calculate quota (defaults to droop).

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.

    `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        transfer: Callable,
        seats: int,
        quota: str = "droop",
        ballot_ties: bool = True,
        tiebreak: Union[str, Callable] = "random",
    ):
        # let parent class handle the og profile and election state
        super().__init__(profile, ballot_ties)

        self.transfer = transfer
        self.seats = seats
        self.tiebreak = tiebreak
        self.quota = quota.lower()
        self.threshold = self.get_threshold()

    # can cache since it will not change throughout rounds
    def get_threshold(self) -> int:
        """
        Calculates threshold required for election.

        Returns:
            Value of the threshold.
        """
        quota = self.quota
        if quota == "droop":
            return int(self._profile.num_ballots() / (self.seats + 1) + 1)
        elif quota == "hare":
            return int(self._profile.num_ballots() / self.seats)
        else:
            raise ValueError("Misspelled or unknown quota type")

    def next_round(self) -> bool:
        """
        Determines if the number of seats has been met to call an election.

        Returns:
            True if number of seats has not been met, False otherwise.
        """
        cands_elected = 0
        for s in self.state.winners():
            cands_elected += len(s)
        return cands_elected < self.seats

    def run_step(self) -> ElectionState:
        """
        Simulates one round an STV election.

        Returns:
           An ElectionState object for a given round.
        """
        remaining = self.state.profile.get_candidates()
        ballots = self.state.profile.get_ballots()
        round_votes, plurality_score = compute_votes(remaining, ballots)

        elected = []
        eliminated = []

        # if number of remaining candidates equals number of remaining seats,
        # everyone is elected
        if len(remaining) == self.seats - len(
            [c for s in self.state.winners() for c in s]
        ):
            elected = [{cand} for cand, _ in round_votes]
            remaining = []
            ballots = []

        # elect all candidates who crossed threshold
        elif round_votes[0].votes >= self.threshold:
            # partition ballots by first place candidate
            cand_to_ballot = ballots_by_first_cand(remaining, ballots)
            new_ballots = []
            for candidate, votes in round_votes:
                if votes >= self.threshold:
                    elected.append({candidate})
                    remaining.remove(candidate)
                    # only transfer on ballots where winner is first
                    new_ballots += self.transfer(
                        candidate,
                        cand_to_ballot[candidate],
                        plurality_score,
                        self.threshold,
                    )

            # add in remaining ballots where non-winners are first
            for cand in remaining:
                new_ballots += cand_to_ballot[cand]

            # remove winners from all ballots
            ballots = remove_cand([c for s in elected for c in s], new_ballots)

        # since no one has crossed threshold, eliminate one of the people
        # with least first place votes
        else:
            lp_candidates = [
                candidate
                for candidate, votes in round_votes
                if votes == round_votes[-1].votes
            ]

            if isinstance(self.tiebreak, str):
                lp_cand = tie_broken_ranking(
                    ranking=[set(lp_candidates)],
                    profile=self.state.profile,
                    tiebreak=self.tiebreak,
                )[-1]
            else:
                lp_cand = self.tiebreak(
                    ranking=[set(lp_candidates)], profile=self.state.profile
                )[-1]

            eliminated.append(lp_cand)
            ballots = remove_cand(lp_cand, ballots)
            remaining.remove(next(iter(lp_cand)))

        # sorts remaining based on their current first place votes
        _, score_dict = compute_votes(remaining, ballots)
        remaining = scores_into_set_list(score_dict, remaining)

        # sort candidates by vote share if multiple are elected
        if len(elected) >= 1:
            elected = scores_into_set_list(
                plurality_score, [c for s in elected for c in s]
            )

        # Make sure list-of-sets have non-empty elements
        elected = [s for s in elected if s != set()]
        eliminated = [s for s in eliminated if s != set()]

        self.state = ElectionState(
            curr_round=self.state.curr_round + 1,
            elected=elected,
            eliminated_cands=eliminated,
            remaining=remaining,
            scores=score_dict,
            profile=PreferenceProfile(ballots=ballots),
            previous=self.state,
        )
        return self.state

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Runs complete STV election.

        Returns:
            An ElectionState object with results for a complete election.
        """
        if not self.next_round():
            raise ValueError(
                f"Length of elected set equal to number of seats ({self.seats})"
            )

        while self.next_round():
            self.run_step()

        return self.state

get_threshold()

Calculates threshold required for election.

Returns:

Type Description
int

Value of the threshold.

Source code in src/votekit/elections/election_types.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def get_threshold(self) -> int:
    """
    Calculates threshold required for election.

    Returns:
        Value of the threshold.
    """
    quota = self.quota
    if quota == "droop":
        return int(self._profile.num_ballots() / (self.seats + 1) + 1)
    elif quota == "hare":
        return int(self._profile.num_ballots() / self.seats)
    else:
        raise ValueError("Misspelled or unknown quota type")

next_round()

Determines if the number of seats has been met to call an election.

Returns:

Type Description
bool

True if number of seats has not been met, False otherwise.

Source code in src/votekit/elections/election_types.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def next_round(self) -> bool:
    """
    Determines if the number of seats has been met to call an election.

    Returns:
        True if number of seats has not been met, False otherwise.
    """
    cands_elected = 0
    for s in self.state.winners():
        cands_elected += len(s)
    return cands_elected < self.seats

run_election() cached

Runs complete STV election.

Returns:

Type Description
ElectionState

An ElectionState object with results for a complete election.

Source code in src/votekit/elections/election_types.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
@lru_cache
def run_election(self) -> ElectionState:
    """
    Runs complete STV election.

    Returns:
        An ElectionState object with results for a complete election.
    """
    if not self.next_round():
        raise ValueError(
            f"Length of elected set equal to number of seats ({self.seats})"
        )

    while self.next_round():
        self.run_step()

    return self.state

run_step()

Simulates one round an STV election.

Returns:

Type Description
ElectionState

An ElectionState object for a given round.

Source code in src/votekit/elections/election_types.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def run_step(self) -> ElectionState:
    """
    Simulates one round an STV election.

    Returns:
       An ElectionState object for a given round.
    """
    remaining = self.state.profile.get_candidates()
    ballots = self.state.profile.get_ballots()
    round_votes, plurality_score = compute_votes(remaining, ballots)

    elected = []
    eliminated = []

    # if number of remaining candidates equals number of remaining seats,
    # everyone is elected
    if len(remaining) == self.seats - len(
        [c for s in self.state.winners() for c in s]
    ):
        elected = [{cand} for cand, _ in round_votes]
        remaining = []
        ballots = []

    # elect all candidates who crossed threshold
    elif round_votes[0].votes >= self.threshold:
        # partition ballots by first place candidate
        cand_to_ballot = ballots_by_first_cand(remaining, ballots)
        new_ballots = []
        for candidate, votes in round_votes:
            if votes >= self.threshold:
                elected.append({candidate})
                remaining.remove(candidate)
                # only transfer on ballots where winner is first
                new_ballots += self.transfer(
                    candidate,
                    cand_to_ballot[candidate],
                    plurality_score,
                    self.threshold,
                )

        # add in remaining ballots where non-winners are first
        for cand in remaining:
            new_ballots += cand_to_ballot[cand]

        # remove winners from all ballots
        ballots = remove_cand([c for s in elected for c in s], new_ballots)

    # since no one has crossed threshold, eliminate one of the people
    # with least first place votes
    else:
        lp_candidates = [
            candidate
            for candidate, votes in round_votes
            if votes == round_votes[-1].votes
        ]

        if isinstance(self.tiebreak, str):
            lp_cand = tie_broken_ranking(
                ranking=[set(lp_candidates)],
                profile=self.state.profile,
                tiebreak=self.tiebreak,
            )[-1]
        else:
            lp_cand = self.tiebreak(
                ranking=[set(lp_candidates)], profile=self.state.profile
            )[-1]

        eliminated.append(lp_cand)
        ballots = remove_cand(lp_cand, ballots)
        remaining.remove(next(iter(lp_cand)))

    # sorts remaining based on their current first place votes
    _, score_dict = compute_votes(remaining, ballots)
    remaining = scores_into_set_list(score_dict, remaining)

    # sort candidates by vote share if multiple are elected
    if len(elected) >= 1:
        elected = scores_into_set_list(
            plurality_score, [c for s in elected for c in s]
        )

    # Make sure list-of-sets have non-empty elements
    elected = [s for s in elected if s != set()]
    eliminated = [s for s in eliminated if s != set()]

    self.state = ElectionState(
        curr_round=self.state.curr_round + 1,
        elected=elected,
        eliminated_cands=eliminated,
        remaining=remaining,
        scores=score_dict,
        profile=PreferenceProfile(ballots=ballots),
        previous=self.state,
    )
    return self.state

SequentialRCV

Bases: Election

Class to conduct Sequential RCV election, in which votes are not transferred after a candidate has reached threshold, or been elected.

Attributes

profile : PreferenceProfile to run election on.

seats : number of seats to be elected.

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
class SequentialRCV(Election):
    """
    Class to conduct Sequential RCV election, in which votes are not transferred
    after a candidate has reached threshold, or been elected.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `seats`
    :   number of seats to be elected.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                Defaults to True.

    `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        seats: int,
        ballot_ties: bool = True,
        tiebreak: Union[Callable, str] = "random",
    ):
        super().__init__(profile, ballot_ties)
        self.seats = seats
        self.tiebreak = tiebreak

    def run_step(self, old_profile: PreferenceProfile) -> ElectionState:
        """
        Simulates a single step of the sequential RCV contest or a full
        IRV election run on the current set of candidates.

         Returns:
           An ElectionState object for a given round.
        """
        old_election_state = self.state

        IRVrun = STV(
            old_profile, transfer=seqRCV_transfer, seats=1, tiebreak=self.tiebreak
        )
        old_election = IRVrun.run_election()
        elected_cand = old_election.winners()[0]

        # Removes elected candidate from Ballot List
        updated_ballots = remove_cand(elected_cand, old_profile.get_ballots())

        # Updates profile with removed candidates
        updated_profile = PreferenceProfile(ballots=updated_ballots)

        self.state = ElectionState(
            curr_round=old_election_state.curr_round + 1,
            elected=[elected_cand],
            profile=updated_profile,
            previous=old_election_state,
            scores=first_place_votes(updated_profile),
            remaining=old_election.remaining,
        )
        return self.state

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Simulates a complete sequential RCV contest.

        Returns:
            An ElectionState object for a complete election.
        """
        old_profile = self._profile
        elected = []  # type: ignore
        seqRCV_step = self.state

        while len(elected) < self.seats:
            seqRCV_step = self.run_step(old_profile)
            elected.append(seqRCV_step.elected)
            old_profile = seqRCV_step.profile
        return seqRCV_step

run_election() cached

Simulates a complete sequential RCV contest.

Returns:

Type Description
ElectionState

An ElectionState object for a complete election.

Source code in src/votekit/elections/election_types.py
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
@lru_cache
def run_election(self) -> ElectionState:
    """
    Simulates a complete sequential RCV contest.

    Returns:
        An ElectionState object for a complete election.
    """
    old_profile = self._profile
    elected = []  # type: ignore
    seqRCV_step = self.state

    while len(elected) < self.seats:
        seqRCV_step = self.run_step(old_profile)
        elected.append(seqRCV_step.elected)
        old_profile = seqRCV_step.profile
    return seqRCV_step

run_step(old_profile)

Simulates a single step of the sequential RCV contest or a full IRV election run on the current set of candidates.

Returns: An ElectionState object for a given round.

Source code in src/votekit/elections/election_types.py
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
def run_step(self, old_profile: PreferenceProfile) -> ElectionState:
    """
    Simulates a single step of the sequential RCV contest or a full
    IRV election run on the current set of candidates.

     Returns:
       An ElectionState object for a given round.
    """
    old_election_state = self.state

    IRVrun = STV(
        old_profile, transfer=seqRCV_transfer, seats=1, tiebreak=self.tiebreak
    )
    old_election = IRVrun.run_election()
    elected_cand = old_election.winners()[0]

    # Removes elected candidate from Ballot List
    updated_ballots = remove_cand(elected_cand, old_profile.get_ballots())

    # Updates profile with removed candidates
    updated_profile = PreferenceProfile(ballots=updated_ballots)

    self.state = ElectionState(
        curr_round=old_election_state.curr_round + 1,
        elected=[elected_cand],
        profile=updated_profile,
        previous=old_election_state,
        scores=first_place_votes(updated_profile),
        remaining=old_election.remaining,
    )
    return self.state

TopTwo

Bases: Election

Eliminates all but the top two plurality vote getters, and then conducts a runoff between them, reallocating other ballots.

Attributes

profile : PreferenceProfile to run election on.

seats : number of seats to be elected.

ballot_ties : (optional) resolves input ballot ties if True, else assumes ballots have no ties. Defaults to True.

tiebreak : (optional) resolves procedural and final ties by specified tiebreak. Can either be a custom tiebreak function or a string. Supported strings are given in tie_broken_ranking documentation. The custom function must take as input two named parameters; ranking, a list-of-sets ranking of candidates and profile, the original PreferenceProfile. It must return a list-of-sets ranking of candidates with no ties. Defaults to random tiebreak.

Methods

Source code in src/votekit/elections/election_types.py
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
class TopTwo(Election):
    """
    Eliminates all but the top two plurality vote getters, and then
    conducts a runoff between them, reallocating other ballots.

    **Attributes**

    `profile`
    :   PreferenceProfile to run election on.

    `seats`
    :   number of seats to be elected.

    `ballot_ties`
    :   (optional) resolves input ballot ties if True, else assumes ballots have no ties.
                    Defaults to True.

     `tiebreak`
    :   (optional) resolves procedural and final ties by specified tiebreak.
                    Can either be a custom tiebreak function or a string. Supported strings are
                    given in `tie_broken_ranking` documentation. The custom function must take as
                    input two named parameters; `ranking`, a list-of-sets ranking of candidates and
                    `profile`, the original `PreferenceProfile`. It must return a list-of-sets
                    ranking of candidates with no ties. Defaults to random tiebreak.

    **Methods**
    """

    def __init__(
        self,
        profile: PreferenceProfile,
        ballot_ties: bool = True,
        tiebreak: Union[str, Callable] = "random",
    ):
        super().__init__(profile, ballot_ties)
        self.tiebreak = tiebreak

    def run_step(self) -> ElectionState:
        """
        Conducts a TopTwo election for one seat with a cutoff of 2 for the runoff.

        Returns:
            An ElectionState object for the TopTwo election.
        """
        hybrid_equivalent = SNTV_STV_Hybrid(
            profile=self.state.profile,
            transfer=fractional_transfer,
            r1_cutoff=2,
            seats=1,
            tiebreak=self.tiebreak,
        )
        outcome = hybrid_equivalent.run_election()
        self.state = outcome
        return outcome

    @lru_cache
    def run_election(self) -> ElectionState:
        """
        Simulates a complete TopTwo election.

        Returns:
            An ElectionState object for a complete election.
        """
        self.run_step()
        return self.state

run_election() cached

Simulates a complete TopTwo election.

Returns:

Type Description
ElectionState

An ElectionState object for a complete election.

Source code in src/votekit/elections/election_types.py
654
655
656
657
658
659
660
661
662
663
@lru_cache
def run_election(self) -> ElectionState:
    """
    Simulates a complete TopTwo election.

    Returns:
        An ElectionState object for a complete election.
    """
    self.run_step()
    return self.state

run_step()

Conducts a TopTwo election for one seat with a cutoff of 2 for the runoff.

Returns:

Type Description
ElectionState

An ElectionState object for the TopTwo election.

Source code in src/votekit/elections/election_types.py
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
def run_step(self) -> ElectionState:
    """
    Conducts a TopTwo election for one seat with a cutoff of 2 for the runoff.

    Returns:
        An ElectionState object for the TopTwo election.
    """
    hybrid_equivalent = SNTV_STV_Hybrid(
        profile=self.state.profile,
        transfer=fractional_transfer,
        r1_cutoff=2,
        seats=1,
        tiebreak=self.tiebreak,
    )
    outcome = hybrid_equivalent.run_election()
    self.state = outcome
    return outcome

Cleaning

clean_profile(pp, clean_ballot_func)

Allows user-defined cleaning rules for PreferenceProfile. Input function that applies modification or rule to a single ballot.

Parameters:

Name Type Description Default
pp PreferenceProfile

A PreferenceProfile to clean.

required
clean_ballot_func Callable[[Ballot], Ballot]

Function that takes a list of ballots and cleans each ballot.

required

Returns:

Type Description
PreferenceProfile

A cleaned PreferenceProfile.

Source code in src/votekit/cleaning.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def clean_profile(
    pp: PreferenceProfile, clean_ballot_func: Callable[[Ballot], Ballot]
) -> PreferenceProfile:
    """
    Allows user-defined cleaning rules for PreferenceProfile. Input function
    that applies modification or rule to a single ballot.

    Args:
        pp (PreferenceProfile): A PreferenceProfile to clean.
        clean_ballot_func (Callable[[Ballot], Ballot]): Function that
            takes a list of ballots and cleans each ballot.

    Returns:
        (PreferenceProfile): A cleaned PreferenceProfile.
    """

    # apply cleaning function to clean all ballots
    if clean_ballot_func is not None:
        cleaned = map(clean_ballot_func, pp.ballots)
    # group ballots that have the same ranking after cleaning
    grouped_ballots = [
        list(result)
        for key, result in groupby(cleaned, key=lambda ballot: ballot.ranking)
    ]
    # merge ballots in the same groups
    new_ballots = [merge_ballots(b) for b in grouped_ballots]
    return PreferenceProfile(ballots=new_ballots)

deduplicate_profiles(pp)

Given a PreferenceProfile, deduplicates its ballots.

Parameters:

Name Type Description Default
pp PreferenceProfile

A PreferenceProfile to clean.

required

Returns:

Type Description
PreferenceProfile

A cleaned PreferenceProfile without duplicates.

Source code in src/votekit/cleaning.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def deduplicate_profiles(pp: PreferenceProfile) -> PreferenceProfile:
    """
    Given a PreferenceProfile, deduplicates its ballots.

    Args:
        pp (PreferenceProfile): A PreferenceProfile to clean.

    Returns:
        (PreferenceProfile): A cleaned PreferenceProfile without duplicates.
    """

    def deduplicate_ballots(ballot: Ballot) -> Ballot:
        """
        Takes a ballot and deduplicates its rankings.

        Args:
            ballot (Ballot): a ballot with duplicates in its ranking.

        Returns:
            Ballot: a ballot without duplicates.
        """
        ranking = ballot.ranking
        dedup_ranking = []
        for cand in ranking:
            if cand in ranking and cand not in dedup_ranking:
                dedup_ranking.append(cand)
        new_ballot = Ballot(
            id=ballot.id,
            weight=Fraction(ballot.weight),
            ranking=tuple(dedup_ranking),
            voter_set=ballot.voter_set,
        )
        return new_ballot

    pp_clean = clean_profile(pp=pp, clean_ballot_func=deduplicate_ballots)
    return pp_clean

merge_ballots(ballots)

Takes a list of ballots with the same ranking and merge them into one ballot.

Parameters:

Name Type Description Default
ballots list[Ballot]

A list of ballots to deduplicate.

required

Returns:

Type Description
Ballot

A ballot with the same ranking and aggregated weight and voters.

Source code in src/votekit/cleaning.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def merge_ballots(ballots: list[Ballot]) -> Ballot:
    """
    Takes a list of ballots with the same ranking and merge them into one ballot.

    Args:
        ballots (list[Ballot]): A list of ballots to deduplicate.

    Returns:
        (Ballot): A ballot with the same ranking and aggregated weight and voters.
    """
    weight = sum(b.weight for b in ballots)
    ranking = ballots[0].ranking
    voters_to_merge = [b.voter_set for b in ballots if b.voter_set]
    voter_set = None
    if len(voters_to_merge) > 0:
        voter_set = reduce(lambda b1, b2: b1.union(b2), voters_to_merge)
        voter_set = set(voter_set)
    return Ballot(ranking=ranking, voter_set=voter_set, weight=Fraction(weight))

remove_empty_ballots(pp, keep_candidates=False)

Removes empty ballots from a PreferenceProfile.

Parameters:

Name Type Description Default
pp PreferenceProfile

A PreferenceProfile to clean.

required
keep_candidates bool

If True, keep all of the candidates from the original PreferenceProfile in the returned PreferenceProfile, even if they got no votes. Defaults to False.

False

Returns:

Type Description
PreferenceProfile

A cleaned PreferenceProfile.

Source code in src/votekit/cleaning.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def remove_empty_ballots(
    pp: PreferenceProfile, keep_candidates: bool = False
) -> PreferenceProfile:
    """
    Removes empty ballots from a PreferenceProfile.

    Args:
        pp (PreferenceProfile): A PreferenceProfile to clean.
        keep_candidates (bool, optional): If True, keep all of the candidates
            from the original PreferenceProfile in the returned PreferenceProfile, even if
            they got no votes. Defaults to False.

    Returns:
        (PreferenceProfile): A cleaned PreferenceProfile.
    """

    ballots_nonempty = [
        deepcopy(ballot) for ballot in pp.get_ballots() if ballot.ranking
    ]
    if keep_candidates:
        old_cands = deepcopy(pp.get_candidates())
        pp_clean = PreferenceProfile(ballots=ballots_nonempty, candidates=old_cands)
    else:
        pp_clean = PreferenceProfile(ballots=ballots_nonempty)
    return pp_clean

remove_noncands(profile, non_cands)

Removes user-assigned non-candidates from ballots, deletes ballots that are empty as a result of the removal.

Parameters:

Name Type Description Default
profile PreferenceProfile

A PreferenceProfile to clean.

required
non_cands list[str]

A list of non-candidates to be removed.

required

Returns:

Type Description
PreferenceProfile

A profile with non-candidates removed.

Source code in src/votekit/cleaning.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def remove_noncands(
    profile: PreferenceProfile, non_cands: list[str]
) -> PreferenceProfile:
    """
    Removes user-assigned non-candidates from ballots, deletes ballots
    that are empty as a result of the removal.

    Args:
        profile (PreferenceProfile): A PreferenceProfile to clean.
        non_cands (list[str]): A list of non-candidates to be removed.

    Returns:
        (PreferenceProfile): A profile with non-candidates removed.
    """

    def remove_from_ballots(ballot: Ballot, non_cands: list[str]) -> Ballot:
        """
        Removes non-candidiates from ballot objects.

        Args:
            ballot (Ballot): a ballot to be cleaned.
            non_cands (list[str]): a list of candidates to remove.

        Returns:
            Ballot: returns a cleaned Ballot.
        """
        # TODO: adjust so string and list of strings are acceptable inputes

        to_remove = []
        for item in non_cands:
            to_remove.append({item})

        ranking = ballot.ranking
        clean_ranking = []
        for cand in ranking:
            if cand not in to_remove and cand not in clean_ranking:
                clean_ranking.append(cand)

        clean_ballot = Ballot(
            id=ballot.id,
            ranking=tuple(clean_ranking),
            weight=Fraction(ballot.weight),
            voter_set=ballot.voter_set,
        )

        return clean_ballot

    cleaned = [
        remove_from_ballots(ballot, non_cands)
        for ballot in profile.ballots
        if remove_from_ballots(ballot, non_cands).ranking
    ]
    grouped_ballots = [
        list(result)
        for key, result in groupby(cleaned, key=lambda ballot: ballot.ranking)
    ]
    # merge ballots in the same groups
    new_ballots = [merge_ballots(b) for b in grouped_ballots]
    return PreferenceProfile(ballots=new_ballots)

Metrics

borda_scores(profile, ballot_length=None, score_vector=None)

Calculates Borda scores for a PreferenceProfile.

Parameters:

Name Type Description Default
profile PreferenceProfile

Inputed PreferenceProfile of ballots.

required
ballot_length Optional[int]

Length of a ballot, if None length of longest ballot is used.

None
score_vector Optional[list]

Borda weights, if None, vector is assigned \((n,n-1,\dots,1)\).

None

Returns:

Type Description
dict

Dictionary of candidates (keys) and Borda scores (values).

Source code in src/votekit/utils.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
def borda_scores(
    profile: PreferenceProfile,
    ballot_length: Optional[int] = None,
    score_vector: Optional[list] = None,
) -> dict:
    """
    Calculates Borda scores for a PreferenceProfile.

    Args:
        profile: Inputed PreferenceProfile of ballots.
        ballot_length: Length of a ballot, if None length of longest ballot is
            used.
        score_vector: Borda weights, if None, vector is assigned $(n,n-1,\dots,1)$.

    Returns:
        (dict): Dictionary of candidates (keys) and Borda scores (values).
    """
    candidates = profile.get_candidates()
    if ballot_length is None:
        ballot_length = max([len(ballot.ranking) for ballot in profile.ballots])
    if score_vector is None:
        score_vector = list(range(ballot_length, 0, -1))

    candidate_borda = {c: Fraction(0) for c in candidates}
    for ballot in profile.ballots:
        current_ind = 0
        candidates_covered = []
        for s in ballot.ranking:
            position_size = len(s)
            local_score_vector = score_vector[current_ind : current_ind + position_size]
            borda_allocation = sum(local_score_vector) / position_size
            for c in s:
                candidate_borda[c] += Fraction(borda_allocation) * ballot.weight
            current_ind += position_size
            candidates_covered += list(s)

        # If ballot was incomplete, evenly allocation remaining points
        if current_ind < len(score_vector):
            remainder_cands = set(candidates).difference(set(candidates_covered))
            remainder_score_vector = score_vector[current_ind:]
            remainder_borda_allocation = sum(remainder_score_vector) / len(
                remainder_cands
            )
            for c in remainder_cands:
                candidate_borda[c] += (
                    Fraction(remainder_borda_allocation) * ballot.weight
                )

    return candidate_borda

first_place_votes(profile, to_float=False)

Calculates first-place votes for a PreferenceProfile.

Parameters:

Name Type Description Default
profile PreferenceProfile

Inputed PreferenceProfile of ballots.

required
to_float bool

If True, compute first place votes as floats instead of Fractions. Defaults to False.

False

Returns:

Type Description
dict

Dictionary of candidates (keys) and first place vote totals (values).

Source code in src/votekit/utils.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def first_place_votes(profile: PreferenceProfile, to_float: bool = False) -> dict:
    """
    Calculates first-place votes for a PreferenceProfile.

    Args:
        profile: Inputed PreferenceProfile of ballots.

        to_float: If True, compute first place votes as floats instead of Fractions. Defaults to
                    False.

    Returns:
        Dictionary of candidates (keys) and first place vote totals (values).
    """
    cands = profile.get_candidates()
    ballots = profile.get_ballots()

    _, votes_dict = compute_votes(cands, ballots)

    if to_float:
        votes_dict = {k: float(v) for k, v in votes_dict.items()}
        return votes_dict
    else:
        return votes_dict

mentions(profile)

Calculates total mentions for a PreferenceProfile.

Parameters:

Name Type Description Default
profile PreferenceProfile

Inputed PreferenceProfile of ballots.

required

Returns:

Type Description
dict

Dictionary of candidates (keys) and mention totals (values).

Source code in src/votekit/utils.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def mentions(profile: PreferenceProfile) -> dict:
    """
    Calculates total mentions for a PreferenceProfile.

    Args:
        profile: Inputed PreferenceProfile of ballots.

    Returns:
        Dictionary of candidates (keys) and mention totals (values).
    """
    mentions: dict[str, float] = {}

    ballots = profile.get_ballots()
    for ballot in ballots:
        for rank in ballot.ranking:
            for cand in rank:
                if cand not in mentions:
                    mentions[cand] = 0
                if len(rank) > 1:
                    mentions[cand] += (1 / len(rank)) * int(
                        ballot.weight
                    )  # split mentions for candidates that are tied
                else:
                    mentions[cand] += float(ballot.weight)

    return mentions

earth_mover_dist(pp1, pp2)

Computes the earth mover distance between two elections. Assumes both elections share the same candidates.

Parameters:

Name Type Description Default
pp1 PreferenceProfile

PreferenceProfile for first election.

required
pp2 PreferenceProfile

PreferenceProfile for second election.

required

Returns:

Type Description
int

Earth mover distance between inputted elections.

Source code in src/votekit/metrics/distances.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def earth_mover_dist(pp1: PreferenceProfile, pp2: PreferenceProfile) -> int:
    """
    Computes the earth mover distance between two elections.
    Assumes both elections share the same candidates.

    Args:
        pp1: PreferenceProfile for first election.
        pp2: PreferenceProfile for second election.

    Returns:
        Earth mover distance between inputted elections.
    """
    # create ballot graph
    ballot_graph = BallotGraph(source=pp2).graph
    # ballot_graph = graph.from_profile(profile=pp2, complete=True)

    # Solving Earth Mover Distance
    electA_distr = np.array(em_array(pp=pp1))
    electB_distr = np.array(em_array(pp=pp2))

    # Floyd Warshall Shortest Distance alorithm. Returns a dictionary of shortest path for each node
    fw_dist_dict = nx.floyd_warshall(ballot_graph)
    keys_list = sorted(fw_dist_dict.keys())
    cost_matrix = np.zeros((len(keys_list), len(keys_list)))
    for i in range(len(keys_list)):
        node_dict = fw_dist_dict[keys_list[i]]
        cost_col = [value for key, value in sorted(node_dict.items())]
        cost_matrix[i] = cost_col
    earth_mover_matrix = ot.emd(electA_distr, electB_distr, cost_matrix)

    # Hadamard Product = Earth mover dist between two matrices
    earth_mover_dist = np.sum(np.multiply(cost_matrix, earth_mover_matrix))
    return earth_mover_dist

lp_dist(pp1, pp2, p_value=1)

Computes the L_p distance between two election distributions. Use 'inf' for infinity norm. Assumes both elections share the same candidates.

Parameters:

Name Type Description Default
pp1 PreferenceProfile

PreferenceProfile for first election.

required
pp2 PreferenceProfile

PreferenceProfile for second election.

required
p_value Optional[Union[int, str]]

Distance parameter, 1 for Manhattan, 2 for Euclidean or 'inf' for Chebyshev distance.

1

Returns:

Type Description
int

Lp distance between two elections.

Source code in src/votekit/metrics/distances.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def lp_dist(
    pp1: PreferenceProfile,
    pp2: PreferenceProfile,
    p_value: Optional[Union[int, str]] = 1,
) -> int:
    """
    Computes the L_p distance between two election distributions.
    Use 'inf' for infinity norm.
    Assumes both elections share the same candidates.

    Args:
        pp1: PreferenceProfile for first election.
        pp2: PreferenceProfile for second election.
        p_value: Distance parameter, 1 for Manhattan, 2 for Euclidean
            or 'inf' for Chebyshev distance.

    Returns:
        Lp distance between two elections.
    """
    pp_list = [pp1, pp2]
    pp_2arry = profiles_to_ndarrys(pp_list)
    electA = pp_2arry[:, 0]
    electB = pp_2arry[:, 1]

    if isinstance(p_value, int):
        sum = 0
        for i in range(len(electA)):
            diff = (abs(electA[i] - electB[i])) ** p_value
            sum += diff
        lp_dist = sum ** (1 / p_value)
        return lp_dist

    elif p_value == "inf":
        diff = [abs(x - y) for x, y in zip(electA, electB)]
        return max(diff)

    else:
        raise ValueError("Unsupported input type")

em_array(pp)

Converts a PreferenceProfile into a distribution using ballot graphs.

Parameters:

Name Type Description Default
pp PreferenceProfile

PreferenceProfile for a given election.

required

Returns:

Type Description
list

Distribution of ballots for an election.

Source code in src/votekit/metrics/distances.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def em_array(pp: PreferenceProfile) -> list:
    """
    Converts a PreferenceProfile into a distribution using ballot graphs.

    Args:
        pp: PreferenceProfile for a given election.

    Returns:
        Distribution of ballots for an election.
    """
    ballot_graph = BallotGraph(source=pp)
    node_cand_map = ballot_graph.label_cands(sorted(pp.get_candidates()))
    pp_dict = pp.to_dict(True)

    # invert node_cand_map to map to pp_dict
    # split is used to remove the custom labeling from the ballotgraph
    inverted = {v.split(":")[0]: k for k, v in node_cand_map.items()}
    combined_dict = {k: 0 for k in node_cand_map}

    # map nodes with weight of corresponding rank
    # labels on ballotgraph are strings so need to convert key to string
    node_pp_dict = {inverted[str(key)]: pp_dict[key] for key in pp_dict}

    complete_election_dict = combined_dict | node_pp_dict
    elect_distr = [
        float(complete_election_dict[key])
        for key in sorted(complete_election_dict.keys())
    ]

    return elect_distr

fractional_transfer(winner, ballots, votes, threshold)

Calculates fractional transfer from winner, then removes winner from the list of ballots.

Parameters:

Name Type Description Default
winner str

Candidate to transfer votes from.

required
ballots list[Ballot]

List of Ballot objects.

required
votes dict

Contains candidates and their corresponding vote totals.

required
threshold int

Value required to be elected, used to calculate transfer value.

required

Returns:

Type Description
list[Ballot]

Modified ballots with transfered weights and the winning candidate removed.

Source code in src/votekit/elections/transfers.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def fractional_transfer(
    winner: str, ballots: list[Ballot], votes: dict, threshold: int
) -> list[Ballot]:
    """
    Calculates fractional transfer from winner, then removes winner
    from the list of ballots.

    Args:
        winner: Candidate to transfer votes from.
        ballots: List of Ballot objects.
        votes: Contains candidates and their corresponding vote totals.
        threshold: Value required to be elected, used to calculate transfer value.

    Returns:
        Modified ballots with transfered weights and the winning candidate removed.
    """
    transfer_value = (votes[winner] - threshold) / votes[winner]

    transfered_ballots = []
    for ballot in ballots:
        new_ranking = []
        if ballot.ranking and ballot.ranking[0] == {winner}:
            transfered_weight = ballot.weight * transfer_value
            for cand in ballot.ranking:
                if cand != {winner}:
                    new_ranking.append(frozenset(cand))
            transfered_ballots.append(
                Ballot(
                    ranking=tuple(new_ranking),
                    weight=transfered_weight,
                    voter_set=ballot.voter_set,
                    id=ballot.id,
                )
            )
        else:
            transfered_ballots.append(ballot)

    return remove_cand(winner, transfered_ballots)

seqRCV_transfer(winner, ballots, votes, threshold)

Transfer method for Sequential RCV elections.

Parameters:

Name Type Description Default
winner str

Candidate to transfer votes from.

required
ballots list[Ballot]

List of Ballot objects.

required
votes dict

Contains candidates and their corresponding vote totals.

required
threshold int

Value required to be elected, used to calculate transfer value.

required

Returns:

Type Description
list[Ballot]

Original list of ballots as Sequential RCV does not transfer votes.

Source code in src/votekit/elections/transfers.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def seqRCV_transfer(
    winner: str, ballots: list[Ballot], votes: dict, threshold: int
) -> list[Ballot]:
    """
    Transfer method for Sequential RCV elections.

    Args:
        winner: Candidate to transfer votes from.
        ballots: List of Ballot objects.
        votes: Contains candidates and their corresponding vote totals.
        threshold: Value required to be elected, used to calculate transfer value.

    Returns:
        Original list of ballots as Sequential RCV does not transfer votes.
    """
    return ballots

random_transfer(winner, ballots, votes, threshold)

Cambridge-style transfer where transfer ballots are selected randomly.

Parameters:

Name Type Description Default
winner str

Candidate to transfer votes from.

required
ballots list[Ballot]

List of Ballot objects.

required
votes dict

Contains candidates and their corresponding vote totals.

required
threshold int

Value required to be elected, used to calculate transfer value.

required

Returns:

Type Description
list[Ballot]

Modified ballots with transferred weights and the winning candidate removed.

Source code in src/votekit/elections/transfers.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def random_transfer(
    winner: str, ballots: list[Ballot], votes: dict, threshold: int
) -> list[Ballot]:
    """
    Cambridge-style transfer where transfer ballots are selected randomly.

    Args:
        winner: Candidate to transfer votes from.
        ballots: List of Ballot objects.
        votes: Contains candidates and their corresponding vote totals.
        threshold: Value required to be elected, used to calculate transfer value.

    Returns:
        Modified ballots with transferred weights and the winning candidate removed.
    """

    # turn all of winner's ballots into (multiple) ballots of weight 1
    weight_1_ballots = []
    updated_ballots = []
    for ballot in ballots:
        if ballot.ranking and ballot.ranking[0] == {winner}:
            # note: under random transfer, weights should always be integers
            for _ in range(int(ballot.weight)):
                weight_1_ballots.append(
                    Ballot(
                        id=ballot.id,
                        ranking=ballot.ranking,
                        weight=Fraction(1),
                        voter_set=ballot.voter_set,
                    )
                )
        else:
            updated_ballots.append(ballot)

    surplus_ballots = random.sample(weight_1_ballots, int(votes[winner]) - threshold)
    updated_ballots += surplus_ballots

    return remove_cand(winner, updated_ballots)

Plotting

compute_MDS(data, distance, random_seed=47, *args, **kwargs)

Computes the coordinates of an MDS plot. This is time intensive, so it is decoupled from plot_mds to allow users to flexibly use the coordinates.

Parameters:

Name Type Description Default
data Dict[str, list[PreferenceProfile]]

Dictionary with key being a string label and value being list of PreferenceProfiles. ex: {'PL with alpha = 4': list[PreferenceProfile]}

required
distance Callable[..., int]

Distance function. See distance.py.

required
random_seed int

an integer seed to allow for reproducible MDS plots. Defaults to 47.

47

Returns:

Name Type Description
coord_dict dict

a dictionary whose keys match data and whose values are tuples of

numpy arrays (x_list, y_list) of coordinates for the MDS plot.

Source code in src/votekit/plots/mds.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def compute_MDS(
    data: Dict[str, list[PreferenceProfile]],
    distance: Callable[..., int],
    random_seed: int = 47,
    *args,
    **kwargs
):
    """
    Computes the coordinates of an MDS plot. This is time intensive, so it is decoupled from
    `plot_mds` to allow users to flexibly use the coordinates.

    Args:
        data: Dictionary with key being a string label and value being list of
                    PreferenceProfiles. ex: {'PL with alpha = 4': list[PreferenceProfile]}
        distance: Distance function. See distance.py.
        random_seed (int): an integer seed to allow for reproducible MDS plots. Defaults to 47.

    Returns:
        coord_dict (dict): a dictionary whose keys match `data` and whose values are tuples of
        numpy arrays (x_list, y_list) of coordinates for the MDS plot.
    """
    # combine all lists to create distance matrix
    combined_pp = []
    for pp_list in data.values():
        combined_pp.extend(pp_list)

    # compute distance matrix
    dist_matrix = distance_matrix(combined_pp, distance, *args, **kwargs)

    mds = manifold.MDS(
        n_components=2,
        max_iter=3000,
        eps=1e-9,
        dissimilarity="precomputed",
        n_jobs=1,
        normalized_stress="auto",
        random_state=random_seed,
    )
    pos = mds.fit(np.array(dist_matrix)).embedding_

    coord_dict = {}
    start_pos = 0
    for key, value_list in data.items():
        # color, label, marker = key
        end_pos = start_pos + len(value_list)
        coord_dict[key] = (pos[start_pos:end_pos, 0], pos[start_pos:end_pos, 1])
        start_pos += len(value_list)

    return coord_dict

distance_matrix(pp_arr, distance, *args, **kwargs)

Creates pairwise distance matrix between PreferenceProfile. The \((i,j)\) entry is the pairwise distance between \(i\)th and the \(j\)th PreferenceProfile.

Parameters:

Name Type Description Default
pp_arr list[PreferenceProfile]

List of PreferenceProfiles.

required
distance Callable[..., int]

Callable distance function type. See distances.py in the metrics module.

required

Returns:

Name Type Description
dist_matrix ndarray

Distance matrix for an election.

Source code in src/votekit/plots/mds.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def distance_matrix(
    pp_arr: list[PreferenceProfile], distance: Callable[..., int], *args, **kwargs
):
    """
    Creates pairwise distance matrix between PreferenceProfile. The $(i,j)$ entry is the pairwise
    distance between $i$th and the $j$th PreferenceProfile.

    Args:
        pp_arr: List of PreferenceProfiles.
        distance: Callable distance function type. See distances.py in the metrics module.

    Returns:
        dist_matrix (ndarray): Distance matrix for an election.
    """
    rows = len(pp_arr)
    dist_matrix = np.zeros((rows, rows))

    for i in range(rows):
        for j in range(i + 1, rows):
            dist_matrix[i][j] = distance(pp_arr[i], pp_arr[j], *args, **kwargs)
            dist_matrix[j][i] = dist_matrix[i][j]
    return dist_matrix

plot_MDS(coord_dict, plot_kwarg_dict=None, legend=True, title=True)

Creates an MDS plot from the output of compute_MDS with legend labels matching the keys of coord_dict.

Parameters:

Name Type Description Default
coord_dict dict

Dictionary with key being a string label and value being tuple

required
plot_kwarg_dict Optional[dict]

Dictionary with keys matching coord_dict and values are kwarg dictionaries that will be passed to matplotlib scatter.

None
legend bool

boolean for plotting the legend. Defaults to True.

True
title bool

boolean for plotting the title. Defaults to True.

True

Returns:

Name Type Description
fig

a matplotlib fig

Source code in src/votekit/plots/mds.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def plot_MDS(
    coord_dict: dict,
    plot_kwarg_dict: Optional[dict] = None,
    legend: bool = True,
    title: bool = True,
):
    """
    Creates an MDS plot from the output of `compute_MDS` with legend labels matching the keys
    of `coord_dict`.

    Args:
        coord_dict: Dictionary with key being a string label and value being tuple
        (x_list, y_list), coordinates for the MDS plot.
        Should be piped in from `compute_MDS`.

        plot_kwarg_dict: Dictionary with keys matching coord_dict and values are kwarg dictionaries
            that will be passed to matplotlib `scatter`.

        legend: boolean for plotting the legend. Defaults to True.

        title: boolean for plotting the title. Defaults to True.

    Returns:
        fig: a matplotlib fig
    """

    # Plot data
    fig, ax = plt.subplots()

    for key, value in coord_dict.items():
        x, y = value
        if plot_kwarg_dict and key in plot_kwarg_dict:
            ax.scatter(x, y, label=key, **plot_kwarg_dict[key])
        else:
            ax.scatter(x, y, label=key)

    if title:
        ax.set_title("MDS Plot for Pairwise Election Distances")
    if legend:
        ax.legend()

    ax.set_aspect("equal")

    return fig

plot_summary_stats(profile, stat, multi_color=True, title='')

Plots histogram of election summary statistics.

Parameters:

Name Type Description Default
profile PreferenceProfile

A PreferenceProfile to visualize.

required
stat str

'first place votes', 'mentions', or 'borda'.

required
multi_color bool

If the bars should be multicolored. Defaults to True.

True
title str

Title for the figure. Defaults to None.

''

Returns:

Type Description
Figure

A figure with the visualization.

Source code in src/votekit/plots/profile_plots.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def plot_summary_stats(
    profile: PreferenceProfile, stat: str, multi_color: bool = True, title: str = ""
) -> Figure:
    """
    Plots histogram of election summary statistics.

    Args:
        profile (PreferenceProfile): A PreferenceProfile to visualize.
        stat (str): 'first place votes', 'mentions', or 'borda'.
        multi_color (bool, optional): If the bars should be multicolored. Defaults to True.
        title (str, optional): Title for the figure. Defaults to None.

    Returns:
        (Figure): A figure with the visualization.
    """
    stats = {
        "first place votes": first_place_votes,
        "mentions": mentions,
        "borda": borda_scores,
    }

    stat_func = stats[stat]
    data: dict = stat_func(profile)  # type: ignore

    if multi_color:
        colors = COLOR_LIST[: len(list(data.keys()))]
    else:
        colors = [COLOR_LIST[-1]]

    fig, ax = plt.subplots()

    candidates = profile.get_candidates(received_votes=False)
    y_data = [data[c] for c in candidates]

    ax.bar(candidates, y_data, color=colors, width=0.35)
    ax.set_xlabel("Candidates")
    ax.set_ylabel("Frequency")

    if title:
        ax.set_title(title)

    return fig

Utils

compute_votes(candidates, ballots)

Computes first place votes for all candidates in a PreferenceProfile.

Parameters:

Name Type Description Default
candidates list

List of all candidates in a PreferenceProfile.

required
ballots list[Ballot]

List of Ballot objects.

required

Returns:

Type Description
tuple[list[CandidateVotes], dict]

A tuple (ordered, votes) where ordered is a list of tuples (cand, first place votes) ordered by decreasing first place votes and votes is a dictionary whose keys are candidates and values are first place votes.

Source code in src/votekit/utils.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def compute_votes(
    candidates: list,
    ballots: list[Ballot],
) -> tuple[list[CandidateVotes], dict]:
    """
    Computes first place votes for all candidates in a PreferenceProfile.

    Args:
        candidates: List of all candidates in a PreferenceProfile.
        ballots: List of Ballot objects.

    Returns:
        A tuple (ordered, votes) where ordered is a list of tuples (cand, first place votes)
            ordered by decreasing first place votes and votes is a dictionary whose keys are
            candidates and values are first place votes.
    """
    votes = {cand: Fraction(0) for cand in candidates}

    for ballot in ballots:
        if not ballot.ranking:
            continue
        first_place_cand = unset(ballot.ranking[0])
        if isinstance(first_place_cand, list):
            for cand in first_place_cand:
                votes[cand] += ballot.weight / len(first_place_cand)
        else:
            votes[first_place_cand] += ballot.weight

    ordered = [
        CandidateVotes(cand=key, votes=value)
        for key, value in sorted(votes.items(), key=lambda x: x[1], reverse=True)
    ]

    return ordered, votes

remove_cand(removed, ballots)

Removes specified candidate(s) from ballots.

Parameters:

Name Type Description Default
removed Union[str, Iterable]

Candidate or set of candidates to be removed.

required
ballots list[Ballot]

List of Ballots to remove candidate(s) from.

required

Returns:

Type Description
list[Ballot]

Updated list of ballots with candidate(s) removed.

Source code in src/votekit/utils.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def remove_cand(removed: Union[str, Iterable], ballots: list[Ballot]) -> list[Ballot]:
    """
    Removes specified candidate(s) from ballots.

    Args:
        removed: Candidate or set of candidates to be removed.
        ballots: List of Ballots to remove candidate(s) from.

    Returns:
        Updated list of ballots with candidate(s) removed.
    """

    if isinstance(removed, str):
        remove_set = {removed}
    elif isinstance(removed, Iterable):
        remove_set = set(removed)

    update = []
    for ballot in ballots:
        new_ranking = []
        if len(remove_set) == 1 and remove_set in ballot.ranking:
            for s in ballot.ranking:
                new_s = s.difference(remove_set)
                if new_s:
                    new_ranking.append(new_s)
            update.append(
                Ballot(
                    id=ballot.id,
                    ranking=tuple(new_ranking),
                    weight=ballot.weight,
                    voter_set=ballot.voter_set,
                )
            )
        elif len(remove_set) > 1:
            for s in ballot.ranking:
                new_s = s.difference(remove_set)
                if new_s:
                    new_ranking.append(new_s)
            update.append(
                Ballot(
                    id=ballot.id,
                    ranking=tuple(new_ranking),
                    weight=ballot.weight,
                    voter_set=ballot.voter_set,
                )
            )
        else:
            update.append(ballot)

    return update

fix_ties(ballot)

Helper function for recursively_fix_ties. Resolves the first appearing tied rank in the input ballot.

Parameters:

Name Type Description Default
ballot Ballot

A Ballot.

required

Returns:

Type Description
list

List of Ballots that are permutations of the tied ballot.

Source code in src/votekit/utils.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
def fix_ties(ballot: Ballot) -> list[Ballot]:
    """
    Helper function for recursively_fix_ties. Resolves the first appearing
    tied rank in the input ballot.

    Args:
        ballot: A Ballot.

    Returns:
        (list): List of Ballots that are permutations of the tied ballot.
    """

    ballots = []
    for idx, rank in enumerate(ballot.ranking):
        if len(rank) > 1:
            for order in permutations(rank):
                resolved = []
                for cand in order:
                    resolved.append(frozenset(cand))
                ballots.append(
                    Ballot(
                        id=ballot.id,
                        ranking=ballot.ranking[:idx]
                        + tuple(resolved)
                        + ballot.ranking[idx + 1 :],
                        weight=ballot.weight / math.factorial(len(rank)),
                        voter_set=ballot.voter_set,
                    )
                )

    return ballots

elect_cands_from_set_ranking(ranking, seats)

Splits a ranking into elected and eliminated based on seats, and if a tie set overlaps the desired number of seats raises a ValueError.

Parameters:

Name Type Description Default
ranking list[set[str]]

A list-of-set ranking of candidates.

required
seats int

Number of seats to fill.

required

Returns:

Type Description
tuple[list[set[str]], list[set[str]]]

A list-of-sets of elected candidates, a list-of-sets of eliminated candidates.

Source code in src/votekit/utils.py
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
def elect_cands_from_set_ranking(
    ranking: list[set[str]], seats: int
) -> tuple[list[set[str]], list[set[str]]]:
    """
    Splits a ranking into elected and eliminated based on seats,
    and if a tie set overlaps the desired number of seats raises a ValueError.

    Args:
        ranking: A list-of-set ranking of candidates.
        seats: Number of seats to fill.

    Returns:
        A list-of-sets of elected candidates, a list-of-sets of eliminated candidates.
    """
    cands_elected = 0
    elected = []
    eliminated = []

    for i, s in enumerate(ranking):
        if cands_elected + len(s) <= seats:
            cands_elected += len(s)
            elected.append(s)
        else:
            eliminated = ranking[i:]
            break

    if cands_elected != seats:
        raise ValueError(
            "Cannot elect correct number of candidates without breaking ties."
        )

    return elected, eliminated

scores_into_set_list(score_dict, candidate_subset=None)

Sorts candidates based on a scoring dictionary (i.e Borda, First-Place).

Parameters:

Name Type Description Default
score_dict dict

Dictionary between candidates (key) and their score (value).

required
candidate_subset Union[list[str], set[str], None]

Relevant candidates to sort.

None

Returns:

Type Description
list[set[str]]

Candidate rankings in a list-of-sets form.

Source code in src/votekit/utils.py
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
def scores_into_set_list(
    score_dict: dict, candidate_subset: Union[list[str], set[str], None] = None
) -> list[set[str]]:
    """
    Sorts candidates based on a scoring dictionary (i.e Borda, First-Place).

    Args:
        score_dict: Dictionary between candidates (key) and their score (value).
        candidate_subset: Relevant candidates to sort.

    Returns:
        Candidate rankings in a list-of-sets form.
    """
    if isinstance(candidate_subset, list):
        candidate_subset = set(candidate_subset)

    tier_dict: dict = {}
    for k, v in score_dict.items():
        if v in tier_dict.keys():
            tier_dict[v].add(k)
        else:
            tier_dict[v] = {k}
    tier_list = [tier_dict[k] for k in sorted(tier_dict.keys(), reverse=True)]
    if candidate_subset is not None:
        tier_list = [
            t & candidate_subset for t in tier_list if len(t & candidate_subset) > 0
        ]
    return tier_list

tie_broken_ranking(ranking, profile, tiebreak='none')

Breaks ties in a list-of-sets ranking according to a given scheme.

Parameters:

Name Type Description Default
ranking list[set[str]]

A list-of-set ranking of candidates.

required
profile PreferenceProfile

PreferenceProfile.

required
tiebreak str

Method of tiebreak, currently supports 'none', 'random', 'borda', 'firstplace'.

'none'

Returns:

Type Description
list[set[str]]

A list-of-set ranking of candidates (tie broken down to one candidate sets unless tiebreak = 'none').

Source code in src/votekit/utils.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
def tie_broken_ranking(
    ranking: list[set[str]], profile: PreferenceProfile, tiebreak: str = "none"
) -> list[set[str]]:
    """
    Breaks ties in a list-of-sets ranking according to a given scheme.

    Args:
        ranking: A list-of-set ranking of candidates.
        profile: PreferenceProfile.
        tiebreak: Method of tiebreak, currently supports 'none', 'random', 'borda', 'firstplace'.

    Returns:
        A list-of-set ranking of candidates (tie broken down to one candidate sets unless
            tiebreak = 'none').
    """

    new_ranking = []
    if tiebreak == "none":
        new_ranking = ranking
    elif tiebreak == "random":
        for s in ranking:
            shuffled_s = list(np.random.permutation(list(s)))
            new_ranking += [{c} for c in shuffled_s]
    elif tiebreak == "firstplace":
        tiebreak_scores = first_place_votes(profile)
        for s in ranking:
            ordered_set = scores_into_set_list(tiebreak_scores, s)
            new_ranking += ordered_set
    elif tiebreak == "borda":
        tiebreak_scores = borda_scores(profile)
        for s in ranking:
            ordered_set = scores_into_set_list(tiebreak_scores, s)
            new_ranking += ordered_set
    else:
        raise ValueError("Invalid tiebreak code was provided")

    if tiebreak != "none" and any(len(s) > 1 for s in new_ranking):
        print("Initial tiebreak was unsuccessful, performing random tiebreak")
        new_ranking = tie_broken_ranking(
            ranking=new_ranking, profile=profile, tiebreak="random"
        )

    return new_ranking

candidate_position_dict(ranking)

Creates a dictionary with the integer ranking of candidates given a set ranking i.e. A > B, C > D returns {A: 1, B: 2, C: 2, D: 4}.

Parameters:

Name Type Description Default
ranking list[set[str]]

A list-of-sets ranking of candidates.

required

Returns:

Type Description
dict

Dictionary of candidates (keys) and integer rankings (values).

Source code in src/votekit/utils.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
def candidate_position_dict(ranking: list[set[str]]) -> dict:
    """
    Creates a dictionary with the integer ranking of candidates given a set ranking
    i.e. A > B, C > D returns {A: 1, B: 2, C: 2, D: 4}.

    Args:
        ranking: A list-of-sets ranking of candidates.

    Returns:
        Dictionary of candidates (keys) and integer rankings (values).
    """
    candidate_positions = {}
    position = 0

    for tie_set in ranking:
        for candidate in tie_set:
            candidate_positions[candidate] = position
        position += len(tie_set)

    return candidate_positions