diff --git a/docs/img/score_10years.png b/docs/img/score_10years.png new file mode 100644 index 0000000000000000000000000000000000000000..bef922b8906ac6410650601351336f4909c11b43 Binary files /dev/null and b/docs/img/score_10years.png differ diff --git a/docs/img/score_1year.png b/docs/img/score_1year.png new file mode 100644 index 0000000000000000000000000000000000000000..50d579aabb7e771c656500b97f7c1f6997ecbf1d Binary files /dev/null and b/docs/img/score_1year.png differ diff --git a/docs/img/score_exp1k.png b/docs/img/score_exp1k.png new file mode 100644 index 0000000000000000000000000000000000000000..40a43e73ad235b16cec2ef562ddaae9fae9ae896 Binary files /dev/null and b/docs/img/score_exp1k.png differ diff --git a/docs/img/score_exp6.png b/docs/img/score_exp6.png new file mode 100644 index 0000000000000000000000000000000000000000..9262e8c05ca5e6c9a0b0262ff6bdc24946c09831 Binary files /dev/null and b/docs/img/score_exp6.png differ diff --git a/docs/img/score_formula.png b/docs/img/score_formula.png new file mode 100644 index 0000000000000000000000000000000000000000..131e4d8a5ed7b59c4ea92408f150cb7cdbe8f930 Binary files /dev/null and b/docs/img/score_formula.png differ diff --git a/docs/index.rst b/docs/index.rst index 05a6e669f3451c7c10c251928e4ae815a475d8c6..76d8172a76b2041530e95ebf3b6e986c82076ea3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Contents: usage deployment administration + scores api development hacker diff --git a/docs/scores.rst b/docs/scores.rst new file mode 100644 index 0000000000000000000000000000000000000000..529e74f7297b2f0410d63366676b0e6ce02fc688 --- /dev/null +++ b/docs/scores.rst @@ -0,0 +1,64 @@ +Memopol Scores +~~~~~~~~~~~~~~ + +Score computation +================= + +In Memopol, each representative has a score that shows how their votes match +the recommendations made by administrators on the instance. The total score +of a representative is the sum of their score on each dossier, which in turn is +the sum of their score on each proposal with a recommendation on the dossier. + +Each recommendation made by administrators has a weight: a number that tells how +important a specific proposal is. When the representative vote on a proposal +matches the recommendation, their score on the proposal is set to +weight. +Otherwise, it is set to -weight. + +Score decay parameters +====================== + +Memopol allows to set decay parameters so that older votes have a lower +importance in the total representative score. By default, those parameters are +set to values that disable the score decay, so that each vote contributes +identically to the total score no matter how old it is. + +The formula used to compute score is the following: + +.. image:: img/score_formula.png + +Where: + +* ``baseScore`` is the base score for the vote computed as explained above; +* ``voteAge`` is the age of the vote in days; +* ``decayNum`` and ``decayDenom`` define the decay rate; +* ``exponent`` define the steepness of the decay. + +The corresponding parameters can be set from the Memopol administration +interface (Memopol Settings > Settings); settings keys are ``SCORE_DECAY_NUM``, +``SCORE_DECAY_DENOM``, ``SCORE_EXPONENT``. Additionnaly, the ``SCORE_DECIMALS`` +parameter sets how many decimal places are visible when scores are displayed. + +The default values for those settings disable score decay by setting +``SCORE_DECAY_NUM`` to 0, ``SCORE_DECAY_DENOM`` and ``SCORE_EXPONENT`` to 1. + +If you want to use score decay, start by setting ``SCORE_DECAY_NUM`` to 1, and +``SCORE_DECAY_DENOM`` to the number of days you want votes to matter. The graph +below shows how a score of 1.0 will decay with a 1-year decay (the X axis is in +days). + +.. image:: img/score_1year.png + +Increasing ``SCORE_DECAY_DENOM`` will make votes matter longer. Here is the +same example but with a 10-year decay. + +.. image:: img/score_10years.png + +Increasing ``SCORE_EXPONENT`` instead will make the decay cutoff steeper. Here +is an example with a 1-year decay and the exponent set to 6. + +.. image:: img/score_exp6.png + +Increasing it dramatically will create a brutal cutoff; here is the same example +with the exponent set to 1000: + +.. image:: img/score_exp1k.png diff --git a/memopol/fixtures/one_representative.json b/memopol/fixtures/one_representative.json index c260ea3bc586f77f7080d471becb1e3817954c0c..9bba397c9a9d817b4d5275946ff357ea58da4a5d 100644 --- a/memopol/fixtures/one_representative.json +++ b/memopol/fixtures/one_representative.json @@ -1159,13 +1159,13 @@ "app_label": "representatives_positions" }, "model": "contenttypes.contenttype", - "pk": 27 + "pk": 28 }, { "fields": { "tag": 1, "object_id": 1, - "content_type": 27 + "content_type": 28 }, "model": "taggit.taggeditem", "pk": 1 @@ -1174,7 +1174,7 @@ "fields": { "tag": 2, "object_id": 1, - "content_type": 27 + "content_type": 28 }, "model": "taggit.taggeditem", "pk": 2 @@ -1183,7 +1183,7 @@ "fields": { "tag": 1, "object_id": 3, - "content_type": 27 + "content_type": 28 }, "model": "taggit.taggeditem", "pk": 5 @@ -1192,7 +1192,7 @@ "fields": { "tag": 1, "object_id": 2, - "content_type": 27 + "content_type": 28 }, "model": "taggit.taggeditem", "pk": 6 @@ -1201,7 +1201,7 @@ "fields": { "tag": 3, "object_id": 2, - "content_type": 27 + "content_type": 28 }, "model": "taggit.taggeditem", "pk": 7 diff --git a/memopol/settings.py b/memopol/settings.py index 227bba18f40cfb3002ac3e7df43d503e0046db5a..68f89dd22a26237ba14ce2a79f5ed05942e5e698 100644 --- a/memopol/settings.py +++ b/memopol/settings.py @@ -90,6 +90,7 @@ INSTALLED_APPS = ( # --- 'core', 'memopol', + 'memopol_settings', 'representatives', 'representatives_votes', 'representatives_recommendations', diff --git a/memopol_settings/__init__.py b/memopol_settings/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/memopol_settings/admin.py b/memopol_settings/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..88c9bfa1d901448becea8ed8e179ea64fe5d1f0d --- /dev/null +++ b/memopol_settings/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import Setting + + +class SettingAdmin(admin.ModelAdmin): + list_display = ('key', 'value', 'comment') + list_editable = ('key', 'value', 'comment') + list_filter = ('key',) + +admin.site.register(Setting, SettingAdmin) diff --git a/memopol_settings/fixtures/score_settings.json b/memopol_settings/fixtures/score_settings.json new file mode 100644 index 0000000000000000000000000000000000000000..01ecf76ba05fc631c84cc81ec403bc0916089851 --- /dev/null +++ b/memopol_settings/fixtures/score_settings.json @@ -0,0 +1,30 @@ +[{ + "model": "memopol_settings.setting", + "pk": "SCORE_DECAY_NUM", + "fields": { + "value": "0", + "comment": "Numerator for decay rate in score formula. Set to 0 for no decay, 1 otherwise. Score formula is base_score * exp( -(vote_age*DECAY_NUM/DECAY_DENOM)^(2*EXPONENT) )." + } +}, +{ + "model": "memopol_settings.setting", + "pk": "SCORE_DECAY_DENOM", + "fields": { + "value": "1", + "comment": "Denominator for decay rate in score formula. Must be nonzero. Set to higher value to delay the score decay. Score formula is base_score * exp( -(vote_age*DECAY_NUM/DECAY_DENOM)^(2*EXPONENT) )." + } +},{ + "model": "memopol_settings.setting", + "pk": "SCORE_EXPONENT", + "fields": { + "value": "1", + "comment": "Exponent for score formula. Set to higher value for a steeper decay around the cutoff. Score formula is base_score * exp( -(vote_age*DECAY_NUM/DECAY_DENOM)^(2*EXPONENT) )." + } +},{ + "model": "memopol_settings.setting", + "pk": "SCORE_DECIMALS", + "fields": { + "value": "0", + "comment": "Number of score decimals to display. Use 0 for integers." + } +}] \ No newline at end of file diff --git a/memopol_settings/migrations/0001_initial.py b/memopol_settings/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..8c5caa8a631b5379b5cb7116e13001b39ef21473 --- /dev/null +++ b/memopol_settings/migrations/0001_initial.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Setting', + fields=[ + ('key', models.CharField(max_length=255, serialize=False, primary_key=True)), + ('value', models.CharField(max_length=255)), + ('comment', models.TextField()), + ], + ), + ] diff --git a/memopol_settings/migrations/0002_score_settings.py b/memopol_settings/migrations/0002_score_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..00c7098b6c631d63be16c797efa5a8858a64a9cf --- /dev/null +++ b/memopol_settings/migrations/0002_score_settings.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import os + +from django.core import serializers +from django.db import migrations + +fixture_dir = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + '../fixtures')) +fixture_filename = 'score_settings.json' + + +def load_fixture(apps, schema_editor): + fixture_file = os.path.join(fixture_dir, fixture_filename) + + fixture = open(fixture_file, 'rb') + objects = serializers.deserialize('json', fixture, ignorenonexistent=True) + for obj in objects: + obj.save() + fixture.close() + + +class Migration(migrations.Migration): + + dependencies = [ + ('memopol_settings', '0001_initial'), + ] + + operations = [ + migrations.RunPython(load_fixture), + ] diff --git a/memopol_settings/migrations/__init__.py b/memopol_settings/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/memopol_settings/models.py b/memopol_settings/models.py new file mode 100644 index 0000000000000000000000000000000000000000..70dc9682f7f41dccc7e1940d6d71be014d73332c --- /dev/null +++ b/memopol_settings/models.py @@ -0,0 +1,7 @@ +from django.db import models + + +class Setting(models.Model): + key = models.CharField(max_length=255, primary_key=True) + value = models.CharField(max_length=255) + comment = models.TextField() diff --git a/representatives_recommendations/migrations/0006_score_formula.py b/representatives_recommendations/migrations/0006_score_formula.py new file mode 100644 index 0000000000000000000000000000000000000000..52039c8bbbd2e48a71c16a212df3b483dcb8c1b7 --- /dev/null +++ b/representatives_recommendations/migrations/0006_score_formula.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('representatives_recommendations', '0005_representativescore'), + ] + + operations = [ + migrations.RunSQL( + """ + DROP VIEW "representatives_recommendations_votescores" CASCADE; + """ + ), + migrations.AlterField( + model_name='recommendation', + name='weight', + field=models.FloatField(default=0), + ), + migrations.RunSQL( + """ + CREATE VIEW "representatives_recommendations_votescores" + AS SELECT + representatives_votes_vote.id, + representatives_votes_vote."position", + representatives_votes_vote.proposal_id, + representatives_votes_vote.representative_id, + ROUND(CAST(EXP(-((decay_num.value * EXTRACT(days FROM CURRENT_DATE - representatives_votes_proposal.datetime) / decay_denom.value) ^ (2 * exponent.value))) + * (CASE + WHEN representatives_votes_vote."position"::text = representatives_recommendations_recommendation.recommendation::text THEN representatives_recommendations_recommendation.weight + ELSE 0 - representatives_recommendations_recommendation.weight + END) AS NUMERIC), decimals.value) AS score + FROM representatives_votes_vote + JOIN (SELECT CAST(TO_NUMBER(value, '99999') AS FLOAT) AS value FROM memopol_settings_setting WHERE key = 'SCORE_DECAY_NUM') decay_num ON 1=1 + JOIN (SELECT CAST(TO_NUMBER(value, '99999') AS FLOAT) AS value FROM memopol_settings_setting WHERE key = 'SCORE_DECAY_DENOM') decay_denom ON 1=1 + JOIN (SELECT CAST(TO_NUMBER(value, '99999') AS FLOAT) AS value FROM memopol_settings_setting WHERE key = 'SCORE_EXPONENT') exponent ON 1=1 + JOIN (SELECT CAST(TO_NUMBER(value, '99999') AS INTEGER) AS value FROM memopol_settings_setting WHERE key = 'SCORE_DECIMALS') decimals ON 1=1 + JOIN representatives_votes_proposal ON representatives_votes_vote.proposal_id = representatives_votes_proposal.id + LEFT JOIN representatives_recommendations_recommendation ON representatives_votes_proposal.id = representatives_recommendations_recommendation.proposal_id + WHERE representatives_recommendations_recommendation.id IS NOT NULL; + """ + ), + migrations.RunSQL( + """ + CREATE VIEW "representatives_recommendations_dossierscores" + AS SELECT + "representatives_recommendations_votescores"."representative_id" || ':' || "representatives_votes_proposal"."dossier_id" AS "id", + "representatives_recommendations_votescores"."representative_id", + "representatives_votes_proposal"."dossier_id", + SUM("representatives_recommendations_votescores"."score") AS "score" + FROM "representatives_recommendations_votescores" + INNER JOIN "representatives_votes_proposal" + ON ( "representatives_recommendations_votescores"."proposal_id" = "representatives_votes_proposal"."id" ) + GROUP BY + "representatives_recommendations_votescores"."representative_id", + "representatives_votes_proposal"."dossier_id" + """ + ), + migrations.RunSQL( + """ + CREATE VIEW "representatives_recommendations_representativescore" + AS SELECT + "representatives_representative"."id" as "representative_id", + COALESCE(SUM("representatives_recommendations_votescores"."score"), 0) AS "score" + FROM + "representatives_representative" + LEFT OUTER JOIN "representatives_recommendations_votescores" + ON "representatives_recommendations_votescores"."representative_id" = "representatives_representative"."id" + GROUP BY "representatives_representative"."id" + """ + ) + ] diff --git a/representatives_recommendations/models.py b/representatives_recommendations/models.py index 7e887e036fda55d0e5731a7b9cd1081a35be61ea..c9a1895196ce932f02f4ad4d47f5d4793274c6c0 100644 --- a/representatives_recommendations/models.py +++ b/representatives_recommendations/models.py @@ -12,7 +12,7 @@ class DossierScore(models.Model): representative = models.ForeignKey(Representative, on_delete=models.DO_NOTHING) dossier = models.ForeignKey(Dossier, on_delete=models.DO_NOTHING) - score = models.IntegerField(default=0) + score = models.FloatField(default=0) class Meta: managed = False @@ -25,7 +25,7 @@ class VoteScore(models.Model): representative = models.ForeignKey( Representative, related_name='votescores', null=True) position = models.CharField(max_length=10) - score = models.IntegerField(default=0) + score = models.FloatField(default=0) class Meta: managed = False @@ -36,7 +36,7 @@ class VoteScore(models.Model): class RepresentativeScore(models.Model): representative = models.OneToOneField('representatives.representative', primary_key=True, related_name='score') - score = models.IntegerField(default=0) + score = models.FloatField(default=0) class Meta: managed = False @@ -52,7 +52,7 @@ class Recommendation(models.Model): recommendation = models.CharField(max_length=10, choices=Vote.VOTECHOICES) title = models.CharField(max_length=1000, blank=True) description = models.TextField(blank=True) - weight = models.IntegerField(default=0) + weight = models.FloatField(default=0) class Meta: ordering = ['proposal__datetime']