Real and Simulated Profiles
In the previous section, we discussed the Ballot class. It was very
flexible, allowing for many possible rankings (beyond full linear
rankings) to be stored. By the end of this section, you should be able
to read and clean ballots from real-world voting records, generate
ballots using a variety of models, and understand the candidate simplex.
Real-World Data
We will use the 2013 Minneapolis mayoral election as our first example. This election had 35 candidates running for one seat, and used a single-winner IRV election to choose the winner. Voters were allowed to rank their top three candidates.
Let’s load in the cast vote record (CVR) from the election, which we have stored in the VoteKit GitHub repository. Please download the file and place it in your working directory (the same folder as your code). The file can be found here.
First we load the appropriate modules.
from votekit.cvr_loaders import load_csv
from votekit.elections import IRV
from votekit.cleaning import remove_and_condense
Next we’ll use the load_csv function to load the data. The data
should be a csv file, where each row is a ballot, and there is a column
for every position—i.e., a first-place vote column, a second-place vote
column, and so on.
The load_csv function has some optional parameters; you can specify
which columns of the csv contain ranking data (all of our columns did so
no need to specify), whether there is a weight column, some choice of
end-of-line delimiters (besides the standard, which is a carriage
return), and a voter ID column. It will return a PreferenceProfile
object.
minneapolis_profile = load_csv("mn_2013_cast_vote_record.csv")
print(minneapolis_profile)
Profile contains rankings: True
Maximum ranking length: 3
Profile contains scores: False
Candidates: ('ABDUL M RAHAMAN "THE ROCK"', 'DAN COHEN', 'JAMES EVERETT', 'MARK V ANDERSON', 'TROY BENJEGERDES', 'undervote', 'ALICIA K. BENNETT', 'BETSY HODGES', 'MARK ANDREW', 'MIKE GOULD', 'BILL KAHN', 'BOB FINE', 'CAM WINTON', 'DON SAMUELS', 'JACKIE CHERRYHOMES', 'JEFFREY ALAN WAGNER', 'JOHN LESLIE HARTWIG', 'KURTIS W. HANNA', 'JOSHUA REA', 'MERRILL ANDERSON', 'NEAL BAXTER', 'STEPHANIE WOODRUFF', 'UWI', 'BOB "AGAIN" CARNEY JR', 'TONY LANE', 'CAPTAIN JACK SPARROW', 'GREGG A. IVERSON', 'JAMES "JIMMY" L. STROUD, JR.', 'JAYMIE KELLY', 'CYD GORMAN', 'EDMUND BERNARD BRUYERE', 'DOUG MANN', 'CHRISTOPHER ROBIN ZIMMERMAN', 'RAHN V. WORKCUFF', 'JOHN CHARLES WILSON', 'OLE SAVIOR', 'overvote', 'CHRISTOPHER CLARK')
Candidates who received votes: ('ABDUL M RAHAMAN "THE ROCK"', 'DAN COHEN', 'JAMES EVERETT', 'MARK V ANDERSON', 'TROY BENJEGERDES', 'undervote', 'ALICIA K. BENNETT', 'BETSY HODGES', 'MARK ANDREW', 'MIKE GOULD', 'BILL KAHN', 'BOB FINE', 'CAM WINTON', 'DON SAMUELS', 'JACKIE CHERRYHOMES', 'JEFFREY ALAN WAGNER', 'JOHN LESLIE HARTWIG', 'KURTIS W. HANNA', 'JOSHUA REA', 'MERRILL ANDERSON', 'NEAL BAXTER', 'STEPHANIE WOODRUFF', 'UWI', 'BOB "AGAIN" CARNEY JR', 'TONY LANE', 'CAPTAIN JACK SPARROW', 'GREGG A. IVERSON', 'JAMES "JIMMY" L. STROUD, JR.', 'JAYMIE KELLY', 'CYD GORMAN', 'EDMUND BERNARD BRUYERE', 'DOUG MANN', 'CHRISTOPHER ROBIN ZIMMERMAN', 'RAHN V. WORKCUFF', 'JOHN CHARLES WILSON', 'OLE SAVIOR', 'overvote', 'CHRISTOPHER CLARK')
Total number of Ballot objects: 7084
Total weight of Ballot objects: 80101.0
Note that the load_csv function automatically condenses the profile.
Let’s explore the profile using some of the tools we learned in the previous notebook.
print("The list of candidates is")
for candidate in sorted(minneapolis_profile.candidates):
print(f"\t{candidate}")
print(f"There are {len(minneapolis_profile.candidates)} candidates.")
The list of candidates is
ABDUL M RAHAMAN "THE ROCK"
ALICIA K. BENNETT
BETSY HODGES
BILL KAHN
BOB "AGAIN" CARNEY JR
BOB FINE
CAM WINTON
CAPTAIN JACK SPARROW
CHRISTOPHER CLARK
CHRISTOPHER ROBIN ZIMMERMAN
CYD GORMAN
DAN COHEN
DON SAMUELS
DOUG MANN
EDMUND BERNARD BRUYERE
GREGG A. IVERSON
JACKIE CHERRYHOMES
JAMES "JIMMY" L. STROUD, JR.
JAMES EVERETT
JAYMIE KELLY
JEFFREY ALAN WAGNER
JOHN CHARLES WILSON
JOHN LESLIE HARTWIG
JOSHUA REA
KURTIS W. HANNA
MARK ANDREW
MARK V ANDERSON
MERRILL ANDERSON
MIKE GOULD
NEAL BAXTER
OLE SAVIOR
RAHN V. WORKCUFF
STEPHANIE WOODRUFF
TONY LANE
TROY BENJEGERDES
UWI
overvote
undervote
There are 38 candidates.
Woah, that’s a little funky! There are candidates called ‘undervote’, ‘overvote’, and ‘UWI’. This cast vote record was already cleaned by the City of Minneapolis, and they chose this way of parsing the ballots: ‘undervote’ indicates that the voter left a position unfilled, such as by having no candidate listed in second place. The ‘overvote’ notation arises when a voter puts two candidates in one position, like by putting Hodges and Samuels both in first place. Unfortunately this way of storing the profile means we have lost any knowledge of the voter intent (which was probably to indicate equal preference). ‘UWI’ stands for unregistered write-in.
This reminds us that it is really important to think carefully about how
we want to handle cleaning ballots, as some storage methods are
efficient but lossy. For now, let’s assume that we want to further
condense the ballots, discarding ‘undervote’, ‘overvote’, and ‘UWI’ as
candidates. The function remove_and_condense will do this for us
once we specify which (non)candidates to remove. If a ballot was “A B
undervote”, it will become “A B”. If a ballot was “A UWI B” it will now
be “A B” as well. Many other cleaning options are reasonable.
print("There were", len(minneapolis_profile.candidates), "candidates\n")
clean_profile = remove_and_condense(["undervote", "overvote", "UWI"], minneapolis_profile)
print(clean_profile.candidates)
print("\nThere are now", len(clean_profile.candidates), "candidates")
print(clean_profile)
There were 38 candidates
('JACKIE CHERRYHOMES', 'TROY BENJEGERDES', 'EDMUND BERNARD BRUYERE', 'DON SAMUELS', 'CAM WINTON', 'DOUG MANN', 'STEPHANIE WOODRUFF', 'JOHN CHARLES WILSON', 'DAN COHEN', 'JOSHUA REA', 'TONY LANE', 'BOB "AGAIN" CARNEY JR', 'NEAL BAXTER', 'ALICIA K. BENNETT', 'BETSY HODGES', 'CAPTAIN JACK SPARROW', 'JOHN LESLIE HARTWIG', 'JAYMIE KELLY', 'CHRISTOPHER ROBIN ZIMMERMAN', 'MERRILL ANDERSON', 'JAMES "JIMMY" L. STROUD, JR.', 'BILL KAHN', 'KURTIS W. HANNA', 'RAHN V. WORKCUFF', 'CYD GORMAN', 'JEFFREY ALAN WAGNER', 'GREGG A. IVERSON', 'MARK V ANDERSON', 'MIKE GOULD', 'ABDUL M RAHAMAN "THE ROCK"', 'CHRISTOPHER CLARK', 'OLE SAVIOR', 'JAMES EVERETT', 'MARK ANDREW', 'BOB FINE')
There are now 35 candidates
Profile has been cleaned
Profile contains rankings: True
Maximum ranking length: 3
Profile contains scores: False
Candidates: ('JACKIE CHERRYHOMES', 'TROY BENJEGERDES', 'EDMUND BERNARD BRUYERE', 'DON SAMUELS', 'CAM WINTON', 'DOUG MANN', 'STEPHANIE WOODRUFF', 'JOHN CHARLES WILSON', 'DAN COHEN', 'JOSHUA REA', 'TONY LANE', 'BOB "AGAIN" CARNEY JR', 'NEAL BAXTER', 'ALICIA K. BENNETT', 'BETSY HODGES', 'CAPTAIN JACK SPARROW', 'JOHN LESLIE HARTWIG', 'JAYMIE KELLY', 'CHRISTOPHER ROBIN ZIMMERMAN', 'MERRILL ANDERSON', 'JAMES "JIMMY" L. STROUD, JR.', 'BILL KAHN', 'KURTIS W. HANNA', 'RAHN V. WORKCUFF', 'CYD GORMAN', 'JEFFREY ALAN WAGNER', 'GREGG A. IVERSON', 'MARK V ANDERSON', 'MIKE GOULD', 'ABDUL M RAHAMAN "THE ROCK"', 'CHRISTOPHER CLARK', 'OLE SAVIOR', 'JAMES EVERETT', 'MARK ANDREW', 'BOB FINE')
Candidates who received votes: ('ABDUL M RAHAMAN "THE ROCK"', 'DAN COHEN', 'JAMES EVERETT', 'MARK V ANDERSON', 'TROY BENJEGERDES', 'ALICIA K. BENNETT', 'BETSY HODGES', 'MARK ANDREW', 'MIKE GOULD', 'BILL KAHN', 'BOB FINE', 'CAM WINTON', 'DON SAMUELS', 'JACKIE CHERRYHOMES', 'JEFFREY ALAN WAGNER', 'JOHN LESLIE HARTWIG', 'KURTIS W. HANNA', 'JOSHUA REA', 'MERRILL ANDERSON', 'NEAL BAXTER', 'STEPHANIE WOODRUFF', 'BOB "AGAIN" CARNEY JR', 'TONY LANE', 'CAPTAIN JACK SPARROW', 'GREGG A. IVERSON', 'JAMES "JIMMY" L. STROUD, JR.', 'JAYMIE KELLY', 'CYD GORMAN', 'EDMUND BERNARD BRUYERE', 'DOUG MANN', 'CHRISTOPHER ROBIN ZIMMERMAN', 'RAHN V. WORKCUFF', 'JOHN CHARLES WILSON', 'OLE SAVIOR', 'CHRISTOPHER CLARK')
Total number of Ballot objects: 7073
Total weight of Ballot objects: 79378.0
Things look a bit cleaner; all three of the non-candidate strings have been removed. Note that the order of candidates is not very meaningful; it’s just the order in which the names occurred in the input data. When listing by weight, note how the top ballot changed from (Mark Andrew, undervote, undervote) to just a bullet vote for Mark Andrew, which occurred on almost 5 percent of ballots! Briefly, let’s run the same kind of election type that was conducted in 2013 to verify we get the same outcome as the city announced. The city used IRV elections (which are equivalent to STV for one seat). Let’s check it out.
# an IRV election for one seat
minn_election = IRV(profile=clean_profile)
print(minn_election)
Status Round
BETSY HODGES Elected 35
MARK ANDREW Eliminated 34
DON SAMUELS Eliminated 33
CAM WINTON Eliminated 32
JACKIE CHERRYHOMES Eliminated 31
BOB FINE Eliminated 30
DAN COHEN Eliminated 29
STEPHANIE WOODRUFF Eliminated 28
MARK V ANDERSON Eliminated 27
DOUG MANN Eliminated 26
OLE SAVIOR Eliminated 25
JAMES EVERETT Eliminated 24
ALICIA K. BENNETT Eliminated 23
ABDUL M RAHAMAN "THE ROCK" Eliminated 22
CAPTAIN JACK SPARROW Eliminated 21
CHRISTOPHER CLARK Eliminated 20
TONY LANE Eliminated 19
JAYMIE KELLY Eliminated 18
MIKE GOULD Eliminated 17
KURTIS W. HANNA Eliminated 16
CHRISTOPHER ROBIN ZIMMERMAN Eliminated 15
JEFFREY ALAN WAGNER Eliminated 14
NEAL BAXTER Eliminated 13
TROY BENJEGERDES Eliminated 12
GREGG A. IVERSON Eliminated 11
MERRILL ANDERSON Eliminated 10
JOSHUA REA Eliminated 9
BILL KAHN Eliminated 8
JOHN LESLIE HARTWIG Eliminated 7
EDMUND BERNARD BRUYERE Eliminated 6
JAMES "JIMMY" L. STROUD, JR. Eliminated 5
RAHN V. WORKCUFF Eliminated 4
BOB "AGAIN" CARNEY JR Eliminated 3
CYD GORMAN Eliminated 2
JOHN CHARLES WILSON Eliminated 1
/Users/cdonnay/Documents/GitHub/MGGG/VoteKit/src/votekit/pref_profile/pref_profile.py:1109: UserWarning: Profile does not contain rankings but max_ranking_length=3. Setting max_ranking_length to 0.
warnings.warn(
If you’re so moved, take a moment to go verify that we got the same order of elimination and the same winning candidate as in the official election.
Well that was simple! One takeaway: cleaning your data is a crucial step, and how you clean your data depends on your own context. This is why VoteKit provides helper functions to clean ballots, but it does not automatically apply them.
Simulated voting with ballot generators
If we want to get a large sample of ballots without using real-world data, we can use a variety of ballot generators included in VoteKit.
Bradley-Terry
The slate-Bradley-Terry model (s-BT) uses the same set of input
parameters as s-PL: slate_to_candidates, bloc_voter_prop,
cohesion_parameters, and pref_intervals_by_bloc. We call s-BT
the deliberative voter model because part of the generation process
involves making all pairwise comparisons between candidates on the
ballot. A more detailed discussion can be found in our social choice
documentation.
import votekit.ballot_generator as bg
from votekit import PreferenceInterval
slate_to_candidates = {"Alpha": ["A", "B"], "Xenon": ["X", "Y"]}
# note that we include candidates with 0 support, and that our preference intervals
# will automatically rescale to sum to 1
pref_intervals_by_bloc = {
"Alpha": {
"Alpha": PreferenceInterval({"A": 0.8, "B": 0.15}),
"Xenon": PreferenceInterval({"X": 0, "Y": 0.05}),
},
"Xenon": {
"Alpha": PreferenceInterval({"A": 0.05, "B": 0.05}),
"Xenon": PreferenceInterval({"X": 0.45, "Y": 0.45}),
},
}
bloc_voter_prop = {"Alpha": 0.8, "Xenon": 0.2}
# assume that each bloc is 90% cohesive
cohesion_parameters = {
"Alpha": {"Alpha": 0.9, "Xenon": 0.1},
"Xenon": {"Xenon": 0.9, "Alpha": 0.1},
}
bt = bg.slate_BradleyTerry(
pref_intervals_by_bloc=pref_intervals_by_bloc,
bloc_voter_prop=bloc_voter_prop,
slate_to_candidates=slate_to_candidates,
cohesion_parameters=cohesion_parameters,
)
profile = bt.generate_profile(number_of_ballots=100)
print(profile.df)
Ranking_1 Ranking_2 Ranking_3 Ranking_4 Voter Set Weight
Ballot Index
0 (A) (B) (Y) (X) {} 69.0
1 (A) (Y) (B) (X) {} 4.0
2 (B) (A) (Y) (X) {} 7.0
3 (Y) (X) (A) (B) {} 7.0
4 (Y) (X) (B) (A) {} 4.0
5 (X) (Y) (A) (B) {} 5.0
6 (X) (Y) (B) (A) {} 4.0
that s-BT samples from is too cumbersome to compute for more than 12
candidates. We have implemented a Markov chain Monte Carlo (MCMC)
sampling method to account for this. Simply set
deterministic = False in the generate_profile method to use the
MCMC code. The sample size should be increased to ensure mixing of the
chain.
mcmc_profile = bt.generate_profile(number_of_ballots=10000, deterministic=False)
print(profile.df)
Ranking_1 Ranking_2 Ranking_3 Ranking_4 Voter Set Weight
Ballot Index
0 (A) (B) (Y) (X) {} 69.0
1 (A) (Y) (B) (X) {} 4.0
2 (B) (A) (Y) (X) {} 7.0
3 (Y) (X) (A) (B) {} 7.0
4 (Y) (X) (B) (A) {} 4.0
5 (X) (Y) (A) (B) {} 5.0
6 (X) (Y) (B) (A) {} 4.0
Generating Preference Intervals from Hyperparameters
Now that we have seen a few ballot generators, we can introduce the candidate simplex and the Dirichlet distribution.
We saw that you can initialize the Plackett-Luce model and the Bradley-Terry model from a preference interval (or multiple ones if you have different voting blocs). Recall, a preference interval stores a voter’s preference for candidates as a vector of non-negative values that sum to 1. Other models that rely on preference intervals include the Alternating Crossover model (AC) and the Cambridge Sampler (CS). There is a nice geometric representation of preference intervals via the candidate simplex.
Candidate Simplex
Informally, the candidate simplex is a geometric representation of the space of preference intervals. With two candidates, it is an interval; with three candidates, it is a triangle; with four, a tetrahedron; and so on getting harder to visualize as the dimension goes up.
This will be easiest to visualize with three candidates \(A,B,C\). Then there is a one-to-one correspondence between positions in the triangle and what are called convex combinations of the extreme points. For instance, \(.8A+.15B+.05C\) is a weighted average of those points giving 80% of the weight to \(A\), 15% to \(B\), and 5% to \(C\). The result is a point that is closest to \(A\), as seen in the picture.
Those coefficients, which sum to 1, become the lengths of the candidate’s sub-intervals. So this lets us see the simplex as the space of all preference intervals.
Dirichlet Distribution
Dirichlet distributions are a one-parameter family of probability distributions on the simplex—this is used here to choose a preference interval at random. We parameterize it with a value \(\alpha \in (0,\infty)\). As \(\alpha\to \infty\), the support of the distribution moves to the center of the simplex. This means we are more likely to sample preference intervals that have roughly equal support for all candidates, which will translate to all orderings being equally likely. As \(\alpha\to 0\), the mass moves to the vertices. This means we are more likely to choose a preference interval that has strong support for a single candidate. In between is \(\alpha=1\), where any region of the simplex is weighted in proportion to its area. We think of this as the “all bets are off” setting – you might choose a balanced preference, a concentrated preference, or something in between.
The value \(\alpha\) is never allowed to equal 0 or \(\infty\) in Python, so VoteKit changes these to a very small number (\(10^{-10}\)) and a very large number \((10^{20})\). We don’t recommend using values that extreme. In previous studies, MGGG members have taken \(\alpha = 1/2\) to be “small” and \(\alpha = 2\) to be “big.”
It is easy to sample a PreferenceInterval from the Dirichlet
distribution. Rerun the code below several times to get a feel for how
these change with randomness.
strong_pref_interval = PreferenceInterval.from_dirichlet(
candidates=["A", "B", "C"], alpha=0.1
)
print("Strong preference for one candidate", strong_pref_interval)
abo_pref_interval = PreferenceInterval.from_dirichlet(
candidates=["A", "B", "C"], alpha=1
)
print("All bets are off preference", abo_pref_interval)
unif_pref_interval = PreferenceInterval.from_dirichlet(
candidates=["A", "B", "C"], alpha=10
)
print("Uniform preference for all candidates", unif_pref_interval)
Strong preference for one candidate {'A': 0.9997, 'B': 0.0003, 'C': 0.0}
All bets are off preference {'A': 0.4123, 'B': 0.008, 'C': 0.5797}
Uniform preference for all candidates {'A': 0.3021, 'B': 0.3939, 'C': 0.3039}
Let’s initialize the s-PL model from the Dirichlet distribution, using that to build a preference interval rather than specifying the interval. Each bloc will need two Dirichlet alpha values; one to describe their own preference interval, and another to describe their preference for the opposing candidates.
bloc_voter_prop = {"X": 0.8, "Y": 0.2}
# the values of .9 indicate that these blocs are highly polarized;
# they prefer their own candidates much more than the opposing slate
cohesion_parameters = {"X": {"X": 0.9, "Y": 0.1}, "Y": {"Y": 0.9, "X": 0.1}}
alphas = {"X": {"X": 2, "Y": 1}, "Y": {"X": 1, "Y": 0.5}}
slate_to_candidates = {"X": ["X1", "X2"], "Y": ["Y1", "Y2"]}
# the from_params method allows us to sample from
# the Dirichlet distribution for our intervals
pl = bg.slate_PlackettLuce.from_params(
slate_to_candidates=slate_to_candidates,
bloc_voter_prop=bloc_voter_prop,
cohesion_parameters=cohesion_parameters,
alphas=alphas,
)
print("Preference interval for X bloc and X candidates")
print(pl.pref_intervals_by_bloc["X"]["X"])
print()
print("Preference interval for X bloc and Y candidates")
print(pl.pref_intervals_by_bloc["X"]["Y"])
print()
profile_dict, agg_profile = pl.generate_profile(number_of_ballots=100, by_bloc=True)
print(profile_dict["X"].df)
Preference interval for X bloc and X candidates
{'X1': 0.4421, 'X2': 0.5579}
Preference interval for X bloc and Y candidates
{'Y1': 0.4563, 'Y2': 0.5437}
Ranking_1 Ranking_2 Ranking_3 Ranking_4 Weight Voter Set
Ballot Index
0 (X2) (X1) (Y2) (Y1) 20.0 {}
1 (X2) (X1) (Y1) (Y2) 14.0 {}
2 (X2) (Y2) (X1) (Y1) 1.0 {}
3 (X2) (Y1) (X1) (Y2) 2.0 {}
4 (X1) (X2) (Y2) (Y1) 22.0 {}
5 (X1) (X2) (Y1) (Y2) 10.0 {}
6 (X1) (Y2) (X2) (Y1) 2.0 {}
7 (X1) (Y1) (Y2) (X2) 1.0 {}
8 (X1) (Y1) (X2) (Y2) 2.0 {}
9 (Y1) (X2) (X1) (Y2) 1.0 {}
10 (Y2) (X1) (Y1) (X2) 1.0 {}
11 (Y2) (X1) (X2) (Y1) 1.0 {}
12 (Y2) (X2) (X1) (Y1) 2.0 {}
13 (Y2) (Y1) (X2) (X1) 1.0 {}
Let’s confirm that the intervals and ballots look reasonable. We have \(\alpha_{XX} = 2\) and \(\alpha_{XY} = 1\). This means that the \(X\) voters tend to be relatively indifferent among their own candidates, but might adopt any candidate strength behavior for the \(Y\) slate.
Try it yourself
Change the code above to check that the preference intervals and ballots for the \(Y\) bloc look reasonable.
Cambridge Sampler
We introduce one more method of generating ballots: the Cambridge Sampler (CS). CS generates ranked ballots using historical election data from Cambridge, MA (which has been continuously conducting ranked choice elections since 1941). It is the only ballot generator we will see today that is capable of producing incomplete ballots, including bullet votes.
By default, CS uses five elections (2009-2017, odd years); with the help of local organizers, we coded the candidates as White (W) or People of Color (POC, or C for short). This is not necessarily the biggest factor predicting people’s vote in Cambridge – housing policy is the biggie – but it’s a good place to find realistic rankings, with candidates of two types.
You also have the option of providing CS with your own historical election data from which to generate ballots instead of using Cambridge data.
bloc_voter_prop = {"W": 0.8, "C": 0.2}
# the values of .9 indicate that these blocs are highly polarized;
# they prefer their own candidates much more than the opposing slate
cohesion_parameters = {"W": {"W": 0.9, "C": 0.1}, "C": {"C": 0.9, "W": 0.1}}
alphas = {"W": {"W": 2, "C": 1}, "C": {"W": 1, "C": 0.5}}
slate_to_candidates = {"W": ["W1", "W2", "W3"], "C": ["C1", "C2"]}
cs = bg.CambridgeSampler.from_params(
slate_to_candidates=slate_to_candidates,
bloc_voter_prop=bloc_voter_prop,
cohesion_parameters=cohesion_parameters,
alphas=alphas,
)
profile = cs.generate_profile(number_of_ballots=1000)
print(profile.df.head(10).to_string())
Ranking_1 Ranking_2 Ranking_3 Ranking_4 Ranking_5 Voter Set Weight
Ballot Index
0 (W2) (C2) (W3) (W1) (C1) {} 3.0
1 (W2) (C2) (W3) (W1) (~) {} 4.0
2 (W2) (C2) (W3) (~) (~) {} 4.0
3 (W2) (C2) (W3) (C1) (W1) {} 2.0
4 (W2) (C2) (C1) (W1) (~) {} 8.0
5 (W2) (C2) (C1) (W1) (W3) {} 12.0
6 (W2) (C2) (C1) (~) (~) {} 3.0
7 (W2) (C2) (C1) (W3) (~) {} 2.0
8 (W2) (C2) (C1) (W3) (W1) {} 3.0
9 (W2) (C2) (~) (~) (~) {} 7.0
Note: the ballot type (as in, Ws and Cs) is strictly drawn from the historical frequencies. The candidate IDs (as in W1 and W2 among the W slate) are filled in by sampling without replacement from the preference interval that you either provided or made from Dirichlet alphas. That is the only role of the preference interval.
Conclusion
There are many other models of ballot generation in VoteKit, both for ranked choice ballots and score based ballots (think cumulative or approval voting). See the ballot generator section of the VoteKit documentation for more.