Spaces:
Sleeping
Sleeping
Update src/app.py
Browse files- src/app.py +224 -28
src/app.py
CHANGED
|
@@ -3,11 +3,18 @@ import pandas as pd
|
|
| 3 |
import matplotlib.pyplot as plt
|
| 4 |
import seaborn as sns
|
| 5 |
import math
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
# Initialize session state
|
| 8 |
if "voters" not in st.session_state:
|
| 9 |
st.session_state.voters = []
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
st.title("π― Strategy-Proof Ranked Voting System")
|
| 12 |
|
| 13 |
# 1. Alternatives and scoring vector
|
|
@@ -47,25 +54,35 @@ if st.session_state.voters:
|
|
| 47 |
st.dataframe(df_votes)
|
| 48 |
|
| 49 |
# --- Function to detect manipulation ---
|
|
|
|
|
|
|
| 50 |
def detect_manipulation(voters, alternatives, scoring_vector):
|
| 51 |
total_profiles = math.factorial(len(alternatives)) ** len(voters)
|
| 52 |
manipulable_cases = 0
|
| 53 |
manipulable_info = []
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
original_scores = {alt: 0 for alt in alternatives}
|
| 56 |
for v in voters:
|
| 57 |
for i, alt in enumerate(v["ranking"]):
|
| 58 |
original_scores[alt] += scoring_vector[i]
|
|
|
|
| 59 |
original_winner = max(original_scores, key=original_scores.get)
|
| 60 |
|
| 61 |
for voter in voters:
|
| 62 |
original_ranking = voter["ranking"]
|
|
|
|
|
|
|
| 63 |
if original_ranking[0] == original_winner:
|
| 64 |
-
continue
|
| 65 |
|
| 66 |
for alt in alternatives:
|
| 67 |
if alt == original_ranking[0]:
|
| 68 |
-
continue
|
| 69 |
|
| 70 |
new_ranking = original_ranking.copy()
|
| 71 |
if alt in new_ranking:
|
|
@@ -78,17 +95,117 @@ def detect_manipulation(voters, alternatives, scoring_vector):
|
|
| 78 |
for i, a in enumerate(rank_list):
|
| 79 |
simulated_scores[a] += scoring_vector[i]
|
| 80 |
|
|
|
|
| 81 |
new_winner = max(simulated_scores, key=simulated_scores.get)
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
manipulable_cases += 1
|
| 84 |
manipulable_info.append({
|
| 85 |
"voter": voter["name"],
|
| 86 |
-
"
|
| 87 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
})
|
| 89 |
break
|
| 90 |
|
| 91 |
-
return manipulable_cases,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
# 4. Compute results
|
| 94 |
if st.button("π Compute Result") and st.session_state.voters:
|
|
@@ -143,38 +260,117 @@ if st.button("π Compute Result") and st.session_state.voters:
|
|
| 143 |
else:
|
| 144 |
st.success(f"π
Winner: {top_alts[0]}")
|
| 145 |
|
|
|
|
| 146 |
# 5. Manipulation Detection
|
| 147 |
st.subheader("π΅οΈ Manipulation Detection")
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
if m_profiles:
|
| 152 |
for item in m_profiles:
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
manipulated = pd.DataFrame(
|
| 162 |
-
[scoring_vector[:len(item['manipulated'])]],
|
| 163 |
-
columns=item['manipulated'],
|
| 164 |
-
index=["Manipulated"]
|
| 165 |
-
)
|
| 166 |
-
|
| 167 |
-
combined = pd.concat([original, manipulated])
|
| 168 |
-
|
| 169 |
-
st.markdown("##### π Original vs Manipulated Preference (Score View)")
|
| 170 |
-
fig, ax = plt.subplots(figsize=(8, 2))
|
| 171 |
-
sns.heatmap(combined, annot=True, cmap="coolwarm", cbar=False, ax=ax)
|
| 172 |
-
st.pyplot(fig)
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
else:
|
| 175 |
st.success("β
No manipulable profiles detected.")
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
# Reset
|
| 178 |
if st.button("π Reset Voters"):
|
| 179 |
st.session_state.voters = []
|
|
|
|
| 180 |
st.rerun()
|
|
|
|
| 3 |
import matplotlib.pyplot as plt
|
| 4 |
import seaborn as sns
|
| 5 |
import math
|
| 6 |
+
from copy import deepcopy
|
| 7 |
+
from itertools import permutations, product
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
|
| 10 |
|
| 11 |
# Initialize session state
|
| 12 |
if "voters" not in st.session_state:
|
| 13 |
st.session_state.voters = []
|
| 14 |
|
| 15 |
+
if "scoring_history" not in st.session_state:
|
| 16 |
+
st.session_state.scoring_history = [] # List of tuples: (label, vector, manipulability %)
|
| 17 |
+
|
| 18 |
st.title("π― Strategy-Proof Ranked Voting System")
|
| 19 |
|
| 20 |
# 1. Alternatives and scoring vector
|
|
|
|
| 54 |
st.dataframe(df_votes)
|
| 55 |
|
| 56 |
# --- Function to detect manipulation ---
|
| 57 |
+
import math
|
| 58 |
+
|
| 59 |
def detect_manipulation(voters, alternatives, scoring_vector):
|
| 60 |
total_profiles = math.factorial(len(alternatives)) ** len(voters)
|
| 61 |
manipulable_cases = 0
|
| 62 |
manipulable_info = []
|
| 63 |
|
| 64 |
+
def get_rank(score_dict):
|
| 65 |
+
sorted_alts = sorted(score_dict.items(), key=lambda x: -x[1])
|
| 66 |
+
return {alt: rank + 1 for rank, (alt, _) in enumerate(sorted_alts)}
|
| 67 |
+
|
| 68 |
+
# Original overall ranking
|
| 69 |
original_scores = {alt: 0 for alt in alternatives}
|
| 70 |
for v in voters:
|
| 71 |
for i, alt in enumerate(v["ranking"]):
|
| 72 |
original_scores[alt] += scoring_vector[i]
|
| 73 |
+
original_ranks = get_rank(original_scores)
|
| 74 |
original_winner = max(original_scores, key=original_scores.get)
|
| 75 |
|
| 76 |
for voter in voters:
|
| 77 |
original_ranking = voter["ranking"]
|
| 78 |
+
|
| 79 |
+
# Skip if voter's top choice is already the winner
|
| 80 |
if original_ranking[0] == original_winner:
|
| 81 |
+
continue
|
| 82 |
|
| 83 |
for alt in alternatives:
|
| 84 |
if alt == original_ranking[0]:
|
| 85 |
+
continue # skip if same as current top
|
| 86 |
|
| 87 |
new_ranking = original_ranking.copy()
|
| 88 |
if alt in new_ranking:
|
|
|
|
| 95 |
for i, a in enumerate(rank_list):
|
| 96 |
simulated_scores[a] += scoring_vector[i]
|
| 97 |
|
| 98 |
+
simulated_ranks = get_rank(simulated_scores)
|
| 99 |
new_winner = max(simulated_scores, key=simulated_scores.get)
|
| 100 |
+
|
| 101 |
+
# Manipulation is beneficial ONLY IF new winner is ranked *higher* than original winner
|
| 102 |
+
if original_ranking.index(new_winner) < original_ranking.index(original_winner):
|
| 103 |
+
rank_improvements = []
|
| 104 |
+
for a in alternatives:
|
| 105 |
+
old_rank = original_ranks[a]
|
| 106 |
+
new_rank = simulated_ranks[a]
|
| 107 |
+
if new_rank < old_rank:
|
| 108 |
+
rank_improvements.append(f"{a}: {old_rank} β {new_rank}")
|
| 109 |
+
|
| 110 |
manipulable_cases += 1
|
| 111 |
manipulable_info.append({
|
| 112 |
"voter": voter["name"],
|
| 113 |
+
"original_ranking": original_ranking,
|
| 114 |
+
"manipulated_ranking": new_ranking,
|
| 115 |
+
"original_winner": original_winner,
|
| 116 |
+
"new_winner": new_winner,
|
| 117 |
+
"rank_improvements": rank_improvements,
|
| 118 |
+
"simulated_ranks": simulated_ranks # β
Add this for your DataFrame logic
|
| 119 |
})
|
| 120 |
break
|
| 121 |
|
| 122 |
+
return manipulable_cases, len(voters), manipulable_info
|
| 123 |
+
|
| 124 |
+
def get_winners(scores):
|
| 125 |
+
max_score = max(scores.values())
|
| 126 |
+
return [alt for alt, score in scores.items() if score == max_score]
|
| 127 |
+
|
| 128 |
+
# --- Function to count manipulable profile ---
|
| 129 |
+
from itertools import permutations, product
|
| 130 |
+
from collections import defaultdict
|
| 131 |
+
|
| 132 |
+
def count_manipulable_profiles(m, n, scoring_vector):
|
| 133 |
+
alternatives = [f"M{i+1}" for i in range(m)]
|
| 134 |
+
all_rankings = list(permutations(alternatives))
|
| 135 |
+
total_profiles = len(all_rankings) ** n
|
| 136 |
+
manipulable_count = 0
|
| 137 |
+
|
| 138 |
+
def get_rank(scores):
|
| 139 |
+
# Returns a ranking dictionary based on scores (highest score first)
|
| 140 |
+
sorted_items = sorted(scores.items(), key=lambda x: -x[1])
|
| 141 |
+
return {alt: i + 1 for i, (alt, _) in enumerate(sorted_items)}
|
| 142 |
+
|
| 143 |
+
# Iterate over all possible profiles
|
| 144 |
+
for profile in product(all_rankings, repeat=n):
|
| 145 |
+
# Calculate the original scores for this profile
|
| 146 |
+
original_scores = defaultdict(int)
|
| 147 |
+
for ranking in profile:
|
| 148 |
+
for i, alt in enumerate(ranking):
|
| 149 |
+
original_scores[alt] += scoring_vector[i]
|
| 150 |
+
|
| 151 |
+
# Identify the original winner (the alternative with the highest score)
|
| 152 |
+
original_winner = max(original_scores, key=original_scores.get)
|
| 153 |
+
|
| 154 |
+
# Calculate the original rank order
|
| 155 |
+
original_ranks = get_rank(original_scores)
|
| 156 |
+
|
| 157 |
+
# Check if the profile can be manipulated by at least one voter
|
| 158 |
+
profile_manipulated = False
|
| 159 |
+
|
| 160 |
+
for voter_idx, ranking in enumerate(profile):
|
| 161 |
+
# If the original winner is already the first choice for this voter, no manipulation is possible
|
| 162 |
+
if ranking[0] == original_winner:
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
# Find the alternatives this voter prefers over the original winner
|
| 166 |
+
preferred_alts = []
|
| 167 |
+
for alt in ranking:
|
| 168 |
+
if alt == original_winner:
|
| 169 |
+
break
|
| 170 |
+
preferred_alts.append(alt)
|
| 171 |
+
|
| 172 |
+
# Try manipulating the vote by promoting each preferred alternative
|
| 173 |
+
for alt in preferred_alts:
|
| 174 |
+
# Create a manipulated vote where 'alt' is ranked first
|
| 175 |
+
manipulated = list(ranking)
|
| 176 |
+
manipulated.remove(alt)
|
| 177 |
+
manipulated.insert(0, alt)
|
| 178 |
+
|
| 179 |
+
# Recalculate the scores with the manipulated vote
|
| 180 |
+
new_scores = original_scores.copy()
|
| 181 |
+
# Subtract the original vote's scores
|
| 182 |
+
for pos, a in enumerate(ranking):
|
| 183 |
+
new_scores[a] -= scoring_vector[pos]
|
| 184 |
+
# Add the manipulated vote's scores
|
| 185 |
+
for pos, a in enumerate(manipulated):
|
| 186 |
+
new_scores[a] += scoring_vector[pos]
|
| 187 |
+
|
| 188 |
+
# Determine the new winner after manipulation
|
| 189 |
+
new_winner = max(new_scores, key=new_scores.get)
|
| 190 |
+
|
| 191 |
+
# Check if the new winner is one of the preferred alternatives and is better than the original winner
|
| 192 |
+
if new_winner in preferred_alts and new_scores[new_winner] > new_scores[original_winner]:
|
| 193 |
+
manipulable_count += 1
|
| 194 |
+
profile_manipulated = True
|
| 195 |
+
break
|
| 196 |
+
|
| 197 |
+
if profile_manipulated:
|
| 198 |
+
break
|
| 199 |
+
|
| 200 |
+
# Count the profile if at least one voter can manipulate it
|
| 201 |
+
if profile_manipulated:
|
| 202 |
+
manipulable_count += 1
|
| 203 |
+
|
| 204 |
+
# Return manipulable count, total profiles, and manipulability percentage
|
| 205 |
+
manipulability = (manipulable_count / total_profiles) * 100
|
| 206 |
+
return manipulable_count, total_profiles, manipulability
|
| 207 |
+
|
| 208 |
+
|
| 209 |
|
| 210 |
# 4. Compute results
|
| 211 |
if st.button("π Compute Result") and st.session_state.voters:
|
|
|
|
| 260 |
else:
|
| 261 |
st.success(f"π
Winner: {top_alts[0]}")
|
| 262 |
|
| 263 |
+
|
| 264 |
# 5. Manipulation Detection
|
| 265 |
st.subheader("π΅οΈ Manipulation Detection")
|
| 266 |
+
|
| 267 |
+
m_cases, t_cases, m_profiles = detect_manipulation(
|
| 268 |
+
st.session_state.voters, alternatives, scoring_vector
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
st.markdown(f"π **Voters who can potentially improve their outcome:** `{m_cases} / {t_cases}`")
|
| 272 |
|
| 273 |
if m_profiles:
|
| 274 |
for item in m_profiles:
|
| 275 |
+
# Corrected rank improvements with accurate old and new positions
|
| 276 |
+
rank_changes = []
|
| 277 |
+
original_ranking = item['original_ranking']
|
| 278 |
+
manipulated_ranking = item['manipulated_ranking']
|
| 279 |
+
|
| 280 |
+
for imp in item['rank_improvements']:
|
| 281 |
+
alt_name, _ = imp.split(':') # Only keep the alternative name, ignore the rank change part
|
| 282 |
+
alt_name = alt_name.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
+
# Get original and new rank positions (1-based)
|
| 285 |
+
old = original_ranking.index(alt_name) + 1
|
| 286 |
+
new = manipulated_ranking.index(alt_name) + 1
|
| 287 |
+
benefit = old - new
|
| 288 |
+
|
| 289 |
+
if benefit > 0:
|
| 290 |
+
rank_changes.append(f"β
{alt_name}: Rank {old} β {new} (β {benefit})")
|
| 291 |
+
elif benefit < 0:
|
| 292 |
+
rank_changes.append(f"β οΈ {alt_name}: Rank {old} β {new} (β {abs(benefit)})")
|
| 293 |
+
else:
|
| 294 |
+
rank_changes.append(f"β {alt_name}: Rank {old} β {new} (no change)")
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# Simulated overall rank table
|
| 298 |
+
sim_ranks_df = pd.DataFrame.from_dict(item["simulated_ranks"], orient="index", columns=["Rank"])
|
| 299 |
+
sim_ranks_df.index.name = "Alternative"
|
| 300 |
+
sim_ranks_df = sim_ranks_df.sort_values("Rank")
|
| 301 |
+
|
| 302 |
+
# Display voter manipulation info
|
| 303 |
+
st.markdown(f"""
|
| 304 |
+
<div style='margin-bottom:15px; border-left: 3px solid #444; padding-left: 10px;'>
|
| 305 |
+
π <b>{item['voter']}</b> can manipulate:
|
| 306 |
+
<ul>
|
| 307 |
+
<li><b>Original Ranking:</b> {', '.join(original_ranking)}</li>
|
| 308 |
+
<li><b>Manipulated Ranking:</b> {', '.join(manipulated_ranking)}</li>
|
| 309 |
+
<li><b>Original Winner:</b> {item['original_winner']}</li>
|
| 310 |
+
<li><b>New Winner after manipulation:</b> {item['new_winner']}</li>
|
| 311 |
+
<li><b>Rank Improvements:</b>
|
| 312 |
+
<ul>
|
| 313 |
+
{''.join(f"<li>{rc}</li>" for rc in rank_changes)}
|
| 314 |
+
</ul>
|
| 315 |
+
</li>
|
| 316 |
+
</ul>
|
| 317 |
+
</div>
|
| 318 |
+
""", unsafe_allow_html=True)
|
| 319 |
+
|
| 320 |
+
st.markdown("**π Simulated Ranks after Manipulation:**")
|
| 321 |
+
st.dataframe(sim_ranks_df)
|
| 322 |
else:
|
| 323 |
st.success("β
No manipulable profiles detected.")
|
| 324 |
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# 6. Global Manipulability (All Possible Profiles)
|
| 328 |
+
st.subheader("π Global Profile Manipulability")
|
| 329 |
+
m = len(alternatives)
|
| 330 |
+
n = len(st.session_state.voters)
|
| 331 |
+
|
| 332 |
+
with st.spinner("Computing over all possible profiles..."):
|
| 333 |
+
manipulable, total,manipulability_perc = count_manipulable_profiles(m, n, scoring_vector)
|
| 334 |
+
st.success(f"Out of {total} total profiles, at least one voter can manipulate in {manipulable} profiles.")
|
| 335 |
+
st.metric("Manipulable Profiles", f"{manipulable} / {total}", delta=f"{round((manipulable/total)*100, 2)}%")
|
| 336 |
+
|
| 337 |
+
current_label = f"Custom {scoring_vector}"
|
| 338 |
+
# After detect_manipulation()
|
| 339 |
+
#m_cases, t_cases, m_profiles = detect_manipulation(st.session_state.voters, alternatives, scoring_vector)
|
| 340 |
+
|
| 341 |
+
# Accurate manipulability % for the current scoring vector
|
| 342 |
+
global_m, global_t, manipulability = count_manipulable_profiles(len(alternatives), len(st.session_state.voters), scoring_vector)
|
| 343 |
+
#manipulability = (global_m / global_t) * 100
|
| 344 |
+
|
| 345 |
+
# Avoid duplicates
|
| 346 |
+
if not any(vector == scoring_vector for _, vector, _, _, _ in st.session_state.scoring_history):
|
| 347 |
+
st.session_state.scoring_history.append((f"Custom {scoring_vector}", scoring_vector.copy(), manipulability, len(alternatives), len(st.session_state.voters)))
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# 6. Accumulated Scoring Vector Comparison
|
| 351 |
+
if st.session_state.scoring_history:
|
| 352 |
+
df_hist = pd.DataFrame(
|
| 353 |
+
[(label, m, n, percent) for label, _, percent, m, n in st.session_state.scoring_history],
|
| 354 |
+
columns=["Scoring Vector", "Alternatives", "Voters", "Manipulable %"]
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
st.subheader("π Manipulability of All Used Scoring Vectors")
|
| 358 |
+
fig_hist, ax_hist = plt.subplots(figsize=(10, 6))
|
| 359 |
+
sns.barplot(data=df_hist, x="Manipulable %", y="Scoring Vector", palette="Spectral", ax=ax_hist)
|
| 360 |
+
ax_hist.set_xlim(0, 100)
|
| 361 |
+
ax_hist.set_xlabel("Percentage of Manipulable Profiles")
|
| 362 |
+
ax_hist.set_title("Manipulability by Scoring Vector")
|
| 363 |
+
|
| 364 |
+
# Annotate each bar with m, n and manipulability %
|
| 365 |
+
for i, row in df_hist.iterrows():
|
| 366 |
+
label = f"m={row['Alternatives']}, n={row['Voters']} | {row['Manipulable %']:.2f}%"
|
| 367 |
+
ax_hist.text(row['Manipulable %'] + 1, i, label, va='center', fontsize=9, color='black')
|
| 368 |
+
|
| 369 |
+
st.pyplot(fig_hist)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
# Reset
|
| 373 |
if st.button("π Reset Voters"):
|
| 374 |
st.session_state.voters = []
|
| 375 |
+
st.session_state.scoring_history = []
|
| 376 |
st.rerun()
|