Como classificar objetos por várias chaves em Python?

96

Ou, na prática, como posso classificar uma lista de dicionários por várias teclas?

Eu tenho uma lista de dictos:

b = [{u'TOT_PTS_Misc': u'Utley, Alex', u'Total_Points': 96.0},
 {u'TOT_PTS_Misc': u'Russo, Brandon', u'Total_Points': 96.0},
 {u'TOT_PTS_Misc': u'Chappell, Justin', u'Total_Points': 96.0},
 {u'TOT_PTS_Misc': u'Foster, Toney', u'Total_Points': 80.0},
 {u'TOT_PTS_Misc': u'Lawson, Roman', u'Total_Points': 80.0},
 {u'TOT_PTS_Misc': u'Lempke, Sam', u'Total_Points': 80.0},
 {u'TOT_PTS_Misc': u'Gnezda, Alex', u'Total_Points': 78.0},
 {u'TOT_PTS_Misc': u'Kirks, Damien', u'Total_Points': 78.0},
 {u'TOT_PTS_Misc': u'Worden, Tom', u'Total_Points': 78.0},
 {u'TOT_PTS_Misc': u'Korecz, Mike', u'Total_Points': 78.0},
 {u'TOT_PTS_Misc': u'Swartz, Brian', u'Total_Points': 66.0},
 {u'TOT_PTS_Misc': u'Burgess, Randy', u'Total_Points': 66.0},
 {u'TOT_PTS_Misc': u'Smugala, Ryan', u'Total_Points': 66.0},
 {u'TOT_PTS_Misc': u'Harmon, Gary', u'Total_Points': 66.0},
 {u'TOT_PTS_Misc': u'Blasinsky, Scott', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Carter III, Laymon', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Coleman, Johnathan', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Venditti, Nick', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Blackwell, Devon', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Kovach, Alex', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Bolden, Antonio', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Smith, Ryan', u'Total_Points': 60.0}]

e preciso usar uma classificação de várias chaves revertida por Total_Points, e não revertida por TOT_PTS_Misc.

Isso pode ser feito no prompt de comando da seguinte forma:

a = sorted(b, key=lambda d: (-d['Total_Points'], d['TOT_PTS_Misc']))

Mas eu tenho que executar isso por meio de uma função, onde passo a lista e as chaves de classificação. Por exemplo def multikeysort(dict_list, sortkeys):,.

Como a linha lambda pode ser usada para classificar a lista, para um número arbitrário de chaves que são passadas para a função multikeysort, e levar em consideração que as chaves de classificação podem ter qualquer número de chaves e aquelas que precisam de classificações invertidas serão identificadas com um '-' antes disso?

simi
fonte

Respostas:

72

Essa resposta funciona para qualquer tipo de coluna no dicionário - a coluna negada não precisa ser um número.

def multikeysort(items, columns):
    from operator import itemgetter
    comparers = [((itemgetter(col[1:].strip()), -1) if col.startswith('-') else
                  (itemgetter(col.strip()), 1)) for col in columns]
    def comparer(left, right):
        for fn, mult in comparers:
            result = cmp(fn(left), fn(right))
            if result:
                return mult * result
        else:
            return 0
    return sorted(items, cmp=comparer)

Você pode chamá-lo assim:

b = [{u'TOT_PTS_Misc': u'Utley, Alex', u'Total_Points': 96.0},
     {u'TOT_PTS_Misc': u'Russo, Brandon', u'Total_Points': 96.0},
     {u'TOT_PTS_Misc': u'Chappell, Justin', u'Total_Points': 96.0},
     {u'TOT_PTS_Misc': u'Foster, Toney', u'Total_Points': 80.0},
     {u'TOT_PTS_Misc': u'Lawson, Roman', u'Total_Points': 80.0},
     {u'TOT_PTS_Misc': u'Lempke, Sam', u'Total_Points': 80.0},
     {u'TOT_PTS_Misc': u'Gnezda, Alex', u'Total_Points': 78.0},
     {u'TOT_PTS_Misc': u'Kirks, Damien', u'Total_Points': 78.0},
     {u'TOT_PTS_Misc': u'Worden, Tom', u'Total_Points': 78.0},
     {u'TOT_PTS_Misc': u'Korecz, Mike', u'Total_Points': 78.0},
     {u'TOT_PTS_Misc': u'Swartz, Brian', u'Total_Points': 66.0},
     {u'TOT_PTS_Misc': u'Burgess, Randy', u'Total_Points': 66.0},
     {u'TOT_PTS_Misc': u'Smugala, Ryan', u'Total_Points': 66.0},
     {u'TOT_PTS_Misc': u'Harmon, Gary', u'Total_Points': 66.0},
     {u'TOT_PTS_Misc': u'Blasinsky, Scott', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Carter III, Laymon', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Coleman, Johnathan', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Venditti, Nick', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Blackwell, Devon', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Kovach, Alex', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Bolden, Antonio', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Smith, Ryan', u'Total_Points': 60.0}]

a = multikeysort(b, ['-Total_Points', 'TOT_PTS_Misc'])
for item in a:
    print item

Experimente com qualquer coluna negada. Você verá a ordem de classificação inversa.

Próximo: mude para que não use classe extra ....


17/01/2016

Inspirando-me nesta resposta Qual é a melhor maneira de obter o primeiro item de uma correspondência iterável com uma condição? , Encurtei o código:

from operator import itemgetter as i

def multikeysort(items, columns):
    comparers = [
        ((i(col[1:].strip()), -1) if col.startswith('-') else (i(col.strip()), 1))
        for col in columns
    ]
    def comparer(left, right):
        comparer_iter = (
            cmp(fn(left), fn(right)) * mult
            for fn, mult in comparers
        )
        return next((result for result in comparer_iter if result), 0)
    return sorted(items, cmp=comparer)

Caso você goste do seu código conciso.


Mais tarde, 17/01/2016

Isso funciona com python3 (que eliminou o cmpargumento para sort):

from operator import itemgetter as i
from functools import cmp_to_key

def cmp(x, y):
    """
    Replacement for built-in function cmp that was removed in Python 3

    Compare the two objects x and y and return an integer according to
    the outcome. The return value is negative if x < y, zero if x == y
    and strictly positive if x > y.

    https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function
    """

    return (x > y) - (x < y)

def multikeysort(items, columns):
    comparers = [
        ((i(col[1:].strip()), -1) if col.startswith('-') else (i(col.strip()), 1))
        for col in columns
    ]
    def comparer(left, right):
        comparer_iter = (
            cmp(fn(left), fn(right)) * mult
            for fn, mult in comparers
        )
        return next((result for result in comparer_iter if result), 0)
    return sorted(items, key=cmp_to_key(comparer))

Inspirado por esta resposta, Como devo fazer a classificação personalizada no Python 3?

hughdbrown
fonte
Isso funciona melhor porque posso usar o inverso em quaisquer chaves ou colunas. Obrigado!
simi
Então isso funciona bem. Eu chamo minha função com a lista e string como parâmetros. Eu divido a string primeiro e depois chamo o multikeysort com a lista e a lista de chaves da string dividida. Não importa qual item na string tem o '-' no início do nome da coluna, porque funcionará com qualquer item ou todos os itens. Impressionante. Obrigado.
simi
2
Obrigado, você salvou meu dia!
Sander van Leeuwen de
4
cmp()não está disponível para Python3, então tive que defini-lo sozinho, conforme mencionado aqui: stackoverflow.com/a/22490617/398514
pferate
8
@hughdbrown: Você removeu a cmppalavra - chave, mas a cmp()função ainda é usada 4 linhas acima. Tentei com 3.2, 3.3, 3.4 e 3.5, todos falharam na chamada de função, porque cmp()não está definido. O terceiro marcador aqui ( docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons ) menciona o tratamento cmp()como desaparecido.
Refere-se a
53

Este artigo apresenta um bom resumo das várias técnicas para fazer isso. Se seus requisitos são mais simples do que "multi-chave bidirecional completa", dê uma olhada. Está claro que a resposta aceita e a postagem do blog que acabei de mencionar influenciaram um ao outro de alguma forma, embora eu não saiba em qual ordem.

No caso de o link morrer, aqui está uma sinopse muito rápida de exemplos não cobertos acima:

mylist = sorted(mylist, key=itemgetter('name', 'age'))
mylist = sorted(mylist, key=lambda k: (k['name'].lower(), k['age']))
mylist = sorted(mylist, key=lambda k: (k['name'].lower(), -k['age']))
Scott Stafford
fonte
Pelo que posso dizer, stygianvision usa meu código e não dá crédito. Google pararesult = cmp(fn(left), fn(right))
hughdbrown
4
Obrigado pela sinopse, Link está realmente morto agora. :)
Amyth
47

Eu sei que esta é uma pergunta bastante antiga, mas nenhuma das respostas menciona que o Python garante uma ordem de classificação estável para suas rotinas de classificação como list.sort()e sorted(), o que significa que os itens comparados iguais mantêm sua ordem original.

Isso significa que o equivalente a ORDER BY name ASC, age DESC(usando a notação SQL) para uma lista de dicionários pode ser feito assim:

items.sort(key=operator.itemgetter('age'), reverse=True)
items.sort(key=operator.itemgetter('name'))

Observe como os itens são classificados primeiro pelo atributo "menor" age(decrescente) e, em seguida, pelo atributo "principal" name, levando à ordem final correta.

A reversão / inversão funciona para todos os tipos que podem ser solicitados, não apenas para números que você pode negar colocando um sinal de menos na frente.

E por causa do algoritmo Timsort usado em (pelo menos) CPython, isso é bastante rápido na prática.

wouter bolsterlee
fonte
2
muito agradável. para conjuntos de dados moderados em que classificar o conjunto várias vezes não importa, isso é muito legal! Como você destacou, é necessário reverter a classificação python em comparação com a classificação sql. Obrigado.
Greg
A segunda classificação quebrará o resultado da primeira. Engraçado que nenhum dos votantes notou.
vulcão
9
engraçado que você não tenha notado que o critério de classificação principal vai por último, como mostrado no meu exemplo, e explicitamente mencionado no outro comentário para deixar bem claro caso você não tenha notado.
Wouter Bolsterlee
24
def sortkeypicker(keynames):
    negate = set()
    for i, k in enumerate(keynames):
        if k[:1] == '-':
            keynames[i] = k[1:]
            negate.add(k[1:])
    def getit(adict):
       composite = [adict[k] for k in keynames]
       for i, (k, v) in enumerate(zip(keynames, composite)):
           if k in negate:
               composite[i] = -v
       return composite
    return getit

a = sorted(b, key=sortkeypicker(['-Total_Points', 'TOT_PTS_Misc']))
Alex Martelli
fonte
Uau! Isso é incrível. Funciona muito bem. Sou tão novato que sinto que nunca chegarei ao ponto de saber de tudo isso. Isso também foi rápido. Muito obrigado.
simi
Mas, e se as chaves enviadas para o sortkeypicker forem uma string, como '-Total_Points, TOT_PTS_Misc'?
simi
1
Em seguida, você pode dividir a string em uma matriz chamandosome_string.split(",")
Jason Creighton
Obrigado. Percebi que posso fazer split da string, depois que já comentei. DOH!
simi
2
Mas e se você negar o valor da string em vez do valor numérico? Eu não acho que isso funcionaria.
Nick Perkins
5

Eu uso o seguinte para classificar uma matriz 2d em várias colunas

def k(a,b):
    def _k(item):
        return (item[a],item[b])
    return _k

Isso poderia ser estendido para funcionar em um número arbitrário de itens. Tendo a pensar que encontrar um padrão de acesso melhor para suas chaves classificáveis ​​é melhor do que escrever um comparador sofisticado.

>>> data = [[0,1,2,3,4],[0,2,3,4,5],[1,0,2,3,4]]
>>> sorted(data, key=k(0,1))
[[0, 1, 2, 3, 4], [0, 2, 3, 4, 5], [1, 0, 2, 3, 4]]
>>> sorted(data, key=k(1,0))
[[1, 0, 2, 3, 4], [0, 1, 2, 3, 4], [0, 2, 3, 4, 5]]
>>> sorted(a, key=k(2,0))
[[0, 1, 2, 3, 4], [1, 0, 2, 3, 4], [0, 2, 3, 4, 5]]
múmra
fonte
2

Tive um problema semelhante hoje - tive que classificar os itens do dicionário em valores numéricos decrescentes e em valores de string crescentes. Para resolver o problema das direções conflitantes, neguei os valores inteiros.

Aqui está uma variante da minha solução - conforme aplicável ao OP

sorted(b, key=lambda e: (-e['Total_Points'], e['TOT_PTS_Misc']))

Muito simples - e funciona perfeitamente

[{'TOT_PTS_Misc': 'Chappell, Justin', 'Total_Points': 96.0},
 {'TOT_PTS_Misc': 'Russo, Brandon', 'Total_Points': 96.0},
 {'TOT_PTS_Misc': 'Utley, Alex', 'Total_Points': 96.0},
 {'TOT_PTS_Misc': 'Foster, Toney', 'Total_Points': 80.0},
 {'TOT_PTS_Misc': 'Lawson, Roman', 'Total_Points': 80.0},
 {'TOT_PTS_Misc': 'Lempke, Sam', 'Total_Points': 80.0},
 {'TOT_PTS_Misc': 'Gnezda, Alex', 'Total_Points': 78.0},
 {'TOT_PTS_Misc': 'Kirks, Damien', 'Total_Points': 78.0},
 {'TOT_PTS_Misc': 'Korecz, Mike', 'Total_Points': 78.0},
 {'TOT_PTS_Misc': 'Worden, Tom', 'Total_Points': 78.0},
 {'TOT_PTS_Misc': 'Burgess, Randy', 'Total_Points': 66.0},
 {'TOT_PTS_Misc': 'Harmon, Gary', 'Total_Points': 66.0},
 {'TOT_PTS_Misc': 'Smugala, Ryan', 'Total_Points': 66.0},
 {'TOT_PTS_Misc': 'Swartz, Brian', 'Total_Points': 66.0},
 {'TOT_PTS_Misc': 'Blackwell, Devon', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Blasinsky, Scott', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Bolden, Antonio', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Carter III, Laymon', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Coleman, Johnathan', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Kovach, Alex', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Smith, Ryan', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Venditti, Nick', 'Total_Points': 60.0}]
vulcão
fonte
0
from operator import itemgetter
from functools import partial

def _neg_itemgetter(key, d):
    return -d[key]

def key_getter(key_expr):
    keys = key_expr.split(",")
    getters = []
    for k in keys:
        k = k.strip()
        if k.startswith("-"):
           getters.append(partial(_neg_itemgetter, k[1:]))
        else:
           getters.append(itemgetter(k))

    def keyfunc(dct):
        return [kg(dct) for kg in getters]

    return keyfunc

def multikeysort(dict_list, sortkeys):
    return sorted(dict_list, key = key_getter(sortkeys)

Demonstração:

>>> multikeysort([{u'TOT_PTS_Misc': u'Utley, Alex', u'Total_Points': 60.0},
                 {u'TOT_PTS_Misc': u'Russo, Brandon', u'Total_Points': 96.0}, 
                 {u'TOT_PTS_Misc': u'Chappell, Justin', u'Total_Points': 96.0}],
                "-Total_Points,TOT_PTS_Misc")
[{u'Total_Points': 96.0, u'TOT_PTS_Misc': u'Chappell, Justin'}, 
 {u'Total_Points': 96.0, u'TOT_PTS_Misc': u'Russo, Brandon'}, 
 {u'Total_Points': 60.0, u'TOT_PTS_Misc': u'Utley, Alex'}]

A análise é um pouco frágil, mas pelo menos permite um número variável de espaços entre as chaves.

Torsten Marek
fonte
Mas, quando tenho o segundo item na string com um '-', isso me dá um tipo de operando incorreto para erro unário.
simi
Você não pode pegar o negativo de uma string.
Torsten Marek
Sim, eu sei, mas é assim que os parâmetros são passados. Mesmo se eu fizer uma divisão, um ou outro começará com '-'. Acho que as chaves de classificação precisam ser divididas antes de chamar key_getter, dessa forma, cada item na lista de chaves verificará o primeiro caractere. Estou no caminho certo?
simi
0

Como você já está confortável com lambda, aqui está uma solução menos prolixa.

>>> def itemgetter(*names):
    return lambda mapping: tuple(-mapping[name[1:]] if name.startswith('-') else mapping[name] for name in names)

>>> itemgetter('a', '-b')({'a': 1, 'b': 2})
(1, -2)
A. Coady
fonte
Isso não funciona. Eu tenho: values ​​= ['-Total_Points', 'TOT_PTS_Misc'] then b como a lista de dicts Quando eu chamo g = itemgetter (values) (b) eu obtenho AttributeError: 'list' objeto não tem atributo 'startswith'
simi
Leva um número variável de nomes, não uma lista de nomes. Chame-o assim: itemgetter (* valores). Dê uma olhada em operator.itemgetter integrado semelhante para outro exemplo.
A. Coady