Lecture Notes

Collected lectures notes in computational biology

Un point sur les variables et espaces de noms

Les variables en Python sont des références nommées

En Python, tout est objet. Comme Python est un langage fortement typé, chaque objet à un type et ce type ne peut pas changer. Les types de bases sont les entiers int et le nombres à virgule flottante float, les chaînes de caractères str, les complexes complex. La bibliothèque standard contient aussi plusieurs types de conteneurs: listes list, n-uplets tuples, dictionnaires dict, ensembles set...

In [3]:
# En Python les variables sont des références nommées à des objets. 
# Par exemple:
a = 3
# Créer un objet de type int et lui associe le nom 'a'
type(a) #=> int
# Cet objet se trouve dans la mémoire de l'ordinateur
# à un endroit que l'on peut obtenir avec `id`:
id(a) #=> La position de l'objet nommé a dans la mémoire de l'ordinateur.
Out[3]:
94875962855936
In [4]:
# Maintenant si on fait:
a = "coucou"
# Le type de a est maintenant `str`.
# Mais l'objet de type int que nous avions précédemment n'a pas changé de type.
# Il n'est juste plus référencé, il est maintenant impossible d'y accéder.
# Un objet qui n'a plus de référence est automatiquement retiré de la mémoire 
# par le mécanisme de "ramassage miettes" (garbage collection) de Python. 
# Nous avons donc juste créé un objet de type `str` et lui avons associé le nom `a`.

Objets immuables et modifiables, Attention aux pièges !

On l'a vu en Python tout est objet. Il existe une importante distinction entre deux grandes catégories d'objet en Python. Les objets que l'on peut modifier, et ceux que l'on ne peut pas modifier (immuables). Un objet immuable, une fois créé ne peut plus changer. Voici un tableau récapitulatif.

Immuables Modifiables
Entiers int Listes list
Flottants float Dictionnaires dict
Complexes complex Ensembles set
Booléens bool
Chaînes de caractères string
n-uplet tuple
Intervalles range
Ensemble immuables frozenset
In [37]:
# Ainsi si vous faites:
x = 3 
x += 2 
# vous ne changez pas la valeur de l'objet référencé par `x`, vous créez un nouvel objet 
# de type `int` et de valeur `5`.

# Par contre:
liste = [1,2,3,4,5]
liste[1] = 200
# Change effectivement le contenu de l'objet `liste`. Maintenant, la deuxième case 
# de la liste fait référence à un autre objet (`int`) de valeur 200. 
# C'est impossible à faire avec un n-uplet (`tuple`).

In [19]:
# Un objet peut avoir plusieurs références:
a = [1,2,3]
b = a
print(id(a), id(b))

# On peut utiliser l'opérateur `is` pour tester si deux variables font 
# référence au même objet
print(a is b)

# Ce qui nous amène au premier piège:
# Si on modifie le premier cela modifie le second.
a.append(4)
b.append(5)
print(a,b)
140447784022984 140447784022984
True
[1, 2, 3, 4, 5] [1, 2, 3, 4, 5]
In [15]:
# il y a donc une différence entre faire:
liste = [1,2,3]
print(liste, id(liste))
liste = [1,2,3,4] # Créer un nouvel objet de type `list` et y associe le nom `liste`
print(liste, id(liste))
liste.append(5) # Modifie l'objet liste "en place"
print(liste, id(liste))
[1, 2, 3] 140447783846984
[1, 2, 3, 4] 140447783832520
[1, 2, 3, 4, 5] 140447783832520
In [14]:
# Le second piège provient du fait que c'est le conteneur qui est immuable, non son contenu.
nuplet = (1,2,3, [1,2])
print(id(nuplet[3]))

# Ainsi, s'il est interdit de changer la référence qui est contenue dans un tuple...
# nuplet[3] = [1,2,3] #=> will rise a TypeError exception

# ... il est possible de modifier l'objet lui même si celui-ci n'est pas immuable.
nuplet[3].append(3)
print(id(nuplet[3])) # n'a pas changé
nuplet
140447783900232
140447783900232
Out[14]:
(1, 2, 3, [1, 2, 3])

Ces deux pièges peuvent être évités si on utilise des objets immuables: on est sûr qu'un objet une fois créé ne peut pas être modifié ailleurs dans le code. Si on ne change pas la référence le contenu est identique. Voilà pourquoi il faut toujours privilégier les objets immuables si on le peut. (En plus les objets immuables sont en général plus rapides car optimisés par l'interpréteur).

Les espaces de noms

Les espaces de noms sont une des fonctionnalités les plus importantes de Python et participent grandement à sa simplicité. Voici une petite introduction, plus d'informations sont disponibles dans la documentation de Python sur les namespace.

Quand vous faites référence à une variable, Python cherche la référence à l'objet correspondant dans l'espace de nom adéquat. Un espace de nom est donc simplement une collection de noms associés à des références d'objet. C'est ce qui fait la correspondance entre le nom de la variable et ce à quoi elle se réfère.

Il existe de nombreux espaces de noms:

  • L'espace des noms réservé de python (built-in): créé quand on lance l'interpréteur.
  • L'espace de nom global: créé quand on lance l'interpréteur.
  • L'espace de nom d'un module: créé la première fois qu'un module est importé avec import
  • L'espace de nom d'une fonction: créé quand la fonction est appelée et détruit quand elle retourne ça valeur (return).
  • L'espace de nom d'un objet: créé quand l'objet est instancié et détruit avec l'objet
  • L'espace de nom d'une classe: créé quand la classe est définie

Quand on fait référence à une méthode ou un attribut d'un objet objet.méthode() ou objet.attribut, python cherche dans l'espace de nom associé à cet objet (puis éventuellement dans celui de la classe).

Quand on fait référence à une méthode ou un attribut d'un module numpy.sum(), matplotlib.pyplot.plot(), ptyhon cherche dans l'espace de nom associé à ce module.

Quand on donne juste le no d'une variable Python cherche toujours dans l'espace de nom le plus local possible s'il existe, dans l'ordre: fonction < objet < classe < global < réservé.

Toute assignation de variable (avec =) se fait dans l'espace de nom le plus local. On parle de portée (locale) des variables.Il est possible de changer ce comportement si on utilise les mots clés global (pour assigner dans l'espace de nom global) ou nonlocal (pour assigner dans l'espace de nom directement supérieur). Je vous déconseille fortement d'utiliser ces mots clés à moins de savoir ce que vous faites et d'avoir une très bonne raison de le faire: cela complique inutilement le code et son développement.

In [38]:
# Exercice: Prédire la sortie des différents print. Expliquez pourquoi.
# Executer la fonction et comparer avec votre prédiction. 
def test_portee_variables():
    def do_local():
        variable = "Cette valeur est locale"
        print('Dans la fonction do_local:', variable)

    def do_nonlocal():
        nonlocal variable
        variable = "Cette valeur est non locale"

    def do_global():
        global variable
        variable = "Cette valeur est globale"

    variable = "valeur"
    do_local()
    print("Après une assignation locale:", variable)
    do_nonlocal()
    print("Après une assignation non locale:", variable)
    do_global()
    print("Après une assignation global:", variable)
In [27]:
test_portee_variables()
print("Dans l'espace de nom global:", variable)
In [36]:
# Les pièges précédent s'appliquent aussi !
# Que pouvez vous dire de ce code ? 

liste = [1,2,3]
print(id(liste))
def penser_global():
    print(id(liste))
    liste.append(3)
def agir_local():
    liste = [1,2,3]
    print(id(liste))
    liste.append(4)
penser_global()
agir_local()
print(liste)
140447310203464
140447310203464
140447310185864
[1, 2, 3, 3]

Piège: les arguments par défaut des fonctions

Les arguments par défaut d'une fonction font partie de l'espace de nom de l'objet de type fonction et non de l'espace de nom local créer à l'appel de la fonction. Voilà pourquoi il vaut mieux éviter de mettre des objets modifiables comme arguments par défaut.

In [51]:
def fonction_inutile(a,b={}):
    c = {a:a} #=> `c` est local à un appel de la fonction_inutile.
    b[a] = a #=> `b` est local à l'objet fonction_inutile. 
    return c,b

fonction_inutile(3)
fonction_inutile(4)
fonction_inutile(5)
Out[51]:
({5: 5}, {3: 3, 4: 4, 5: 5})

Un dernier example illustré

import numpy as np

def fonction_utile(alpha='argument_par_defaut'):
    """Multiplie par 3"""
    beta = 3 
    return alpha * beta  

class Human():
    """Un primate capable de coder en python"""
    espece = 'Homo sapiens'
    def __init__(self, prenom="Jean", nom="Sépasplus"):
        """Méthode constructeur des objets Humans"""
        self.nom = nom
        self.prenom = prenom

    def dire_bonjour(self):
        """Dit bonjour à l'instance"""
        print("Bonjour {}".format(self.prenom))

a = Human(nom='Rie', prenom='Théo')
b = Human(nom='Cover', prenom='Harry')

La fermeture des espaces de nom (Avancé)

Quand on définie des fonctions emboitées, la fonction la plus locale garde une référence à l'espace de nom des fonctions emboitantes. Ce mécanisme est appelé la fermeture de l'espace de nom (closure en anglais) et est très important en programation fonctionnelle car cela permet d'écrire des fonctions qui renvoient des fonctions:

In [40]:
def creer_additioneur(combien):
    """Retourne une fonction qui additionne """
    # Combien est local à creer_additioneur.
    def additioneur(valeur):
        # mais peut être utilisé dans la fonction sous-jacente.
        return valeur+combien
    return additioneur

ajoute_un = creer_additioneur(1)
# l'espace de nom local créé par l'appel à `creer_additioneur(1)` n'est pas détruit,
# il subsiste par fermeture dans `ajoute_un`.
ajoute_un(3)
Out[40]:
4

Pourquoi Python est lent ? L'idée centrale derrière Numpy

On vous a sûrement dit que python était lent par rapport aux langages compilés (par exemple le C). C'est vrai mais pourquoi est-ce le cas ?

L'expressivité et la souplesse de Python ont un coût non négligeable [1]. Quand on additionne deux entiers en python, il faut: constater que le premier objet est un entier, utiliser sa méthode d'addition, constater que le second objet est un entier, créer un nouvel objet "entier" de sortie quelque part dans la mémoire, réaliser l'opération d'addition, et remplir le nouvel objet.

Cela nécessite de nombreuses opérations, alors que les langages compilés et statiquement typés ont assez d'information à priori sur la nature des éléments pour pouvoir faire l'addition directement sur les cases mémoire (en beaucoup moins d'opération pour le processeur).

Mais Pyhton possède une fonctionnalité très importante pour remédier à ce problème, dont l'interface est fournie par numpy. Il est possible de manipuler directement des morceaux continus de mémoire à l'aide de Python grace à une interface de bas niveau appellée le Buffer protocol [2] qui a été développé pour numpy.

Voilà pourquoi les np.array sont si rapides pour les opérations élément par élément. Ils correspondent à un bloc contigu dans la mémoire, et les architectures actuelles (des controlleurs mémoires aux processeurs) sont optimisés pour réaliser efficacement ces opérations [3]. Par contre ils sont en général moins rapide que des listes si on change leur taille souvent, et ils n'ont pas beaucoup d'avantage s'ils contiennent des (références) à des objets.

Au final, c'est un compromis à trouver entre temps de développement et temps d'exécution. Le code python bien vectorisé avec numpy est facile à écrire et assez rapide pour la majeure partie des applications en biologie.

Références:

  1. Why Python is slow de l'astronome et éminent pythoniste Jake VanderPlas.
  2. La structure continue dans la mémoire des arrays numpy permet d'accélérer les calculs et de s'affranchir de pas mal de lenteurs en python.
  3. Python is the fastest growing programming language due to a feature you've never heard of

Quel est l'avantage de numpy en terme de vitesse ?

Ne me prenez pas au mot, essayez !

La commande magique %%timeit permet d'executer le code d'une cellule de nombreuses fois et de calculer son temps moyen d'exécution. Comparez les deux exemples suivants:

In [46]:
import numpy as np
In [47]:
a = list(range(500000)) # Une liste python
c = np.arange(500000) # Un array numpy
In [48]:
%%timeit
# En utilisant des listes
b = [i*2 for i in a]
21.7 ms ± 35.1 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [49]:
%%timeit
# En utilisant des np.array
d = c*2
258 µs ± 5.16 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)