itertools – Sam & Max http://sametmax.com Du code, du cul Wed, 30 Oct 2019 15:34:04 +0000 en-US hourly 1 https://wordpress.org/?v=4.9.7 32490438 Un tools d’itertour, ou l’inverse http://sametmax.com/un-tools-ditertour-ou-linverse/ http://sametmax.com/un-tools-ditertour-ou-linverse/#comments Sat, 29 Oct 2016 20:26:22 +0000 http://sametmax.com/?p=20922 itertools. ]]> Ah, ça faisait longtemps que je ne vous avais pas fait un petit article bien long. Ca manquait d’opportunité de partager de la musique. Allez hop !

Python est un langage avec un “for” penchant pour l’itération si je puis dire. Et ce n’est que justice qu’il ait donc un module dédié pour les opérations d’itération : itertools.

itertools est un peu impénétrable pour quelqu’un qui n’a pas l’habitude, alors je vais vous faire un petit tour du propriétaire.

Principes de base

Itertools a pour but de manipuler les itérables, et si vous voulez en savoir plus sur le concept d’itérabilité, je vous renvoie à l’article sur les trucmuchables. Pour faire court, les itérables sont tout ce sur quoi on peut appliquer une boucle for.

Le problème, c’est que tous les itérables ne se comportent pas de la même façon. En effet, certains ont une taille définie:

>>> len("abcd")
4
>>> len(range(3))
3

D’autres non:

>>> len(open('/etc/fstab'))
Traceback (most recent call last):
  File "", line 1, in 
    len(open('/etc/fstab'))
TypeError: object of type '_io.TextIOWrapper' has no len()

Certains, se terminent:

>>> a = list(open('/etc/fstab'))
>>>

D’autres sont infinis:

>>> def infinite_generator():
...     while True:
...         yield 1
...
>>> list(infinite_generator) # bloque la VM jusqu'à crasher sur un MemoryError

Du coup, si vous voulez faire des opérations qui marchent sur tous les itérables, il faut écrire des algos spéciaux dit “paresseux” (en anglais, “lazy”), c’est à dire qui font le travail minimum. Ils se lancent à la dernière minute et ne lisent rien de plus que nécessaire.

Et c’est ce que contient itertools. Les fonctions de ce module marchent sur les fichiers, les générateurs, les sockets réseaux comme les listes, les tuples et les chaînes de caractères.

chain

Combine plusieurs itérables en un seul:

>>> from itertools import chain
>>> generator = chain("abc", range(3), [True, False, None])
>>> generator

>>> list(generator)
['a', 'b', 'c', 0, 1, 2, True, False, None]

Très utile pour boucler sur des itérables hétérogènes comme si c’était une seule et même structure. Chaîner des fichiers, des générateurs, etc.

islice

Comme la syntaxe de slicing [::]:

>>> carres_de_nombres_pairs = [x * x  for x in range(100) if x % 2 == 0]
>>> carres_de_nombres_pairs[3:11]
[36, 64, 100, 144, 196, 256, 324, 400]

Mais marche sur tous les itérables, même sur les générateurs:

>>> carres_de_nombres_pairs = (x * x  for x in range(100) if x % 2 == 0)
>>> carres_de_nombres_pairs
 at 0x7f2eab171a98>
>>> from itertools import islice
>>> generator = islice(carres_de_nombres_pairs, 3, 11)
>>> generator

>>> list(generator)
[36, 64, 100, 144, 196, 256, 324, 400]

cycle

Boucler de manière infinie sur un itérable. Quand on arrive à la fin, on revient au début.

>>> generator = cycle('abc')
>>> generator

>>> next(generator)
'a'
>>> next(generator)
'b'
>>> next(generator)
'c'
>>> next(generator)
'a'

C’est très pratique, mais assez dangereux donc faites des tests avant de l’utiliser. En effet si l’itérable génère les données à la volée, elles sont stockées dans un buffer, donc ça charge tout en mémoire. De plus, c’est une boucle infinie. Ne castez pas ça avec un tuple ou une liste :)

repeat

Prend n’importe quel élément, et le retourne encore et encore.

>>> generator = repeat("plop") # répète infiniment
>>> next(generator)
'plop'
>>> next(generator)
'plop'
>>> next(generator)
'plop'
>>> generator = repeat("plop", 2) # répète 2 fois
>>> for x in generator:
...     print(x)
...
plop
plop

On ne peut pas passer un callable, mais iter() le fait déjà. La signature normale de cette dernière prend un iterable, mais si on lui passe deux paramètres, un callable et un sentinel, on obtient un générateur qui va appeler le callable jusqu’à ce qu’il retourne une valeur égale au sentinel.

Exemple, vous voulez lire un fichier 50 caractères à la fois. On passe une fonction qui retourne les 50 caractères suivants (le callable) et une chaîne de caractères vide (le sentinel):

>>> f = open('/etc/fstab')
>>>
>>> def read_50_chars():
...     return f.read(50)
...
>>> generator = iter(read_50_chars, '') # lit 50 caractères à la fois jusqu'à tomber sur ''
>>> generator

>>> next(generator)
'# /etc/fstab: static file system information.\n#\n# '
>>> next(generator)
"Use 'blkid' to print the universally unique identi"
>>> next(generator)
'fier for a\n# device; this may be used with UUID= a'

iter() est un built-in, pas dans itertools, mais ça aurait été con de pas en parler.

zip_longest

Comme zip(), mais au lieu de s’arrêter quand le premier itérable est fini:

>>> list(zip('abc', range(5)))
[('a', 0), ('b', 1), ('c', 2)]

On remplit les valeurs manquantes:

>>> list(zip_longest('abc', range(5)))
[('a', 0), ('b', 1), ('c', 2), (None, 3), (None, 4)]
>>> list(zip_longest('abc', range(5), fillvalue="Tada !"))
[('a', 0), ('b', 1), ('c', 2), ('Tada !', 3), ('Tada !', 4)]

starmap

Comme map(), mais applique l’opérateur splat sur les arguments avant.

Si map() ressemble (en mieux) à:

def map_en_moins_bien(callable, iterable):
    for x in iterable:
       yield callable(x)

starmap() fait:

def map_en_moins_bien(callable, iterable):
    for x in iterable:
       yield callable(*x)

Exemple:

>>> nombres = [(1, 2), (3, 4), (5, 6)]
>>> def ajouter(a, b):
...     return a + b
...
>>> from itertools import starmap
>>> list(starmap(ajouter, nombres))
[3, 7, 11]

Ces fonctions sont moins utilisées à cause des listes en intension. Mais il y a des trucs rigolos comme:

>>> list(starmap(print, zip(range(3), map(int, "123"))))
0 1
1 2
2 3
[None, None, None]

Ouais, vous allez pas utiliser ça tous les jours :)

filterfalse

Le contraire de filter(), garde les résultats négatifs au lieu des résultats positifs:

>>> list(filter(est_pair, range(10)))
[0, 2, 4, 6, 8]
>>> from itertools import filterfalse
>>> list(filterfalse(est_pair, range(10)))
[1, 3, 5, 7, 9]

Comme précédemment, aujourd’hui on utilise peu ces fonctions car on a les listes en intension. Mais certaines astuces sont sympas comme:

>>> list(filter(bool, [True, False, 1, 0, 'foo', '']))
[True, 1, 'foo']
>>> list(filterfalse(bool, [True, False, 1, 0, 'foo', '']))
[False, 0, '']

groupby

Ah, groupby(), la fonction la moins facile à comprendre. D’abord, personne ne lit la doc, et la doc dit clairement qu’il faut trier les éléments de l’itérable AVANT d’appliquer groupby, sinon les résultats sont incohérents.

Ensuite, groupby() peut prendre un callback pour un usage avancé, et toutes les fonctions qui prennent un callback sont plus dures à comprendre. Mais pour que ça ait du sens, il faut que le tri préalable s’applique sur le même callback. Personne ne pige jamais ça.

Enfin, groupby() ne renvoie pas les éléments directement, mais des objets “groupers”, qui sont eux mêmes des générateurs. Tout est bien fait pour embrouiller le chaland, surtout pour une fonction qu’on utilise pas souvent.

Pour comprendre groupby(), le mieux est de commencer par l’entrée et la sortie. Vous avez un truc comme ça:

>>> noms = ['Zebulon', 'Léo', 'Alice', 'Bob', 'Anaïs', 'Adam', 'Bernardo', 'Io']

Et vous voulez les grouper par un critère, un peu comme un GROUP BY en SQL. Ca peut être n’importe quoi:

  • La valeur de la première lettre.
  • La position de la première voyelle.
  • Deux catégories: ceux qui contiennent des caractères non ASCII et les autres.

Prenons un exemple simple, et groupons les par le nombre de lettres qu’ils contiennent. Ca donnerait quelque chose comme ça:

[(2, ('Io',)),
 (3, ('Léo', 'Bob')),
 (4, ('Adam',)),
 (5, ('Alice', 'Anaïs')),
 (7, ('Zebulon',)),
 (8, ('Bernardo',))]

Pour obtenir ce résultat avec groupby(), il faut d’abord trier l’itérable (ici notre liste) par nombre de lettres:

>>> noms.sort(key=len)
>>> noms
['Io', 'Léo', 'Bob', 'Adam', 'Alice', 'Anaïs', 'Zebulon', 'Bernardo']

Si vous ne vous souvenez plus comment fonctionne le tri en Python, particulièrement le paramètre key, allez vite lire l’article dédié du lien précédent. En effet le mécanisme est le même pour groupby() donc il faut comprendre ce fonctionnement de toute manière.

Ensuite on applique groupby(), avec la même fonction key:

>>> noms = groupby(noms, key=len)
>>> noms

A ce stade, on a un générateur qu’on peut parcourir, mais il ne contient pas le résultat tel que je vous l’ai montré. A la place, il yield des paires (group, grouper):

>>> next(noms)
(2, )

Le premier élément, le groupe, est ce que votre fonction key retourne, c’est à dire ce sur quoi vous vouliez grouper. Ici la taille du mot.

En second, un objet grouper, qui est un générateur yieldant les mots qui correspondent à ce groupe.

Donc pour vraiment voir le contenu de tout ça, il faut se taper une double boucle imbriquée:

>>> from itertools import groupby
>>> for groupe, groupeur in groupby(noms, key=len):
...     print(groupe, ":")
...     for mot in groupeur:
...         print('-', mot)
2 :
- Io
3 :
- Léo
- Bob
4 :
- Adam
5 :
- Alice
- Anaïs
7 :
- Zebulon
8 :
- Bernardo

La raison de ce manque d’intuitivité, c’est que théoriquement groupby() peut travailler de manière paresseuse et donc les groupeurs sont des générateurs qui traitent l’arrivée des données au fur et à mesure. Mais c’est rare d’avoir des données lazy qui arrivent déjà pré-ordonnées. Ensuite ça suppose que vous voulez lire ces données au fur et à mesure, mais généralement le jeu de données groupées n’est pas suffisamment grand pour que ça soit un vrai problème et on caste directement le groupe en tuple ou en liste.

Dans la pratique donc, groupby() fait parti de ces fonctions un peu overkill, car un usage typique ne bénéficiera pas du côté paresseux. En tout cas ça ne m’est jamais arrivé.

dropwhile

dropwhile() prend un callable et un itérable. Le callable est appelé sur chaque élément, et tant qu’il retourne quelque chose de vrai, l’élément est ignoré. C’est un peu tordu car ça demande d’écrire sa fonction de filtre à l’inverse de ce qu’on veut.

Par exemple, si vous voulez commencer à lire un fichier à partir de la première ligne qui commence par un commentaire, il faut que votre fonction retourne True… si la ligne ligne ne commence PAS par un commentaire.

>>> def ne_commence_pas_par_un_commentaire(element):
...     return not element.startswith('#')
...
>>> generator = dropwhile(ne_commence_pas_par_un_commentaire, open('/etc/fstab'))
>>> generator

>>> next(generator)
'# /etc/fstab: static file system information.\n'

dropwhile veut dire “ignorer tant que”. Donc ici on ignore tant que la ligne ne commence pas par un commentaire.

takewhile

takewhile() est le contraire de dropwhile(), on prend les éléments tant qu’une condition est vraie, et on s’arrête dès que la condition n’est plus vraie. On les utilise souvent en combinaison d’ailleurs, pour slicer les itérables en utilisant des conditions plutôt que des indexes.

Comme pour dropwhile, il faut penser à l’envers. Vous voulez vous arrêter quand un nombre dépasse 10000 ? Alors il faut faire une fonction qui retourne True tant que le nombre ne dépasse PAS 10000. Jusqu’ici j’ai utilisé des fonctions traditionnelles, mais bien entendu, une lambda marche aussi:

>>> nombres = (x * x for x in range(1000000000000))
>>> from itertools import takewhile
>>> generator = takewhile(lambda x: x < 10000, nombres)
>>> next(generator)
0
>>> next(generator)
1
>>> next(generator)
4
>>> list(generator)[-1]
9801

count

count() est une relique du passé, qui fait ce que fait range() maintenant, mais en moins bien. Vous pouvez l’ignorer.

tee

tee() est une fonction très intéressante qui duplique votre itérable en autant de clones que vous le souhaitez. Pour ce faire, tee() garde en mémoire les derniers éléments générés, ce qui fait que si vous lisez les clones les uns après les autres, ça n’a aucun intérêt. Autant caster en liste et lire la liste plusieurs fois.

Non, l’interêt de tee() est si vous lisez les clones en parallèle. Car là, tee() ne garde en mémoire que les éléments qui n’ont pas été lus par le dernier clone utilisé.

Par exemple, ceci est inutile:

>>> from itertools import tee
>>> clone1, clone2, clone3 = tee((x for x in range(100)), 3)
>>> list(clone1)[:3]
[0, 1, 2]
>>> list(clone2)[:3]
[0, 1, 2]

Par contre ceci économise pas mal de mémoire:

>>> clone1, clone2, clone3 = tee((x for x in range(100)), 3)
>>> next(clone1)
0
>>> next(clone2)
0
>>> next(clone3)
0
>>> next(clone1)
1
>>> next(clone2)
1
>>> next(clone3) # la valeur 0 n'est plus en mémoire après ça
1

compress

compress() est une curiosité qui va surtout parler aux data-scientists et matheux, et d’une manière générale aux adeptes de numpy.

Cette fonction prend deux itérables, un avec des valeurs à filtrer, et un avec des valeurs qui indiquent s’il faut filtrer l’élément ou non:

>>> list(compress(['toto', 'tata', 'titi'], [True, False, True]))
['toto', 'titi']

Je n’ai pas trouvé de use case pour cette fonction dans ma vie de tous les jours. Si quelqu’un est inspiré en commentaire, je mettrai l’article à jour.

accumulate

accumulate() est un pur produit de la programmation fonctionnelle. C’est un peu comme un map(), mais au lieu de passer chaque élément au callable, on lui passe l’élément, et le résultat du dernier appel.

Par exemple, vous voulez multiplier tout une liste : [1, 2, 3, 4]

Vous allez faire:

(((1 * 2) * 3) * 4)

C’est typiquement pour ce genre de chose qu’on utiliserait la fonction reduce():

>>> reduce(lambda x, y: x * y, [1, 2, 3, 4])
24

accumulate() fait cela, mais en plus vous yield tous les résultats intermédiaires:

>>> list(accumulate([1, 2, 3, 4], lambda x, y: x * y))
[1, 2, 6, 24]

La signature est inversée par rapport à reduce(), c’est ballot. Dans d’autres langages vous trouverez ce genre de fonction sous le nom fold() ou scan(), mais c’est le même principe.

product

product() prend des itérables, et vous balance toutes les combinaisons des éléments de ces itérables. On s’en sert surtout pour éviter les boucles for imbriquées. Par exemple au lieu de faire:

>>> lettres = "abcd"
>>> chiffres = range(3)
>>> for lettre in lettres:
...     for chiffre in chiffres:
...         print(lettre, chiffre)
...
a 0
a 1
a 2
b 0
b 1
b 2
c 0
c 1
c 2
d 0
d 1
d 2

On va faire:

>>> from itertools import product
>>> for lettre, chiffre in product(lettres, chiffres):
...     print(lettre, chiffre)
...
a 0
a 1
a 2
b 0
b 1
b 2
c 0
c 1
c 2
d 0
d 1
d 2

combinations

Génère tous les combinaisons possibles des éléments d’un itérable:

>>> from itertools import combinations
>>> list(combinations('abcd', 2))
[('a', 'b'), ('a', 'c'), ('a', 'd'), ('b', 'c'), ('b', 'd'), ('c', 'd')]
>>> list(combinations('abcd', 3))
[('a', 'b', 'c'), ('a', 'b', 'd'), ('a', 'c', 'd'), ('b', 'c', 'd')]

combinations_with_replacement

Pareil, mais autorise les doublons:

>>> from itertools import combinations_with_replacement
>>> list(combinations_with_replacement('abcd', 3))
[('a', 'a', 'a'), ('a', 'a', 'b'), ('a', 'a', 'c'), ('a', 'a', 'd'), ('a', 'b', 'b'), ('a', 'b', 'c'), ('a', 'b', 'd'), ('a', 'c', 'c'), ('a', 'c', 'd'), ('a', 'd', 'd'), ('b', 'b', 'b'), ('b', 'b', 'c'), ('b', 'b', 'd'), ('b', 'c', 'c'), ('b', 'c', 'd'), ('b', 'd', 'd'), ('c', 'c', 'c'), ('c', 'c', 'd'), ('c', 'd', 'd'), ('d', 'd', 'd')]

permutations

Comme combinations, mais en générant aussi les mêmes combinaisons dans un ordre différent:

>>> list(permutations('abc', 3))
[('a', 'b', 'c'), ('a', 'c', 'b'), ('b', 'a', 'c'), ('b', 'c', 'a'), ('c', 'a', 'b'), ('c', 'b', 'a')]
]]>
http://sametmax.com/un-tools-ditertour-ou-linverse/feed/ 21 20922
itertools.product fait sauter des boucles imbriquées http://sametmax.com/itertools-product-fait-sauter-des-boucles-imbriquees/ http://sametmax.com/itertools-product-fait-sauter-des-boucles-imbriquees/#comments Tue, 03 Nov 2015 06:52:45 +0000 http://sametmax.com/?p=16986 product() depuis bel lurette, et je n'avais jamais réalisé son utilité. Des fois on a le truc sous les yeux, comme ça, et on voit rien. ]]> Je connais product() depuis bel lurette, et je n’avais jamais réalisé son utilité. Des fois on a le truc sous les yeux, comme ça, et on voit rien.

Vous savez, on veut parfois parcourir tous les trucs, et leur appliquer tous les machins. Ca donne une boucle imbriquée :

res = []
for truc in trucs:
    for machin in machins:
        res.append(bidule(machin, truc))

Un code parfaitement légitime, clair, lisible. Mais l’envie de faire une liste en intension est si forte !

C’est là que product() intervient, avec ses petits bras musclés :

from itertools import product
res  = [ bidule(machin, truc) for truc, machin in product(trucs, machins)]

Python va magiquement créer autant de tuples (machin, truc) qu’il y en a besoin.

Et parce que ça fait un mois que j’ai pas mis d’article, faut prendre tout de suite les bonnes habitudes :

Hommes nus assis sur un homme nus

for but in buts

]]>
http://sametmax.com/itertools-product-fait-sauter-des-boucles-imbriquees/feed/ 13 16986