Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ We therefore use a _score-based algorithm_ and _no floating-point arithmetic_ wh
- Supports thousands of proposals
- Handles default grades (static or normalized)
- No floating-point arithmetic
- Room for other deliberators (central, usual)
- Room for other deliberation resolvers (central, usual)
- Computes the merit of proposals (JM-Score and an approximation of Merit from Absolute Rank)


## Example Usage
Expand Down Expand Up @@ -99,7 +100,7 @@ TallyInterface tally = new NormalizedTally(new ProposalTallyInterface[] {

### Collect a Tally from judgments

It's usually best to use structured queries (eg: in SQL) directly in your database to collect the tallies, since it scales better with high amounts of participants, but if you must you can collect the tally directly from individual judgments, with a `CollectedTally`.
It's usually best to use structured queries (eg: in SQL) directly in your database to collect the tallies, since it scales better with high amounts of participants, but if you must, you can collect the tally directly from individual judgments, with a `CollectedTally`.

```java
Integer amountOfProposals = 2;
Expand Down
159 changes: 153 additions & 6 deletions src/main/java/fr/mieuxvoter/mj/MajorityJudgmentDeliberator.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package fr.mieuxvoter.mj;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.Arrays;
import java.util.Comparator;

Expand Down Expand Up @@ -45,26 +47,29 @@ public ResultInterface deliberate(TallyInterface tally) throws InvalidTallyExcep
Result result = new Result();
ProposalResult[] proposalResults = new ProposalResult[amountOfProposals];

// I. Compute the scores of each Proposal
// I. Compute the score and merit of each Proposal
for (int proposalIndex = 0; proposalIndex < amountOfProposals; proposalIndex++) {
ProposalTallyInterface proposalTally = tallies[proposalIndex];
String score = computeScore(proposalTally, amountOfJudges);
ProposalTallyAnalysis analysis = new ProposalTallyAnalysis(
proposalTally, this.favorContestation
);

ProposalResult proposalResult = new ProposalResult();
proposalResult.setIndex(proposalIndex);
proposalResult.setScore(score);
proposalResult.setAnalysis(analysis);
// proposalResult.setRank(???); // rank is computed below, AFTER the score pass

proposalResults[proposalIndex] = proposalResult;
}

// II. Sort Proposals by score (lexicographical inverse)
ProposalResult[] proposalResultsSorted = proposalResults.clone(); // MUST be shallow
Arrays.sort(
proposalResultsSorted,
(Comparator<ProposalResultInterface>) (p0, p1) -> p1.getScore().compareTo(p0.getScore()));
(Comparator<ProposalResultInterface>) (p0, p1) -> p1.getScore().compareTo(p0.getScore())
);

// III. Attribute a rank to each Proposal
int rank = 1;
Expand All @@ -81,6 +86,57 @@ public ResultInterface deliberate(TallyInterface tally) throws InvalidTallyExcep
rank += 1;
}

// Steps IV, V and VI are not required to rank the proposals, but they're nice to have around.

// IV. Compute the scalar "merit from MJ-Score" of each Proposal
BigInteger sumOfMerits = BigInteger.ZERO;
for (int proposalIndex = 0; proposalIndex < amountOfProposals; proposalIndex++) {
ProposalTallyInterface proposalTally = tallies[proposalIndex];
ProposalResult proposalResult = proposalResults[proposalIndex];

BigInteger merit = computeMerit(proposalTally, amountOfJudges, this.favorContestation);

proposalResult.setMerit(merit);
sumOfMerits = sumOfMerits.add(merit);
}

// V.a Compute the (maximum!) merit a 100% EXCELLENT proposal would get
BigInteger maxMerit = BigInteger.ONE;
if (tallies.length > 0) {
int amountOfGrades = tallies[0].getTally().length;
BigInteger[] bestTally = new BigInteger[amountOfGrades];
Arrays.fill(bestTally, BigInteger.ZERO);
bestTally[bestTally.length - 1] = amountOfJudges;
maxMerit = computeMerit(new ProposalTally(bestTally), amountOfJudges, this.favorContestation);
}

// V.b Approximate the scalar "merit from absolute rank" of each Proposal (Affine Merit)
double sumOfAffineMerits = 0.0;
if (tallies.length > 0) {
for (int proposalIndex = 0; proposalIndex < amountOfProposals; proposalIndex++) {
ProposalResult proposalResult = proposalResults[proposalIndex];
int amountOfGrades = tallies[0].getTally().length;

Double affineMerit = adjustMeritToAffine(
proposalResult.getMerit(),
maxMerit,
amountOfJudges,
amountOfGrades
);

proposalResult.setAffineMerit(affineMerit);
sumOfAffineMerits += affineMerit;
}
}

// VI. Compute the relative merit(s) of each Proposal
for (int proposalIndex = 0; proposalIndex < amountOfProposals; proposalIndex++) {
ProposalResult proposalResult = proposalResultsSorted[proposalIndex];
proposalResult.computeRelativeMerit(sumOfMerits);
proposalResult.computeRelativeAffineMerit(sumOfAffineMerits);
}

// VII. All done, let's output
result.setProposalResults(proposalResults);
result.setProposalResultsRanked(proposalResultsSorted);

Expand Down Expand Up @@ -126,15 +182,24 @@ private boolean isTallyBalanced(TallyInterface tally) {
}

/**
* @see computeScore() below
* @see this#computeScore(ProposalTallyInterface, BigInteger, Boolean, Boolean) below
*/
private String computeScore(ProposalTallyInterface tally, BigInteger amountOfJudges) {
return computeScore(tally, amountOfJudges, this.favorContestation, this.numerizeScore);
private String computeScore(
ProposalTallyInterface tally,
BigInteger amountOfJudges
) {
return computeScore(
tally,
amountOfJudges,
this.favorContestation,
this.numerizeScore
);
}

/**
* A higher score means a better rank. Assumes that grades' tallies are provided from "worst"
* grade to "best" grade.
* grade to "best" grade. This score is fast to compute but is not meaningful.
* For a meaningful scalar value, see this#computeMerit().
*
* @param tally Holds the tallies of each Grade for a single Proposal
* @param amountOfJudges Amount of judges participating
Expand Down Expand Up @@ -193,11 +258,93 @@ private String computeScore(
return score.toString();
}

/**
* This method is not used in ranking, but helps compute a scalar merit for a given merit profile.
* Such a scalar merit is handy for deriving a proportional representation for example.
* This merit is isomorphic with MJ ranking and could be used for ranking. (bigger is better)
* Marc Paraire calls this merit the "MJ-Score".
* As you can see, this algorithm is quite similar to the string score one.
* The main difference is that it's a little slower to compute, but the output value is more meaningful.
*/
private BigInteger computeMerit(
ProposalTallyInterface tally,
BigInteger amountOfJudges,
Boolean favorContestation
) {
ProposalTallyAnalysis analysis = new ProposalTallyAnalysis();
analysis.reanalyze(tally, favorContestation);

int amountOfGrades = tally.getTally().length;

ProposalTallyInterface currentTally = tally.duplicate();

BigInteger merit = BigInteger.valueOf(analysis.getMedianGrade());
Integer cursorGrade = analysis.getMedianGrade();
Integer minProcessedGrade = cursorGrade;
Integer maxProcessedGrade = cursorGrade;

for (int i = 0; i < amountOfGrades - 1; i++) {

merit = merit.multiply(amountOfJudges);

if (analysis.getSecondMedianGroupSize().compareTo(BigInteger.ZERO) == 0) {
continue;
}

if (analysis.getSecondMedianGroupSign() > 0) {
cursorGrade = maxProcessedGrade + 1;
maxProcessedGrade = cursorGrade;
} else {
cursorGrade = minProcessedGrade - 1;
minProcessedGrade = cursorGrade;
}

merit = merit.add(
analysis.getSecondMedianGroupSize().multiply(
BigInteger.valueOf(analysis.getSecondMedianGroupSign())
)
);

currentTally.moveJudgments(analysis.getMedianGrade(), cursorGrade);
analysis.reanalyze(currentTally, favorContestation);
}

return merit;
}

private int countDigits(int number) {
//noinspection StringTemplateMigration
return ("" + number).length();
}

private int countDigits(BigInteger number) {
//noinspection StringTemplateMigration
return ("" + number).length();
}

/**
* This method is NOT used in ranking, but helps compute yet another scalar merit for a given merit profile.
* Such a scalar merit is handy for deriving a proportional representation for example.
* This method adjusts the MJ-Score to make its distribution quasi-affine over all possible merit profiles.
* See study/output_30_0.png
* You can safely pretend that this does not exist, since it is NOT used in ranking.
*/
private Double adjustMeritToAffine(
BigInteger merit,
BigInteger maxMerit,
BigInteger amountOfJudges,
int amountOfGrades
) {
double meritNormalized = (new BigDecimal(merit).divide(
new BigDecimal(maxMerit), 15, RoundingMode.HALF_EVEN
)).doubleValue();

double rankNormalized = new MeritToAbsoluteRankModel().apply(
meritNormalized,
amountOfGrades,
amountOfJudges.intValue()
);

return (1.0 - rankNormalized);
}
}
136 changes: 136 additions & 0 deletions src/main/java/fr/mieuxvoter/mj/MeritToAbsoluteRankModel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package fr.mieuxvoter.mj;

import static java.lang.Math.pow;
import static java.lang.Math.sin;
import static java.lang.Math.PI;
import static java.lang.Math.E;

/**
* This is an experiment. This is NOT used in computing the ranking of MJ. Don't worry. You can ignore this.
* It is used to approximate the "merit by absolute rank" from the scalar "merit by JM-Score".
* Both could be used in proportional representation for proportional polls amongst scouts.
* For proportional polls amongst soldiers (prone to polarized voting), see the Osmotic Favoritism algo instead.
* What we call "absolute rank" is the rank of a merit profile in the MJ poll with ALL possible merit profiles.
*/
public class MeritToAbsoluteRankModel {

/**
* @param merit is expected to be normalized (between 0 and 1)
* @return the approximation of the absolute rank, normalized
*/
public double apply(
double merit,
int amountOfGrades,
Integer amountOfJudges
) {
class SigmoidAmplitudeModel {
final Double coefficient;
final Double offset;
final Double origin;
final Double sin_amplitude;
final Double sin_origin;
final Double sin_phase;

public SigmoidAmplitudeModel(
Double coefficient,
Double offset,
Double origin,
Double sin_amplitude,
Double sin_origin,
Double sin_phase
) {
this.coefficient = coefficient;
this.offset = offset;
this.origin = origin;
this.sin_amplitude = sin_amplitude;
this.sin_origin = sin_origin;
this.sin_phase = sin_phase;
}

public Double computeAmplitude(Integer amountOfJudges) {
return
this.offset + (this.coefficient / (amountOfJudges - this.origin))
+
this.sin_amplitude * sin(amountOfJudges * PI + this.sin_phase)
/
(amountOfJudges - this.sin_origin);
}
}

// This bullshit fitting has been made using dirty, dirty python ; but it works well enough for now
SigmoidAmplitudeModel[] sam;
if (2 == amountOfGrades) {
// With 2 grades the merit from MJ-Score is already affine
return 1.0 - merit;
} else if (3 == amountOfGrades) {
// Values derived from rough model fitting ; they can be improved
sam = new SigmoidAmplitudeModel[]{
new SigmoidAmplitudeModel(0.6409350779507367, 0.4965854515219494, -5.9146962453756444, 23.3851437770479187, 0.9996311919466460, 0.0009832013303302),
new SigmoidAmplitudeModel(-0.6410295650865494, 0.5034170870490888, -5.9157805848947866, -0.5494767763972728, 1.0001343001977745, 0.0418436294071475),
};
} else if (4 == amountOfGrades) {
// Values derived from rough model fitting ; they can be improved
sam = new SigmoidAmplitudeModel[]{
new SigmoidAmplitudeModel(0.9170475003989843, 0.2456153714826784, -3.5091977159324292, 0.1867944159248675, 0.9990570652741461, -6.1051158548115607),
new SigmoidAmplitudeModel(-0.8277524466501042, 0.5019721627432320, -3.0645135231547678, -0.0080383779640542, 1.2071429213468290, 0.5552095403898315),
new SigmoidAmplitudeModel(-0.0537159095557622, 0.2509962916400555, -10.3225213727017575, -0.0450613036977610, 0.7945092912788447, 0.7452859647656658),
};
} else if (5 == amountOfGrades) {
// Values derived from rough model fitting ; they can be improved
sam = new SigmoidAmplitudeModel[]{
new SigmoidAmplitudeModel(0.9000482334396634, 0.1206547483774695, -2.4963552848848400, -0.0356967817861015, 1.0359005237315060, -1.5470500509326637),
new SigmoidAmplitudeModel(-0.3290841630085418, 0.3771535023430787, -1.2587082942998835, -5.2922128265961055, 0.1750391985549460, -0.0032739374414037),
new SigmoidAmplitudeModel(-0.8157881989763880, 0.3768242875184030, -3.6329714453909800, -0.0239089808347504, 0.6837626088956580, 1.5690544889497136),
new SigmoidAmplitudeModel(0.1980505155370003, 0.1265655384666737, -2.5951108466266279, -0.1151449718489945, 0.9638237758976738, 0.2475457864562964),
};
} else if (6 == amountOfGrades) {
// Values derived from rough model fitting ; they can be improved
sam = new SigmoidAmplitudeModel[]{
new SigmoidAmplitudeModel(0.7708075223467123, 0.0580869399899168, -1.7708756450606116, -0.0514019515740431, 1.0922318721535316, 5.5435707901018292),
new SigmoidAmplitudeModel(0.0113468236267469, 0.2593847095025533, 3.4080676150197013, 0.3127399704834197, 4.5162752552045529, 0.0246261384044150),
new SigmoidAmplitudeModel(-0.9580137264950088, 0.3756958174463476, -3.6912376115661321, -0.0808529635154282, 1.1932023599818111, 6.3582517865739003),
new SigmoidAmplitudeModel(-0.3759791146003723, 0.2517848780681173, -2.7201748200294440, -0.0411965223777122, 0.4881179927844195, -5.3011065325748721),
new SigmoidAmplitudeModel(0.2852468211981568, 0.0634098062656290, -2.6247252814193711, 0.0250299350511801, 1.0159131152232956, -1.5837886294153409),
};
} else if (7 == amountOfGrades) {
// Values derived from rough model fitting ; they can be improved
sam = new SigmoidAmplitudeModel[]{
new SigmoidAmplitudeModel(0.5151336373041772, 0.0304017096437998, -0.1560819745436698, -0.0642768687910415, 3.7019618565115722, -0.2267673450950530),
new SigmoidAmplitudeModel(0.8321495032592745, 0.1538010001096599, -10.1403742732170450, 0.1452337649130754, 2.9093303593527824, 0.1670760936959231),
new SigmoidAmplitudeModel(-0.5832534017217945, 0.3128738036537556, -2.4481699553712186, 1.7698591489043021, 0.0064898411429031, -3.1491904326892173),
new SigmoidAmplitudeModel(-0.9135479603269890, 0.3121169039235479, -4.0419384013683608, -0.0398619334678863, 2.2608983418537969, -3.5661704309341040),
new SigmoidAmplitudeModel(-0.0358891062680384, 0.1592742142625385, 0.8473094470570051, -0.1720450496934443, 0.8776512589952787, 0.1900715592340584),
new SigmoidAmplitudeModel(0.2965479931458628, 0.0309932939590777, -2.7064785369970221, -0.0616634512919992, 3.3069369590264279, -3.3295936102008192),
};
} else {
// Let's add support for more grades later
return 1.0;
}

Double sumOfAmplitudes = 0.0;
Double[] amplitudes = new Double[amountOfGrades];
for (int i = 0; i < amountOfGrades - 1; i++) {
amplitudes[i] = sam[i].computeAmplitude(amountOfJudges);
sumOfAmplitudes += amplitudes[i];
}
for (int i = 0; i < amountOfGrades - 1; i++) {
amplitudes[i] = amplitudes[i] / sumOfAmplitudes;
}

double tightness = 96.0; // derived from fitting
double rank = 0.0; // from 0.0 (exclusive) to 1.0 (inclusive) ; is 'double' enough precision?
for (int i = 0; i < amountOfGrades - 1; i++) {
rank += amplitudes[i] * sigmoid(
merit,
tightness,
(2.0 * i + 1.0) / (2.0 * (amountOfGrades - 1))
);
}

return rank;
}

private double sigmoid(double x, double tightness, double origin) {
return 1.0 / (1.0 + pow(E, tightness * (x - origin)));
}
}
Loading
Loading