limaju.py 9.51 KB
Newer Older
Dominique Merle's avatar
Dominique Merle committed
1
2
3
#!/usr/bin/env python

"""
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
A tool to deliberate with majority judgment polling.

Usage:

    ./limaju.py judgments.csv --mentions mentions.txt

where judgments.csv looks like this

    Tyran Sanguinaire, Chien, Écologiste Décroissant
    insuffisant⋅e,excellent⋅e,excellent⋅e
    à rejeter,excellent⋅e,à rejeter
    à rejeter,insuffisant⋅e,très bien

and mentions.txt looks like this

    excellent⋅e
    très bien
    bien
    assez bien
    passable
    insuffisant⋅e
    à rejeter

Run `./limaju.py --help` for more options.
Dominique Merle's avatar
Dominique Merle committed
28
29
30
31
32
33
34
35
36
37
38
39
40
41
"""

__author__ = "Constituantes"
__version__ = "0.1.0"
__license__ = "MIT"

import sys
import argparse
import csv
import math
import copy
from io import StringIO
from functools import cmp_to_key

42
PY2 = sys.version_info.major == 2
Dominique Merle's avatar
Dominique Merle committed
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

VERBOSITY_NONE = 0
VERBOSITY_SOME = 1
VERBOSITY_MUCH = 2
VERBOSITY_VERY = 3


def log(message):
    print(message)


def find_file(some_vague_path):
    return some_vague_path


58
59
60
61
62
63
64
65
66
67
68
def is_string(that_thing):
    return \
        isinstance(that_thing, basestring if PY2 else str) \
        or \
        isinstance(that_thing, unicode if PY2 else str) \
        or \
        isinstance(that_thing, basestring if PY2 else (str, bytes)) \
        or \
        isinstance(that_thing, bytes)


Dominique Merle's avatar
Dominique Merle committed
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
def get_positions(mentions):
    mentions_dict = dict()
    for i, mention in enumerate(mentions):
        mentions_dict[mention] = i
    return mentions_dict


def get_median(tally, mentions):
    low = True
    count = 0
    for mention in tally:
        count += tally[mention]

    # Lowest mention is the default
    median = mentions[-1]

    if 0 == count:
        return median

    median_index = math.floor((count + (-1 if low else 1)) / 2.0)

    current = 0
    for mention in reversed(mentions):  # lowest to highest
        mention_min = current
        current += tally[mention]
        mention_max = current

        if mention_min <= median_index < mention_max:
            median = mention
            break

    return median


def is_tally_empty(tally):
    for mention in tally:
        if 0 < tally[mention]:
            return False
    return True


def decrement_mention(tally, mention):
    assert tally[mention] > 0
    tally[mention] -= 1


def sort_two_candidates(judgments_of, tally_of, mentions, ca, cb):
    toca = tally_of[ca]
    tocb = tally_of[cb]
    mdca = get_median(toca, mentions)
    mdcb = get_median(tocb, mentions)

    positions = get_positions(mentions)

    if mdca == mdcb:
124
125
        cotoca = copy.deepcopy(toca)
        cotocb = copy.deepcopy(tocb)
Dominique Merle's avatar
Dominique Merle committed
126
127
128
129
130
131
132
133
134

        while not is_tally_empty(cotoca) and not is_tally_empty(cotocb):
            nemdca = get_median(cotoca, mentions)
            nemdcb = get_median(cotocb, mentions)
            if nemdca == nemdcb:
                decrement_mention(cotoca, nemdca)
                decrement_mention(cotocb, nemdcb)
            else:
                return positions[nemdca] - positions[nemdcb]
135
136
        log("EXACT EQUALITY FOUND FOR CANDIDATES")
        log("%s == %s" % (ca, cb))
Dominique Merle's avatar
Dominique Merle committed
137
138
139
140
141
        return 0
    else:
        return positions[mdca] - positions[mdcb]


142
def load_judgments_from_string(judgments_string):
143
    judgments_data_reader = csv.reader(
144
        StringIO("".join(judgments_string).strip()),
Dominique Merle's avatar
Dominique Merle committed
145
146
147
148
        skipinitialspace=True,
        delimiter=',',
        lineterminator='\n'
    )
149
150
151
152
153
154
155
    # Load it all up in the memory, who cares?
    judgments_data = []
    for judgments in judgments_data_reader:
        if not judgments:
            #log("Skipping empty line at row %d..." % current_row)
            continue
        judgments_data.append(judgments)
Dominique Merle's avatar
Dominique Merle committed
156

157
158
159
    return judgments_data


160
161
def load_mentions_from_string(ms, sep="\n"):
    return [m.strip() for m in ms.strip().split(sep) if m and m.strip()]
162
163
164
165
166
167
168


def deliberate(judgments_data,
               mentions,
               skip_cols=0):

    ignore_blanks = False
Dominique Merle's avatar
Dominique Merle committed
169
170
171
    candidates_list = list()
    everyones_judgments = dict()
    skip_rows = 0
172
    header_on_row = skip_rows + 0  # Set to -1 to disable header
Dominique Merle's avatar
Dominique Merle committed
173

174
175
176
177
178
179
    if is_string(judgments_data):
        judgments_data = load_judgments_from_string(judgments_data)

    if is_string(mentions):
        mentions = load_mentions_from_string(mentions)

Dominique Merle's avatar
Dominique Merle committed
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
    current_row = -1
    for judgments in judgments_data:
        current_row += 1

        judgments = judgments[skip_cols:]

        if current_row < skip_rows:
            continue

        if current_row == header_on_row:
            candidates_list = judgments
            continue

        if not judgments:
            log("Skipping empty line at row %d..." % current_row)
            continue

        if not candidates_list:
            candidates_list = \
                ["Candidate %s"%(chr(i)) for i in range(len(judgments))]

201
202
203
204
205
206
207
        for i, mention in enumerate(judgments):
            if mention is None or mention == '':
                if ignore_blanks:
                    continue
                else:
                    judgments[i] = mentions[-1]
                    continue
Dominique Merle's avatar
Dominique Merle committed
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
            if mention not in mentions:
                log("Found unknown mention `%s' at row %d." % (
                    mention, current_row
                ))
                log("Use --mentions to specify a mentions file.")
                exit(1)

        for i in range(len(candidates_list)):
            candidate = candidates_list[i]
            if candidate not in everyones_judgments:
                everyones_judgments[candidate] = list()
            everyones_judgments[candidate].append(judgments[i])

    mentions_dict = get_positions(mentions)

    # Sort the mentions of each candidate.
    for candidate in everyones_judgments:
        s = sorted(
            everyones_judgments[candidate],
            key=lambda m: mentions_dict[m])
        everyones_judgments[candidate] = s

    judgments_tallies = dict()
    for i in range(len(candidates_list)):
        candidate = candidates_list[i]
        if candidate not in judgments_tallies:
            judgments_tallies[candidate] = dict()
        for mention in mentions:
            if mention not in judgments_tallies[candidate]:
                judgments_tallies[candidate][mention] = 0
        for mention in everyones_judgments[candidate]:
            if mention not in judgments_tallies[candidate]:
                # mention unknown
                log("Warning: Unknown mention: %s" % mention)
                judgments_tallies[candidate][mention] = 0
            judgments_tallies[candidate][mention] += 1

    # log("Candidates")
    # log(candidates_list)
    # log(judgments_tallies)

    def _cmp_candidates(ca, cb):
        # Here we could hook to external, replaceable classes
        # to simplify usage of other algorithms.
        return sort_two_candidates(
            everyones_judgments,
            judgments_tallies,
            mentions,
            ca, cb
        )

    sorted_candidates = sorted(
        candidates_list,
        key=cmp_to_key(_cmp_candidates)
    )

264
    return sorted_candidates, judgments_tallies
Dominique Merle's avatar
Dominique Merle committed
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
def main(args_parser, args):  # move to bottom, no need for a func
    log("MAJORITY JUDGMENT POLLING -- Version %s" % __version__)

    # In decreasing order.
    default_mentions = [
        'EXCELLENT',
        'VERY GOOD',
        'GOOD',
        'SOMEWHAT GOOD',
        'PASSABLE',
        'POOR',
        'REJECT',
    ]
    if not args.mentions_file:
        mentions = default_mentions
    else:
        with open(args.mentions_file) as f:
            mentions = [l.strip() for l in f.readlines() if l and l.strip()]

    log("\nGoing to use the following mentions:")
    log(''+(', '.join(mentions)))

    if args.input_file is None:
        exit(1)

    log("\nWaiting for input judgments...")
    log("(use CTRL+D to exit)")
    input_csv_strings = args.input_file.readlines()

    if not input_csv_strings:
        log("Please provide an input CSV file.")
        args_parser.print_help()
        args_parser.exit(1)

    judgments_data = load_judgments_from_string(input_csv_strings)

    log("\nRead judgments from %d judges." % (len(judgments_data)-1))

    deliberation, tally = deliberate(
        judgments_data, mentions,
        int(args.skip_cols)
    )

    log("\nDELIBERATION")
    for i, candidate in enumerate(deliberation):
        log("%02d.\t%18s\t%s" % (
            i+1,
            get_median(tally[candidate], mentions),
            candidate,
        ))


Dominique Merle's avatar
Dominique Merle committed
319
320
321
322
323
324
325
326
327
328
329
330
331
if __name__ == "__main__":
    parser = argparse.ArgumentParser()

    # Optional argument flag which defaults to False
    # parser.add_argument(
    #     '-f',
    #     '--flag',
    #     action="store_true",
    #     default=False,
    #     help=""
    # )

    parser.add_argument(
332
333
334
335
336
        'input_file',
        nargs='?',
        type=argparse.FileType('r'),
        help="A CSV file with the judgments.",
        default=sys.stdin
Dominique Merle's avatar
Dominique Merle committed
337
338
339
340
341
342
343
    )

    parser.add_argument(
        "-m",
        "--mentions",
        action="store",
        dest="mentions_file",
344
345
346
347
        help="""
        A text file with one mention per line,
        in order from highest to lowest.
        """
Dominique Merle's avatar
Dominique Merle committed
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
    )

    parser.add_argument(
        "--skip-cols",
        action="store",
        default=0,
        dest="skip_cols",
        help="Amount of columns to skip on the left."
    )

    # Optional verbosity counter (eg. -v, -vv, -vvv, etc.)
    parser.add_argument(
        '-v',
        '--verbose',
        action='count',
        default=0,
        help="Verbosity (-v, -vv, etc)"
    )

    # Specify output of '--version'
    parser.add_argument(
        '--version',
        action='version',
        version='%(prog)s (version {version})'.format(version=__version__)
    )

    args = parser.parse_args()
375
    main(parser, args)