<table><thead><tr><td>Enzyme</td><td>Site de restriction</td></tr></thead><tbody><tr><td>BbvAl</td><td>GAANNNTTC</td></tr></tbody></table>

### 17.4.3 Nettoyeur d'espaces

Le fichier `cigale_fourmi.txt`<fnref n="8" /> contient la célèbre fable de Jean de la Fontaine. Malheureusement, la personne qui l'a recopié a parfois mis plusieurs espaces au lieu d'un seul entre les mots.

Créez un script `cigale_fourmi.py` qui, grâce à une regex et à la fonction `sub()`, remplace plusieurs espaces par un seul dans le texte ci-dessus. Le nouveau texte, ainsi nettoyé, sera enregistré dans un fichier `cigale_fourmi_propre.txt`.

### 17.4.4 Liste des protéines humaines

Téléchargez le fichier `human-proteome.fasta`<fnref n="9" /> qui contient le protéome humain, c'est-à-dire les séquences de l'ensemble des protéines chez l'Homme. Ce fichier est au format FASTA.

On souhaite lister toutes ces protéines et les indexer avec un numéro croissant.

Créez un script `liste_proteome.py` qui :

* lit le fichier `human-proteome.fasta`;

* extrait, avec une regex, le numéro d'accession de la protéine de toutes les lignes de commentaires des séquences;

* affiche le mot protein, suivi d'un numéro qui s'incrémente, suivi du numéro d'accession.

Voici un exemple de sortie attendue :

```
protein 00001 095139
protein 00002 075438
protein 00003 Q8N4C6
[...]
protein 20371 Q8IZJ1
protein 20372 Q9UKP6
protein 20373 Q96HZ7
```

**Conseil**

* Des explications sur le format FASTA et des exemples de code sont fournis dans l'annexe A *Quelques formats de données en biologie*.

* La ligne de commentaire d'une séquence au format FASTA est de la forme

`>sp|095139|NDUB6_HUMAN NADH dehydrogenase [...]`

Elle débute toujours pas le caractère >. Le numéro d'accession `095139` se situe entre le premier et le second symbole | (symbole pipe). Attention, il faudra « échapper » ce symbole car il a une signification particulière dans une regex.

* Le numéro qui s'incrémente débutera à 1 et sera affiché sur 5 caractères, avec des 0 à sa gauche si nécessaires (formatage `[:05d]`).

### 17.4.5 Le défi du dé-HTMLiseur (exercice +++)

Le format HTML permet d'afficher des pages web dans un navigateur. Il s'agit d'un langage à balise qui fonctionne avec des balises ouvrantes `<balise>` et des balises fermantes `</balise>`.

Créez un script `dehtmliseur.py` qui lit le fichier `fichier_a_dehtmliser.html`<fnref n="10" /> au format HTML et qui renvoie à l'écran tout le texte de ce fichier sans les balises HTML.

Nous vous conseillons tout d'abord d'ouvrir le fichier HTML dans un éditeur de texte et de bien l'observer. N'hésitez pas à vous aider des sites mentionnés dans les ressources en ligne.

### 17.4.6 Nettoyeur de doublons (exercice +++)

Téléchargez le fichier `breves_doublons.txt`<fnref n="11" /> qui contient des mots répétés deux fois. Par exemple :

<footnote n="8">8. https://python.sdv.u-paris.fr/data-files/cigale_fourmi.txt</footnote>

<footnote n="9">9. https://python.sdv.u-paris.fr/data-files/human-proteome.fasta</footnote>

<footnote n="10">10. https://python.sdv.u-paris.fr/data-files/fichier_a_dehtmliser.html</footnote>

<footnote n="11">11. https://python.sdv.u-paris.fr/data-files/breves_doublons.txt</footnote>
Le cinéma est devenu parlant, la radio radio finira en images.
La sardine, c'est un petit petit poisson sans tête qui vit dans l'huile.
[...]

Écrivez un script ote_doublons.py qui lit le fichier breves_doublons.txt et qui supprime tous les doublons à l'aide d'une regex. Le script affichera le nouveau texte à l'écran.

Conseil

Utilisez la méthode .sub().
# CHAPITRE 18

## Jupyter et ses notebooks

Les *notebooks* Jupyter sont des cahiers électroniques qui, dans le même document, peuvent rassembler du texte, des images, des formules mathématiques, des tableaux, des graphiques et du code informatique exécutable. Ils sont manipulables interactivement dans un navigateur web.

Initialement développés pour les langages de programmation Julia, Python et R (d'où le nom *Jupyter*), les *notebooks* Jupyter supportent près de 40 langages différents.

La cellule est l'élément de base d'un *notebook* Jupyter. Elle peut contenir du texte formaté au format Markdown ou du code informatique qui pourra être exécuté.

Voici un exemple de *notebook* Jupyter (figure 18.1) :

Ce *notebook* est constitué de cinq cellules : deux avec du texte en Markdown (la première et la dernière) et trois avec du code Python (légèrement grisées).

### 18.1 Installation

Avec la distribution Miniconda, les *notebooks* Jupyter s'installent avec la commande :

```
$ conda install -c conda-forge -y jupyterlab
```

Pour être exact, la commande précédente installe un peu plus que les *notebooks* Jupyter, mais nous verrons cela par la suite.

### 18.2 JupyterLab

En 2018, le consortium Jupyter a lancé *JupyterLab*, qui est un environnement complet de programmation et d'analyse de données.

Pour obtenir cette interface, lancez la commande suivante depuis un shell :

```
$ jupyter lab
```

Une nouvelle page devrait s'ouvrir dans votre navigateur web et vous devriez obtenir une interface similaire à la figure 18.2, avec à gauche un navigateur de fichiers et à droite le « *Launcher* », qui permet de créer un nouveau *notebook* Jupyter, de lancer un terminal ou d'éditer un fichier texte, un fichier Mardown, un script Python...

L'interface proposée par JupyterLab est très riche. On peut y organiser un *notebook* Jupyter, un éditeur de fichier texte, un terminal... Les possibilités sont nombreuses et nous vous invitons à explorer cette interface par vous-même.
<figure><img src="image_32.png" /><figcaption>FIGURE 18.1 – Exemple de *notebook Jupyter*. Les chiffres entourés désignent les différentes cellules.</figcaption></figure>

## 18.3 Création d'un notebook

Pour créer un *notebook*, cliquez sur le bouton Python 3 situé dans la rubrique Notebook dans le Launcher (figure 18.3).

Le *notebook* fraîchement créé ne contient qu'une cellule vide.

La première chose à faire est de donner un nom à votre *notebook*. Pour cela, cliquer avec le bouton droit de la souris sur *Untitled.ipynb*, en haut du *notebook*. Si le nom de votre *notebook* est *test.ipynb*, alors le fichier *test.ipynb* sera créé dans le répertoire depuis lequel vous avez lancé JupyterLab.

**Remarque**

L'extension `.ipynb` est l'extension de fichier des *notebooks* Jupyter.

Vous pouvez entrer des instructions Python dans la première cellule. Par exemple :
<figure><img src="image_33.png" /><figcaption>FIGURE 18.2 – Interface de JupyterLab.</figcaption></figure>

<figure><img src="image_34.png" /><figcaption>FIGURE 18.3 – Création d'un nouveau notebook.</figcaption></figure>

<figure><img src="image_35.png" /><figcaption>FIGURE 18.4 – Nouveau notebook avec une cellule vide.</figcaption></figure>

1 a = 2

2 b = 3

3 print(a+b)
Pour exécuter le contenu de cette cellule, vous avez plusieurs possibilités :

* Cliquer sur le menu *Run*, puis *Run Selected Cells*.

* Cliquer sur le bouton **dans la barre de menu au dessus du notebook**.

* Presser simultanément les touches `Ctrl` + `Entrée`.

Dans tous les cas, vous devriez obtenir un résultat similaire à la figure 18.5. La notation `[1]` à gauche de la cellule indique qu'il s'agit de la première cellule de code qui a été exécutée.

<figure><img src="image_36.png" /><figcaption>FIGURE 18.5 – Exécution d'une première cellule.</figcaption></figure>

Pour créer une nouvelle cellule, vous avez, ici encore, plusieurs possibilités :

* Cliquer sur l'icône `+` dans la barre de menu au dessus du *notebook*.

* Cliquer sur la 2e icône à partir de la droite (juste à côté de la poubelle), dans les icônes situées à l'intérieur de la cellule, à droite.

Une nouvelle cellule vide devrait apparaître.

Vous pouvez également créer une nouvelle cellule, en positionnant votre curseur dans la première cellule, puis en pressant simultanément les touches `Alt` + `Entrée`. Si vous utilisez cette combinaison de touches, vous remarquerez que le numéro à gauche de la première cellule est passée de `[1]` à `[2]`, car vous avez exécuté une nouvelle fois la première cellule puis créé une nouvelle cellule.

Vous pouvez ainsi créer plusieurs cellules les unes à la suite des autres. Un objet créé dans une cellule antérieure sera disponible dans les cellules suivantes. Par exemple, dans la figure 18.6, nous avons quatre cellules.

<figure><img src="image_37.png" /><figcaption>FIGURE 18.6 – *Notebook* avec plusieurs cellules de code Python.</figcaption></figure>

Dans un *notebook* Jupyter, il est parfaitement possible de réexécuter une cellule précédente. Par exemple la première cellule, qui porte désormais à sa gauche la numérotation `[5]` (voir figure 18.7).
<figure><img src="image_38.png" /><figcaption>FIGURE 18.7 – Notebook avec une cellule ré-exécutée.</figcaption></figure>

Attention

La possibilité d'exécuter les cellules d'un *notebook* Jupyter dans un ordre arbitraire peut prêter à confusion, notamment si vous modifiez la même variable dans plusieurs cellules.

Nous vous recommandons de régulièrement relancer complètement l'exécution de toutes les cellules de votre *notebook*, de la première à la dernière, en cliquant sur le menu *Kernel* puis *Restart Kernel and Run All Cells* et enfin de valider le message *Restart Kernel ?* en cliquant sur le bouton *Restart*.

## 18.4 Le format Markdown

Dans le tout premier exemple (figure 18.1), nous avons vu qu'il était possible de mettre du texte au format Markdown dans une cellule.

Il faut cependant indiquer à Jupyter que cette cellule est au format Markdown en cliquant sur *Code*, sous la barre de menu au dessus du *notebook*, puis en choisissant *Markdown*.

Le format Markdown permet de rédiger du texte formaté (gras, italique, liens, titres, images, formules mathématiques...) avec quelques balises très simples. Voici un exemple dans un *notebook* Jupyter (figure 18.8 (A)) et le rendu lorsque la cellule est exécutée (figure 18.8 (B)). Notez qu'une cellule Markdown est sur fond blanc (comme sur la figure 18.8 (B)).

Le format Markdown permet de rédiger du texte structuré rapidement et simplement. Ce cours est par exemple complètement rédigé en Markdown. Nous vous conseillons d'explorer les possibilités du Markdown en consultant la page Wikipédia[^1] ou directement la page de référence[^2].

## 18.5 Des graphiques dans les notebooks

Un autre intérêt des *notebooks* Jupyter est de pouvoir y incorporer des graphiques réalisés avec la bibliothèque *matplotlib* (que nous verrons prochainement).

Voici un exemple, d'un graphique qui sera présenté dans le chapitre 21 *Module Matplotlib* (figure 18.9).

L'instruction `%matplotlib inline` n'est pas nécessaire dans les versions récentes de JupyterLab. Mais avec d'anciennes versions, vous pourriez en avoir besoin pour que les graphiques s'affichent dans le *notebook*.

<footnote n="1">1. https://fr.wikipedia.org/wiki/Markdown</footnote>

<footnote n="2">2. https://daringfireball.net/projects/markdown/syntax</footnote>
Remarque

Pour quitter l'interface JupyterLab, il y a plusieurs possibilités :

* Dans le menu en haut à gauche de l'interface, cliquer sur *File*, puis *Shut Down*, puis confirmer en cliquant sur le bouton *Shut Down*.

* Une méthode plus radicale est de revenir sur le shell depuis lequel JupyterLab a été lancé, puis de presser deux fois de suite la combinaison de touches `Ctrl` + `C`.

## 18.6 Les magic commands

La commande précédente (`%matplotlib inline`) est une *magic command*. Les *magic commands*<fnref n="3" /> apportent des fonctionnalités supplémentaires dans un *notebook*. Il en existe beaucoup, nous allons en aborder ici quelques unes.

Remarque

Dans cette rubrique, nous vous montrerons quelques exemples d'utilisation de *magic commands* exécutées dans un *notebook* Jupyter.

1. Les cellules de code apparaissent de cette manière :

2. dans un notebook Jupyter, avec des numéros de lignes à gauche.

Les résultats seront affichés de cette manière, éventuellement sur plusieurs lignes.

### 18.6.1 %whos

La commande `%whos` liste tous les objets (variables, fonctions, modules...) utilisés dans un *notebook*.

Si une cellule précédente contenait le code :

1. `a = 2`

2. `b = 3`

3.

4. `def ma_fonction(x, y):`

5. `return x + y`

6.

7. `resultat_1 = ma_fonction(a, 10)`

8. `resultat_2 = ma_fonction("Bonjour", "Jupyter")`

alors l'exécution de :

1. `%whos`

renvoie :

<table><thead><tr><th>Variable</th><th>Type</th><th>Data/Info</th></tr></thead><tbody><tr><td>a</td><td>int</td><td>2</td></tr><tr><td>b</td><td>int</td><td>3</td></tr><tr><td>ma_fonction</td><td>function</td><td>&lt;function ma_fonction at 0x7f219c2d04a0&gt;</td></tr><tr><td>resultat_1</td><td>int</td><td>12</td></tr><tr><td>resultat_2</td><td>str</td><td>BonjourJupyter</td></tr></tbody></table>

### 18.6.2 %history

La commande `%history` liste toutes les commandes Python lancées dans un *notebook* :

1. `%history`

<footnote n="3">3. https://ipython.readthedocs.io/en/stable/interactive/magics.html</footnote>
```python
a = 2
b = 3
print(a + b)
def ma_fonction(x, y):
    return x + y
ma_fonction(a, 10)
ma_fonction("Bonjour", "Jupyter")
%whos
%history
```

### 18.6.3 %%time

La commande `%%time` (avec deux symboles %) va mesurer le temps d'exécution d'une cellule. C'est très utile pour faire des tests de performance. On peut, par exemple, comparer les vitesses de parcours d'une liste avec une boucle `for`, par les éléments ou par les indices des éléments.

Ainsi, cette cellule :

```python
1 %%time
2 concentrations = [5.5, 7.2, 11.8, 13.6, 19.1, 21.7, 29.4]
3 somme_carres = 0.0
4 for conc in concentrations:
5     somme_carres += conc**2
```

renvoie :

```
CPU times: user 8 µs, sys: 2 µs, total: 10 µs
Wall time: 11.9 µs
```

et celle-ci :

```python
1 %%time
2 concentrations = [5.5, 7.2, 11.8, 13.6, 19.1, 21.7, 29.4]
3 somme_carres = 0.0
4 for idx in range(len(concentrations)):
5     somme_carres += concentrations[idx]**2
```

renvoie :

```
CPU times: user 26 µs, sys: 5 µs, total: 31 µs
Wall time: 37.4 µs
```

Comme attendu, la première méthode (itération par les éléments) est plus rapide que la seconde (itération par les indices des éléments). Les temps obtenus dépendent de la machine sur laquelle vous exécutez ces commandes. Mais, sur une même machine, les résultats peuvent fluctuer d'une exécution à l'autre en fonction de l'activité de la machine. Ces fluctuations seront d'autant plus importantes que le temps d'exécution est court.

### 18.6.4 %%timeit

Pour palier à ce problème, la *magic command* `%%timeit` va exécuter plusieurs fois la cellule et donner une estimation du temps d'exécution moyen. Python détermine automatiquement le nombre d'itérations et le nombre de répétitions à effectuer pour obtenir un temps global d'exécution raisonnable.

En reprenant l'exemple précédent, on obtient :

```python
1 %%timeit
2 concentrations = [5.5, 7.2, 11.8, 13.6, 19.1, 21.7, 29.4]
3 somme_carres = 0.0
4 for conc in concentrations:
5     somme_carres += conc**2
```

```
492 ns ± 11.8 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
```

et
1. `%%timeit`

2. `concentrations = [5.5, 7.2, 11.8, 13.6, 19.1, 21.7, 29.4]`

3. `somme_carres = 0.0`

4. `for idx in range(len(concentrations)):`

5. `somme_carres += concentrations[idx]**2`

606 ns ± 21.6 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

Ici, chaque cellule sera exécutée un million de fois sur sept répétitions, soit sept millions de fois au total. Comme nous l'avions expliqué dans le chapitre 5 *Boucles et comparaisons*, itérer une liste sur ses éléments est la méthode la plus efficace (et la plus élégante).

## 18.7 Lancement d'une commande Unix

Enfin, dans les environnements Linux ou Mac OS X, il est possible de lancer une commande Unix depuis un *notebook* Jupyter. Il faut pour cela faire précéder la commande du symbole « ! ». Par exemple, la commande `ls` affiche le contenu du répertoire courant :

```
1 !ls
```

```
jupyter-exemple.ipynb
```

```
 markdown.ipynb
```

```
test.ipynb
```

```
jupyter-logo.png
```

```
 matplotlib.ipynb
```

### Pour aller plus loin

Le lancement d'une commande Unix depuis un *notebook* Jupyter (en précédant cette commande de !) est très utile pour réaliser de grosses analyses de données. Pour vous en rendre compte, explorez ce *notebook*<fnref n="4" /> qui reproduit une analyse complète de données de séquençage haut débit. Ces résultats ont donné lieu à la publication de l'article scientifique « An open RNA-Seq data analysis pipeline tutorial with an example of reprocessing data from a recent Zika virus study<fnref n="5" /> » (F1000 Research, 2016).

### Conseil

Les *notebooks* Jupyter sont particulièrement adaptés à l'analyse de données en combinaison avec les modules `matplotlib` et `pandas`, qui seront abordés dans les prochains chapitres.

<footnote n="4">4. https://github.com/MaayanLab/Zika-RNAseq-Pipeline/blob/master/Zika.ipynb</footnote>

<footnote n="5">5. https://f1000research.com/articles/5-1574/</footnote>
<figure><img src="image_39.png" /></figure>

<figure><img src="image_40.png" /><figcaption>FIGURE 18.8 – Notebook avec : (A) une cellule au format Markdown et (B) le rendu après exécution.</figcaption></figure>
<figure><img src="image_41.png" /><figcaption>FIGURE 18.9 – Incorporation d'un graphique dans un *notebook* Jupyter.</figcaption></figure>
# CHAPITRE 19

## Module Biopython

Nous allons aborder dans ce chapitre un module incontournable en bioinformatique. En effet, le module *Biopython*<fnref n="1" /> permet de manipuler des données biologiques, comme des séquences (nucléiques et protéiques) ou des structures (fichiers PDB), et d'interroger des bases de données comme PubMed. Le tutoriel <fnref n="2" /> est particulièrement bien fait, n'hésitez pas à le consulter.

### 19.1 Installation et convention

Contrairement aux autres modules vus précédemment, *Biopython* n'est pas fourni avec la distribution Python de base. Avec la distribution Miniconda que nous vous conseillons d'utiliser (consultez pour cela la documentation en ligne <fnref n="3" />), vous pouvez rapidement l'installer avec la commande :

```
$ conda install -c conda-forge biopython
```

Dans ce chapitre, nous vous montrerons quelques exemples d'utilisation du module *Biopython* pour vous convaincre de sa pertinence. Ces exemples seront exécutés dans un *notebook* Jupyter.

1. Les cellules de code apparaîtront de cette manière :

2. dans un *notebook* Jupyter, avec des numéros de lignes à gauche.

Les résultats seront affichés de cette manière, éventuellement sur plusieurs lignes.

### 19.2 Chargement du module

On charge le module *Biopython* avec la commande :

1. `import Bio`

**Attention**

Le nom du module *Biopython* n'est pas *biopython*, mais *Bio* (avec un B majuscule).

<footnote n="1">1. http://biopython.org/</footnote>

<footnote n="2">2. http://biopython.org/DIST/docs/tutorial/Tutorial.html</footnote>

<footnote n="3">3. https://python.sdv.u-paris.fr/livre-dunod</footnote>
# 19.3 Manipulation de séquences

Voici quelques exemples de manipulation de séquences avec *Biopython*.

## 19.3.1 Définition d'une séquence

1 import Bio

2 from Bio.Seq import Seq

3 ADN = Seq("ATATCGGCTATAGCATGC")

4 ADN

Seq('ATATCGGCTATAGCATGC')

* Ligne 1. Le module *Biopython* s'appelle *Bio*.

* Ligne 2. On charge la classe *Seq* du sous-module *Bio.Seq*.

* Ligne 3. La variable *ADN* est de type *Seq*, comme affiché dans le résultat.

## 19.3.2 Obtention de la séquence complémentaire et de la séquence complémentaire inverse

1 ADN.complement()

Seq('TATAGCCGATATCGTACG')

1 ADN.reverse_complement()

Seq('GCATGCTATAGCCGATAT')

## 19.3.3 Traduction en séquence protéique

1 ADN translate()

Seq('ISAIAC')

### Conseil

Dans l'annexe A *Quelques formats de données en biologie*, vous trouverez de nombreux exemples d'utilisation de *Biopython* pour manipuler des données aux formats FASTA, GenBank et PDB.

# 19.4 Interrogation de la base de données PubMed

Le sous-module *Entrez* de *Biopython* permet d'utiliser les ressources du NCBI et notamment d'interroger la base de données PubMed<fnref n="4" />. Nous allons par exemple utiliser PubMed pour chercher des articles scientifiques relatifs à la transferrine (*transferrin* en anglais) :

1 from Bio import Entrez

2 Entrez.email = "votremail@provider.fr"

3 req_esearch = Entrez.esearch(db="pubmed", term="transferrin")

4 res_esearch = Entrez.read(req_esearch)

* Ligne 1. On charge directement le sous-module *Entrez*.

* Ligne 2. Lors d'une requête sur le site du NCBI, il est important de définir correctement la variable *Entrez.email*, qui sera transmise au NCBI lors de la requête et qui pourra être utilisée pour vous contacter en cas de difficulté avec le serveur.

<footnote n="4">4. https://www.ncbi.nlm.nih.gov/pubmed/</footnote>
1 req_esummary = Entrez.esummary(db="pubmed", id="22294463")

2 res_esummary = Entrez.read(req_esummary)

La variable res_esummary n'est pas réellement une liste (son type exact est Bio.Entrez Parser ListElement), mais elle est indexable (voir chapitre 14 Conteneurs). Cette pseudo-liste n'a qu'un seul élément, qui est lui-même un dictionnaire dont voici les clés :

1 res_esummary[0].keys()

dict_keys(['Item', 'Id', 'PubDate', 'EPubDate', 'Source', 'AuthorList', 'LastAuthor', 'Title', 'Volume', 'Issue', 'Pages', 'LangList', 'NlmUniqueID', 'ISSN', 'ESSN', 'PubTypeList', 'RecordStatus', 'PubStatus', 'ArticleIds', 'DOI', 'History', 'References', 'HasAbstract', 'PmcRefCount', 'FullJournalName', 'ELocationID', 'SO'])

Nous pouvons alors facilement obtenir le titre, le DOI et la date de publication (PubDate) de cet article, ainsi que le journal (Source) dans lequel il a été publié :

1 res_esummary[0ól"Title"]

'Known and potential roles of transferrin in iron biology.'

1 res_esummary[0] ["DOI"]

'10.1007/s10534-012-9520-3'

1 res_esummary[0] ["PubDate"]

'2012 Aug'

1 res_esummary[0] ["Source"]

'Biometals'

Enfin, pour récupérer le résumé de la publication précédente, nous allons utiliser la fonction Entrez.efetch() :

1 req.efetch = Entrez.efetch(

2 db="pubmed", id="22294463",

3 rettype="abstract", retmode="text")

4 req.efetch.read()

'1. Biometals. 2012 Aug;25(4):677-86. doi: 10.1007/s10534-012-9520-3. 
\n\nKnown and potential roles of transferrin in iron biology.\n\nBartnikas TB(1).\n\nAuthor information:\n(1)Department of Pathology, Chil'drens Hospital, Enders 1110, 300 Longwood \nAvenue, Boston, MA 02115 , USA. mas.Bartnikas@childrens.harvard.edu\n\nTransferrin is an abundant serum metal-binding protein best known for its role \nin iron del [...]

Le résultat n'est pas très lisible, car il apparaît comme un seul bloc. Le caractère \n désigne un retour à la ligne.
L'instruction print() affichera le résultat de manière plus lisible :

1 req.efetch = Entrez.efetch(

2 db="pubmed", id="22294463",

3 rettype="abstract", retmode="text")

4 print(req.efetch.read())
1. Biometals. 2012 Aug;25(4):677-86. doi: 10.1007/s10534-012-9520-3.

Known and potential roles of transferrin in iron biology.

Bartnikas TB(1).

Author information:

(1)Department of Pathology, 'Childrens Hospital, Enders 1110, 300 Longwood Avenue, Boston, MA 02115, USA. mas.Bartnikas@childrens.harvard.edu

Transferrin is an abundant serum metal-binding protein best known for its role in iron delivery. The human disease congenital atransferrinemia and animal models of this disease highlight the essential role of transferrin in erythropoiesis and iron metabolism. Patients and mice deficient in transferrin exhibit anemia and a paradoxical iron overload attributed to deficiency in hepcidin, a peptide hormone synthesized largely by the liver that inhibits dietary iron absorption and macrophage iron efflux. Studies of inherited human disease and model organisms indicate that transferrin is an essential regulator of hepcidin expression. In this paper, we review current literature on transferrin deficiency and present our recent findings, including potential overlaps between transferrin, iron and manganese in the regulation of hepcidin expression.

DOI: 10.1007/s10534-012-9520-3

PMCID: PMC3595092

PMID: 22294463 [Indexed for MEDLINE]

Le résultat contient bien le résumé de la figure 19.1, mais aussi d'autres informations comme le titre, le DOI, la date de publication...

## 19.5 Exercices

### Conseil

Pour ces exercices, utilisez des notebooks Jupyter.

### 19.5.1 Pourcentage de GC de gènes de Plasmodium falciparum

Plasmodium falciparum (P. falciparum) est un des parasites responsables du paludisme chez les êtres humains. Le fichier `p_falciparum_500.fasta`<fnref n="6" /> contient 500 gènes du génome de P. falciparum.

Écrivez un code Python qui calcule le pourcentage de GC de chaque gène. Les valeurs seront stockées dans un dictionnaire, avec comme clés les identifiants des gènes et comme valeurs le pourcentage de GC.

On rappelle que le pourcentage de GC d'une séquence est calculé avec la formule suivante :

$$ \text{pourcentage GC} = \frac{\text{nombre de bases G} + \text{nombre de bases C}}{\text{longueur de la séquence}} \times 100 $$

Affichez ensuite :

* Le nombre total de gènes.

* L'identifiant de la séquence qui a le pourcentage de GC le plus élevé, avec la valeur du pourcentage affichée avec deux chiffres après la virgule.

* L'identifiant de la séquence qui a le pourcentage de GC le plus faible, avec la valeur du pourcentage affichée avec deux chiffres après la virgule.

### Conseil

Pour cet exercice, n'hésitez pas à consulter :

* Le chapitre 14 Conteneurs pour trier un dictionnaire.

* L'annexe A Quelques formats de données en biologie pour lire un fichier FASTA avec Biopython.

<footnote n="6">6. https://python.sdv.u-paris.fr/data-files/p_falciparum_500.fasta</footnote>
## 19.5.2 Années de publication des articles relatifs à la barstar

L'objectif de cet exercice est d'interroger automatiquement la base de données bibliographique PubMed pour déterminer le nombre d'articles relatifs à la protéine barstar publiés chaque année.

Vous utiliserez le module `Biopython` et le module ` matplotlib », qui sera vu un peu plus loin (les principales instructions vous seront fournies).

### 19.5.2.1 Requête avec un mot-clé

Sur le site de PubMed <fnref n="7" />, cherchez combien d'articles scientifiques sont relatifs à la barstar.

Effectuez la même chose avec Python et la méthode `Entrez.esearch()` de `Biopython`.

Choisissez un des PMID renvoyé et vérifiez dans PubMed que l'article associé est bien à propos de la barstar. Pour cela, indiquez le PMID choisi dans la barre de recherche de PubMed et cliquez sur **Search**. Attention, l'association n'est pas toujours évidente. Cherchez éventuellement dans le résumé de l'article si besoin.

Est-ce que le nombre total d'articles trouvés est cohérent avec celui obtenu sur le site de PubMed ?

### 19.5.2.2 Récupération des informations d'une publication

Récupérez les informations de la publication dont le PMID est 29701945⁸. Vous utiliserez la méthode `Entrez.esummary()`.

Affichez le titre, le DOI, le nom du journal (*Source*) et la date de publication (*PubDate*) de cet article. Vérifiez que cela correspond bien à ce que vous avez lu sur PubMed.

### 19.5.2.3 Récupération du résumé d'une publication

Récupérez le résumé de la publication dont le PMID est 29701945. Vous utiliserez la méthode `Entrez.efetch()`.

Affichez ce résumé.

### 19.5.2.4 Distribution des années de publication des articles relatifs à la barstar

En utilisant la méthode `Entrez.esearch()`, récupérez tous les PMID relatifs à la barstar. Pour cela, pensez à augmenter le paramètre `retmax`. Vos PMID seront stockés dans la liste `pmids` sous forme de chaînes de caractères. Vérifiez sur PubMed que vous avez récupéré le bon nombre d'articles.

En utilisant maintenant la méthode `Entrez.esummary()` dans une boucle, récupérez la date de publication de chaque article. Stockez l'année sous forme d'un nombre entier dans la liste `years`. Cette étape peut prendre une dizaine de minutes, soyez patient. Vous pouvez afficher dans votre boucle un message qui indique où vous en êtes dans la récupération des articles.

Vérifiez que votre liste `years` contient bien autant d'éléments que la liste `pmids`.

Calculez maintenant le nombre de publications par année. Vous créerez pour cela un dictionnaire `freq` qui aura pour clé les années (oui, une clé de dictionnaire peut aussi être un entier) et pour valeur le nombre de publications associées à une année donnée.

Créez une liste `x` qui contient les clés du dictionnaire `freq`. Ordonnez les valeurs dans `x` avec la méthode `sort()`. Créez maintenant une seconde liste `y` qui contient, dans l'ordre, le nombre de publications associées à chaque année. Bien évidemment, les listes `x` et `y` doivent avoir la même taille. Au fait, en quelle année la barstar apparaît pour la première fois dans une publication scientifique ?

Ensuite, avec le module `plotlib` (que nous aborderons prochainement), vous allez pouvoir afficher la distribution des publications en fonction des années :

<footnote n="7">7. https://www.ncbi.nlm.nih.gov/pubmed/</footnote>

<footnote n="8">8. https://www.ncbi.nlm.nih.gov/pubmed/29701945</footnote>
1 import matplotlib.pyplot as plt

2

3 fig, ax = plt.subplots()

4 ax.bar(x, y)

Vous pouvez également ajouter un peu de cosmétique et enregistrer le graphique sur votre disque dur :

1 import matplotlib.pyplot as plt

2

3 fig, ax = plt.subplots()

4 ax.bar(x, y)

5

6 # Étiquetage des axes.

7 ax.set_xlabel("Années")

8 ax.set_ylabel("Nombre de publications")

9

10 # Ajout du titre du graphique.

11 ax.set_title("Distribution des publications qui mentionnent la barstar")

12

13 # Enregistrement sur le disque.

14 fig.savefig("distribution_barstar_annee.png")
Module NumPy

Le module NumPy<fnref n="1" /> est incontournable en bioinformatique. Il permet d'effectuer des calculs sur des vecteurs ou des matrices, élément par élément, via un nouveau type d'objet appelé *array*.

## 20.1 Installation et convention

Contrairement aux modules vus précédemment, NumPy n'est pas fourni avec la distribution Python de base. Avec la distribution Miniconda que nous vous conseillons d'utiliser (consultez pour cela la documentation en ligne <fnref n="2" />), vous pouvez rapidement l'installer avec la commande :

```
$ conda install -c conda-forge numpy
```

Dans ce chapitre, nous vous montrerons quelques exemples d'utilisation du module NumPy pour vous convaincre de sa pertinence. Ces exemples seront exécutés dans un *notebook* Jupyter.

1. Les cellules de code apparaîtront de cette manière

2. dans un *notebook* Jupyter, avec des numéros de lignes à gauche.

Les résultats seront affichés de cette manière, éventuellement sur plusieurs lignes.

## 20.2 Chargement du module

On charge le module NumPy avec la commande :

1. import numpy

Par convention, on utilise np comme nom raccourci pour NumPy :

1. import numpy as np

## 20.3 Objets de type *array*

Les objets de type *array* correspondent à des tableaux à une ou plusieurs dimensions et permettent d'effectuer du calcul vectoriel. La fonction *array()* convertit un conteneur (comme une liste ou un tuple) en un objet de type *array*.

<footnote n="1">1. http://numpy.scipy.org/</footnote>

<footnote n="2">2. https://python.sdv.u-paris.fr/livre-dunod</footnote>
Voici un exemple de conversion d'une liste à une dimension en objet `array` :

1 import numpy as np

2 a = [1, 2, 3]

3 np.array(a)

array([1, 2, 3])

1 b = np.array(a)

2 b

array([1, 2, 3])

1 type(b)

numpy.ndarray

Nous avons converti la liste `[1, 2, 3]` en `array`. La fonction `np.array()` accepte aussi comme argument un tuple, ou un objet de type `range`.

Par ailleurs, lorsqu'on demande à Python d'afficher le contenu d'un objet `array`, le mot `array` et les symboles (`[]`) sont utilisés pour le distinguer d'une liste (délimitée par les caractères `[ et ]`) ou d'un tuple (délimité par les caractères `( et )`).

## Remarque

Un objet `array` ne contient que des données homogènes, c'est-à-dire d'un type identique. Il est possible de créer un objet `array` à partir d'une liste contenant des entiers et des chaînes de caractères, mais, dans ce cas, toutes les valeurs seront comprises par `NumPy` comme des chaînes de caractères :

1 a = np.array([1, 2, "tigre"])

2 a

array(['1', '2', 'tigre'], dtype='U21')

Dans cet exemple, toutes les valeurs du array sont entre guillemets, indiquant qu'il s'agit de chaînes de caractères.

De même, il est possible de créer un objet `array` à partir d'une liste constituée d'entiers et de `floats`, mais toutes les valeurs seront alors comprises par `NumPy` comme des `floats` :

1 b = np.array([1, 2, 3.5])

2 b

array([1., 2., 3.5])

Ici, la notation `1.` indique qu'il s'agit du `float 1.0000...` et pas de l'entier `1`.

Sur un modèle similaire à la fonction `range()`, la fonction `arange()` permet de construire un `array` à une dimension :

1 np.arange(10)

array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Comme avec `range()`, on peut spécifier en argument une borne de début, une borne de fin et un pas :

1 np.arange(10, 0, -1)

array([10, 9, 8, 7, 6, 5, 4, 3, 2, 1])
Un autre avantage de la fonction `arange()` est qu'elle génère des objets `array` qui contiennent des entiers ou des `floats` (ce qui n'est pas possible avec `range()`) selon l'argument qu'on lui passe. D'abord un entier :

1 np arange(10)

array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Puis un `float` :

1 np arange(10.0)

array([0., 1., 2., 3., 4., 5., 6., 7., 8., 9.])

La différence fondamentale entre un objet `array` à une dimension et une liste (ou un tuple) est que celui-ci est considéré comme un **vecteur**. Par conséquent, on peut effectuer des opérations vectorielles **élément par élément** sur ce type d'objet, ce qui est bien commode lorsqu'on analyse de grandes quantités de données. Regardez ces exemples :

1 v = np arange(4)

2 v

array([0, 1, 2, 3])

On ajoute 1 à **chacun** des éléments de l'array v :

1 v + 1

array([1, 2, 3, 4])

On multiplie par 2 **chacun** des éléments de l'array v :

1 v * 2

array([0, 2, 4, 6])

Avec les listes, ces opérations n'auraient été possibles qu'en utilisant des boucles. Nous vous encourageons donc à utiliser dorénavant les objets `array` lorsque vous aurez besoin de faire des opérations élément par élément.

Il est aussi possible de multiplier deux arrays entre eux. Le résultat correspond alors à la multiplication **élément par élément** des deux arrays initiaux :

1 v * v

array([0, 1, 4, 9])

### 20.3.1 Array et dimensions

Il est aussi possible de construire des objets `arrays` à deux dimensions, il suffit de passer en argument une liste de listes à la fonction `array()` :

1 w = np array([ [1, 2], [3, 4], [5, 6] ])

2 w

array([[1, 2],
[3, 4],
[5, 6]])

On peut aussi créer des tableaux à trois dimensions en passant comme argument à la fonction `array()` une liste de listes de listes :

1 x = np array([[ [1, 2], [2, 3]], [[4, 5], [5, 6]]])

2 x
```python
array([[[[1, 2]]
      [2, 3]]],
      [[4, 5]]
      [5, 6]]])
```

La fonction `array()` peut créer des tableaux à n'importe quel nombre de dimensions. Toutefois, cela devient vite compliqué lorsqu'on dépasse trois dimensions. Retenez qu'un objet `array` à une dimension peut être assimilé à un **vecteur**, un `array` à deux dimensions à une **matrice**. On peut généraliser ces objets mathématiques avec un nombre arbitraires de dimensions, on parle alors de **tenseur**, qui sont représentés avec NumPy en `array` à n dimensions. Nous nous focaliserons dans la suite sur des `arrays` à une dimension (1D) ou deux dimensions (2D).

Avant de continuer, il est important de définir comment sont organisés ces `arrays` 2D qui représentent des matrices. Il s'agit de tableaux de nombres qui sont organisés en lignes et en colonnes comme le montre la figure 20.1. Les indices indiqués dans cette figure seront définis un peu plus loin dans la rubrique *Indices*.

<figure><img src="image_42.png" /><figcaption>FIGURE 20.1 – Définition des lignes et colonnes dans un array 2D.</figcaption></figure>

Voici quelques attributs intéressants pour décrire un objet `array` :

1. `v = np.arange(4)`

2. `v`

```python
array([0, 1, 2, 3])
```

1. `w = np.array([[1, 2], [3, 4], [5, 6]])`

2. `w`

```python
array([[1, 2],
      [3, 4],
      [5, 6]])
```

L'attribut `.ndim` renvoie le nombre de dimensions de l'array. Par exemple, 1 pour un vecteur et 2 pour une matrice :

1. `v.ndim`

1

1. `w.ndim`
2

L'attribut .shape renvoie les dimensions sous forme d'un tuple. Dans le cas d'une matrice (array à deux dimensions), la première valeur du tuple correspond au nombre de lignes et la seconde au nombre de colonnes.

1 v.shape

(4,)

1 w.shape

(3, 2)

Enfin, l'attribut .size renvoie le nombre total d'éléments contenus dans l'array :

1 v.size

4

1 w.size

6

### 20.3.2 Redimensionnement d'array

La méthode .resize() renvoie un nouvel array avec les dimensions spécifiées en argument :

1 a = np.arange(0, 6)

2 a

array([0, 1, 2, 3, 4, 5])

1 a.shape

(6,)

1 b = a.reshape((2, 3))

2 b

array([[0, 1, 2],
       [3, 4, 5]])

1 b.shape

(2, 3)

1 a

array([0, 1, 2, 3, 4, 5])

Notez bien que l'array initial a n'a pas été modifié et que a.reshape((2, 3)) n'est pas la même chose que a.reshape((3, 2)) :
1 c = a.reshape((3, 2))

2 c

array([[0, 1],
      [2, 3],
      [4, 5]])

1 c.shape

(3, 2)

La méthode `.resize()` attend que les nouvelles dimensions soient **compatibles** avec la dimension initiale de l'objet `array`, c'est-à-dire que le nombre d'éléments contenus dans les différents `arrays` soit le même. Dans nos exemples précédents, $6 = 2 \times 3 = 3 \times 2$.

Si les nouvelles dimensions ne sont pas compatibles avec les dimensions initiales, la méthode `.resize()` génère une erreur.

1 a = np.arange(0, 6)

2 a

array([0, 1, 2, 3, 4, 5])

1 a.shape

(6,)

1 d = a.reshape((3, 4))

----------------------------------------------------------------
ValueError                               Traceback (most recent call last)
Cell In[36], line 1
----> 1 d = a.reshape((3, 4))

ValueError: cannot reshape array of size 6 into shape (3,4)

La méthode `.resize()`, par contre, ne déclenche pas d'erreur dans une telle situation et ajoute des 0 jusqu'à ce que le nouvel *array* soit rempli, ou bien coupe la liste initiale :

1 a = np.arange(0, 6)

2 a.shape

(6,)

1 a_resize((3, 3), refcheck=False)

2 a.shape

(3, 3)

1 a

array([[0, 1, 2],
      [3, 4, 5],
      [0, 0, 0]])
1 b = np.arange(0, 10)

2 b.shape

(10,)

1 b_resize((2, 3), refcheck=False)

2 b.shape

(2, 3)

1 b

array([[0, 1, 2],
      [3, 4, 5]])

Attention

* Cette modification de la forme de l'array par la méthode `.resize()` est faite « sur place » (in place), c'est-à-dire que la méthode ne renvoie rien, mais l'array initial est bel et bien modifié (comme des méthodes sur les listes telles que la méthode `.reverse()`, voir le chapitre 13 *Plus sur les listes*).

* Si l'option `refcheck=False` n'est pas présente, Python peut parfois renvoyer une erreur s'il existe des références vers l'array qu'on souhaite modifier.

Enfin, il existe la fonction `np_resize()` qui, dans le cas d'un nouvel array plus grand que l'array initial, va répéter l'array initial afin de remplir les cases manquantes :

1 a = np.arange(0, 6)

2 a.shape

(6,)

1 c = np_resize(a, (3, 5))

2 c.shape

(3, 5)

1 c

array([[0, 1, 2, 3, 4],
      [5, 0, 1, 2, 3],
      [4, 5, 0, 1, 2]])

1 a

array([0, 1, 2, 3, 4, 5])

Notez que la fonction `np_resize()` renvoie un nouvel array mais ne modifie pas l'array initial, contrairement à la méthode `.resize()`, décrite ci-dessus.

Remarque

Depuis le début de ce chapitre, nous avons toujours montré l'affichage d'un array tel quel dans un *notebook* Jupyter :
1 a = np.array(range(10))

2 a

array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

1 a2 = np.ones((3, 3))

2 a2

array([[1., 1., 1.],
      [1., 1., 1.],
      [1., 1., 1.]])

Nous avons déjà indiqué que Python affiche systématiquement le mot array ainsi que les parenthèses, crochets et virgules pour séparer les éléments. Toutefois, si vous utilisez la fonction `print()`, l'affichage sera différent. Le mot array, les parenthèses et les virgules disparaissent :

1 print(a)

[0 1 2 3 4 5 6 7 8 9]

1 print(a2)

[[1. 1. 1.]
[1. 1. 1.]
[1. 1. 1.]]

Ceci peut amener des confusions, en particulier entre un array 1D :

[0 1 2 3 4 5 6 7 8 9]

et une liste contenant les mêmes éléments :

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

Dans ce cas, seule la présence ou l'absence de virgules permet de savoir s'il s'agit d'un array ou d'une liste.

### 20.3.3 Méthodes de calcul sur les arrays et l'argument axis

Chaque array NumPy possède une multitude de méthodes. Nombre d'entre elles permettent de faire des calculs de base comme `.mean()` pour la moyenne, `.sum()` pour la somme, `.std()` pour l'écart-type, `.max()` pour extraire le maximum, `.min()` pour extraire le minimum, etc. La liste exhaustive est disponible en ligne 3. Par défaut, chacune de ces méthodes effectuera l'opération sur l'array entier, quelle que soit sa dimensionnalité. Par exemple :

1 import random

2 ma_liste = list(range(8))

3 random shufle(ma_liste)

4 ma_liste

[2, 7, 6, 4, 0, 3, 1, 5]

1 a = npbeit(ma_liste, (4, 2))

2 a

<footnote n="3">3. https://numpy.org/doc/stable/reference/arrays.ndarray.html#calculation</footnote>
array([[2, 7],
      [6, 4],
      [0, 3],
      [1, 5]])

1 a.max()

7

La méthode .max() a bien renvoyé la valeur maximale 7. Un argument très utile existant dans toutes ces méthodes est axis. Pour un array 2D, axis=0 signifie qu'on fera l'opération le long de l'axe 0, à savoir les lignes. C'est-à-dire que l'opération se fait en variant les lignes. On récupère ainsi une valeur par colonne :

1 a.max(axix=0)

array([6, 7])

Dans l'array 1D récupéré, le premier élément vaut 6 (maximum de la 1ère colonne) et le second vaut 7 (maximum
de la seconde colonne).

Avec axis=1, on fait une opération similaire, mais en faisant varier les colonnes. On récupère ainsi une valeur par
ligne :

1 a.max(axix=1)

array([7, 6, 3, 5])

L'array 1D récupéré a quatre éléments correspondant au maximum de chaque ligne.

On comprend la puissance de l'argument axis. À nouveau, il est possible, en une ligne, de faire des calculs qui
pourraient être fastidieux avec les listes traditionnelles.

20.3.4 Indices

Pour récupérer un ou plusieurs élément(s) d'un objet array, vous pouvez utiliser les indices, de la même manière
qu'avec les listes :

1 a = np.arange(10)

2 a

array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

1 a[1]

1

L'utilisation des tranches est aussi possible :

1 a[5:]

array([5, 6, 7, 8, 9])

Ainsi que les pas :

1 a[:2]

array([0, 2, 4, 6, 8])
Dans le cas d'un objet *array* à deux dimensions, vous pouvez récupérer une ligne complète (d'indice *i*), une colonne complète (d'indice *j*) ou bien un seul élément. La figure 20.1 montre comment sont organisés les indices des lignes et des colonnes :

1 a = np.array([[1, 2], [3, 4]])

2 a

array([[1, 2],
      [3, 4]])

1 a[: ,0]

array([1, 3])

1 a[0: ,]

array([1, 2])

La syntaxe `a[i.]` renvoie la ligne d'indice *i*, et `a[: ,j]` renvoie la colonne d'indice *j*. Les tranches sont aussi utilisables sur un *array* à deux dimensions.

1 a[1, 1]

4

La syntaxe `a[i, j]` renvoie l'élément à la ligne d'indice *i* et à la colonne d'indice *j*. Notez que *NumPy* suit la convention mathématiques des matrices<fnref n="4" />, à savoir, qu'on définit toujours un élément par sa ligne puis par sa colonne. En mathématiques, l'élément $a_{ij}$ d'une matrice $A$ se trouve à la $i^{me}$ ligne et à la $j^{me}$ colonne :

Remarque

Pour un array 2D, si un seul indice est donné, par exemple `a[i]`, on récupère la ligne d'indice *i* sous forme d'array 1D :

1 a = np.array([[1, 2], [3, 4]])

2 a

array([[1, 2],
      [3, 4]])

1 a[0]

array([1, 2])

1 a[1]

array([3, 4])

Pour cette raison, la syntaxe `a[i][j]` est également valide pour récupérer un élément :

1 a[1, 1]

4

<footnote n="4">4. https://fr.wikipedia.org/wiki/Matrice_(math%C3%A9matiques)#D%C3%A9finitions</footnote>
1 a[1][1]

4

Nous vous recommandons la syntaxe `a[i, j]`, qui est plus proche de la définition mathématique d'un élément de matrice<fnref n="5" />.

### 20.3.5 Copie d'arrays

Comme pour les listes, nous attirons votre attention sur la copie d'arrays :

1 a = np.arange(5)

2 a

array([0, 1, 2, 3, 4])

1 b = a

2 b[2] = -300

3 b

array([ 0, 1, -300, 3, 4])

1 a

array([ 0, 1, -300, 3, 4])

**Attention**

Par défaut la copie d'arrays se fait par référence, comme pour tous les conteneurs en Python (listes, tuples, dictionnaires, etc.).

Afin d'éviter le problème, vous pouvez soit utiliser la fonction `np.array()`, qui crée une nouvelle copie distincte de l'array initial, soit la fonction `copy.deepcopy()`, comme pour les listes (voir chapitre 12 Plus sur les listes) :

1 a = np((2, 2), 0)

2 a

array([ [0, 0],
       [0, 0]])

1 b = np.array(a)

2 b[1, 1] = -300

3 import copy

4 c = copy.deepcopy(a)

5 c[1, 1] = -500

6 a

array([ [0, 0],
       [0, 0]])

1 b

<footnote n="5">5. https://fr.wikipedia.org/wiki/Matrice_(math%C3%A9matiques)#D%C3%A9finitions</footnote>
```
array([[ 0,  0],
      [ 0, -300]])
```

1 c

```
array([[ 0,  0],
      [ 0, -500]])
```

La fonction `np Full()` est expliquée dans la rubrique suivante.

Remarque

L'instruction `b = np.array(a)` réalise bien une copie distincte de l'array `a`, quelle que soit sa dimensionnalité. Ceci n'était pas le cas avec la fonction `list()` pour les copies de listes à partir de la dimension deux (liste de listes) :

1 liste_1 = [[0, 0], [1, 1]]

2 liste_2 = list(liste_1)

3 import copy

4 liste_3 = copy.deepcopy(liste_1)

5 liste_1[1][1] = -365

6 liste_2

```
[[0, 0], [1, -365]]
```

1 liste_3

```
[[0, 0], [1, 1]]
```

20.4 Construction automatique de matrices

Il est parfois pénible de construire une matrice (*array* à deux dimensions) à l'aide d'une liste de listes. Le module *NumPy* possède quelques fonctions pratiques pour initialiser des matrices. Par exemple, Les fonctions *zeros()* et *ones()* construisent des objets *array* contenant des 0 ou des 1. Il suffit de leur passer en argument un tuple indiquant les dimensions voulues :

1 np(zeros((2, 3)))

```
array([[0., 0., 0.],
      [0., 0., 0.]])
```

1 np. ones((3, 3))

```
array([[1., 1., 1.],
      [1., 1., 1.],
      [1., 1., 1.]])
```

Par défaut, les fonctions *zeros()* et *ones()* génèrent des *floats*, mais vous pouvez demander des entiers en passant
le type (par exemple *int*, *float*, etc.) en second argument :

1 np. zeros((2,3), int)

```
array([[0, 0, 0],
      [0, 0, 0]])
```
Enfin, si vous voulez construire une matrice avec autre chose que des 0 ou des 1, vous avez à votre disposition la fonction `full()` :

1 np.full((2, 3), 7, int)

array([[7, 7, 7],
      [7, 7, 7]])

1 np.full((2, 3), 7, float)

array([[7., 7., 7.],
      [7., 7., 7.]])

Nous construisons ainsi une matrice constituée de 2 lignes et 3 colonnes. Celle-ci ne contient que le chiffre 7 sous formes d'entiers (int) dans le premier cas et de floats dans le second.

## 20.5 Chargement d'un array depuis un fichier

Le module `NumPy` contient aussi des fonctions pour lire des données à partir de fichiers et créer des `arrays` automa- tiquement. C'est très pratique, car la plupart du temps les données que l'on analyse proviennent de fichiers. La fonction la plus simple à prendre en main est `np.loadtxt()`. Celle-ci lit un fichier organisé en lignes et colonnes. Par exemple, imaginons que nous ayons un fichier `donnees.dat` contenant :

1 7 310
15 -4 35
78 95 79

La fonction prend en argument le nom du fichier et renvoie un `array` 2D directement :

1 np.loadtxt("donnees.dat")

array([[1., 7., 310.],
      [15., -4., 35.],
      [78., 95., 79.]])

Pratique, non ? Attention toutefois aux points suivants :

* Chaque ligne doit avoir le même nombre de colonnes, la fonction ne gère pas les données manquantes.

* Chaque donnée est convertie en `float`, donc si une chaîne de caractères est rencontrée la fonction renvoie une erreur.

* Par défaut, les données doivent être séparées par n'importe quelle combinaison d'espace(s) et/ou de tabulations.

Nous vous conseillons de consulter la documentation complète<fnref n="6" /> de cette fonction. En effet, `np.loadtxt()` contient de nombreux arguments permettant de récupérer telles ou telles lignes ou colonnes, d'ignorer des lignes de commentaire, de changer le séparateur par défaut (par exemple la virgule, pour les fichiers `.csv`)... qui peuvent se révéler utiles.

L'opération inverse qui consiste à sauver un `array` dans un fichier se fait avec la fonction `np.savetxt()` :

1 a = np.reshape(range(1, 10), (3, 3))

2 a

array([[1, 2, 3],
      [4, 5, 6],
      [7, 8, 9]])

1 np.savetxt("out.dat", a)

Ceci générera le fichier `out.dat` contenant les lignes suivantes :

1.00000000000000000000e+00 2.00000000000000000000e+00 3.00000000000000000000e+00
4.00000000000000000000e+00 5.00000000000000000000e+00 6.00000000000000000000e+00
7.00000000000000000000e+00 8.00000000000000000000e+00 9.00000000000000000000e+00

<footnote n="6">6. https://numpy.org/doc/stable/reference/generated/numpy.loadtxt.html</footnote>
La fonction `np.savetxt()` écrit par défaut les données comme des `floats` en notation scientifique. Il existe de nombreuses options possibles<fnref n="7" /> permettant de changer le format, les séparateurs, etc.

### Pour aller plus loin

Il existe d'autres fonctions plus avancées telles que `np.genfromtxt()`<fnref n="8" />, gérant les données manquantes, ou encore `np.load()`<fnref n="9" /> et `np.fromfile()`<fnref n="10" />, permettant de lire des données au format binaire. De même, il existe des fonctions ou méthodes permettant d'écrire au format binaire : `np.save()`<fnref n="11" /> ou `.tofile()`<fnref n="12" />. Le format binaire possède en général l'extension `.npy` ou `.npz` lorsque les données sont compressées. L'avantage d'écrire au format binaire est que cela prend moins de place pour de gros tableaux de données.

## 20.6 Concaténation d'arrays

Il peut être très utile de concatenéner un ou plusieurs arrays. Il existe pour cela plusieurs fonctions dans `NumPy`, nous développerons celle qui nous parait la plus intuitive et directe : `np.concatenate()`.

Pour les arrays 1D, `np.concatenate()` prend en argument un tuple contenant les arrays à concatenéner :

1. `a1 = np.array((0, 1))`

2. `a2 = np.array((3, 4))`

3. `a1`

```
array([0, 1])
```

1. `a2`

```
array([3, 4])
```

1. `np.concatenate((a1, a2))`

```
array([0, 1, 3, 4])
```

L'ordre de la concatenation est important :

1. `np.concatenate((a2, a1))`

```
array([3, 4, 0, 1])
```

1. `np.concatenate((a1, a2, a1, a2))`

```
array([0, 1, 3, 4, 0, 1, 3, 4])
```

Pour les arrays 2D, ça se complique un peu, car on peut concatenéner des lignes ou des colonnes ! Ainsi, `np.concatenate()` prend un argument optionnel, à savoir `axis`. Comme nous l'avions expliqué plus haut, celui-ci va indiquer à `NumPy` si on veut concatenéner le long de l'axe 0 (les lignes) ou le long de l'axe 1 (les colonnes). Voyons un exemple :

1. `a1 = np.reshape(np.array(range(6)), (3, 2))`

2. `a2 = a1 * 5`

3. `a1`

<footnote n="7">7. https://numpy.org/doc/stable/reference/generated/numpy.savetxt.html</footnote>

<footnote n="8">8. https://numpy.org/doc/stable/reference/generated/numpy.genfromtxt.html</footnote>

<footnote n="9">9. https://numpy.org/doc/stable/reference/generated/numpy.load.html</footnote>

<footnote n="10">10. https:// numpy.org/doc/stable/reference/generated/numpy.fromfile.html</footnote>

<footnote n="11">11. https:// numpy.org/doc/stable/reference/generated/numpy.save.html</footnote>

<footnote n="12">12. https:// numpy.org/doc/stable/reference/generated/numpy.ndarray.tofile.html# numpy.ndarray.tofile</footnote>
```
array([[0, 1],
      [2, 3],
      [4, 5]])
```

```
1 a2
```

```
array([[0, 5],
      [10, 15],
      [20, 25]])
```

On concatène d'abord par ligne (axis=0), c'est-à-dire qu'on ajoute les lignes du second array a2 à celles de l'array a1 :

```
1 np.concatenate((a1, a2), axis=0)
```

```
array([[0, 1],
      [2, 3],
      [4, 5],
      [0, 5],
      [10, 15],
      [20, 25]])
```

Ensuite, on concatène par colonne (axis=1). Attention, il vaut bien veiller à ce que la concaténation soit possible en terme de dimensionnalité. Par exemple, lors de la concaténation par colonne, il faut que les deux arrays a1 et a2 aient le même nombre de lignes :

```
1 np.concatenate((a1, a2), axis=1)
```

```
array([[0, 1, 0, 5],
      [2, 3, 10, 15],
      [4, 5, 20, 25]])
```

Ces opérations de concaténation sont très importantes. On les utilise par exemple si on a des données dans plusieurs fichiers différents et qu'on veut les agréger dans un array unique. On verra qu'on peut faire le même genre de chose avec les fameux *Dataframes* du module *pandas*. Lisez bien également les recommandations dans la dernière rubrique 17.1.10 *Quelques conseils sur quand utiliser la concaténation d'arrays avec NumPy*.

## 20.7 Un peu d'algèbre linéaire

Après avoir manipulé les objets array comme des vecteurs et des matrices, voici quelques fonctions pour faire de l'algèbre linéaire.

La fonction `transpose()` renvoie la transposée<fnref n="13" /> d'un array. Par exemple, pour une matrice :

```
1 a = npomatex(np.mzeitable(1, 10), (3, 3))
2 a
```

```
array([[1, 2, 3],
      [4, 5, 6],
      [7, 8, 9]])
```

```
1 np者的 transpose(a)
```

```
array([[1, 4, 7],
      [2, 5, 8],
      [3, 6, 9]])
```

<footnote n="13">13. https://fr.wikipedia.org/wiki/Matrice_transpos%C3%A9</footnote>
Tout objet *array* possède un attribut `.T` qui contient la transposée, il est ainsi possible d'utiliser cette notation objet plus compacte :

1 a.T

array([[1, 4, 7],
      [2, 5, 8],
      [3, 6, 9]])

La fonction `dot()` permet de multiplier deux matrices<fnref n="14" /> :

1 a = npomatize(np arange(4), (2, 2))

2 a

array([[0, 1],
      [2, 3]])

1 np.dot(a, a)

array([[2, 3],
      [6, 11]])

1 a * a

array([[0, 1],
      [4, 9]])

Notez bien que `dot(a, a)` renvoie le **produit matriciel** entre deux matrices, alors que l'opération `a * a` renvoie le **produit élément par élément**.

### Remarque

Dans le module `NumPy`, il existe également des objets de type `matrix` pour lesquels les multiplications de matrices sont différents, mais nous ne les aborderons pas ici.

Pour toutes les opérations suivantes, nous utiliserons des fonctions du sous-module `linalg` de `NumPy`.

La fonction `diag()` permet de générer une matrice diagonale :

1 a = np . diag((1, 2, 3))

2 a

array([[1, 0, 0],
      [0, 2, 0],
      [0, 0, 3]])

La fonction `inv()` renvoie l'inverse d'une matrice carrée<fnref n="15" /> :

1 np . linalg . inv(a)

array([[1.0, 0.0, 0.0],
      [0.0, 0.5, 0.0],
      [0.0, 0.0, 0.33333333]])

La fonction `det()` renvoie le déterminant<fnref n="16" /> d'une matrice carrée :

<footnote n="14">14. https://fr.wikipedia.org/wiki/Produit#Produit Matriciel_ordinaire</footnote>

<footnote n="15">15. https://fr.wikipedia.org/wiki/Matrice_inversible</footnote>

<footnote n="16">16. https://fr.wikipedia.org/wiki/Calcul_du_d%3A9terminant_d%27une</footnote>
1 np.linalg det(a)

6.0

Enfin, la fonction eig() renvoie les vecteurs et valeurs propres :

1 np.linalg.eig(a)

EigResult(eigenvalues=Matrix([1., 2., 3.]), eigenvectors=Matrix([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]))

La fonction eig() renvoie un objet EigResult, qui contient les valeurs propres (eigenvalues) et les vecteurs propres (eigenvectors), qu'on peut ensuite récupérer par affectation multiple :

1 eigvals, eigvecs = np.linalg.eig(a)

2 eigvals

array([1., 2., 3.])

eigvals est un array 1D contenant les trois valeurs propres.

1 eigvecs

array([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]])

eigvecs est un array 2D contenant les trois vecteurs propres (un par ligne).

## 20.8 Parcours de matrice et affectation de lignes et colonnes

Lorsqu'on a une matrice, on est souvent amené à la parcourir par ligne ou par colonne. NumPy permet d'itérer directement sur les lignes d'une array :

1 a = np.reshape(np.arange(1, 10), (3, 3))

2 a

array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

1 for row in a:

2 print(row, type(row))

[1 2 3] <class 'numpy.ndarray'>

[4 5 6] <class 'numpy.ndarray'>

[7 8 9] <class 'numpy.ndarray'>

À chaque itération, la variable row est un array 1D correspondant à chaque ligne de la matrice a. Cela est du au fait que l'utilisation d'un indiçage unique a[i] pour un array 2D correspond à sa ligne d'indice i (voir la rubrique Indices ci-dessus).

Pour itérer sur les colonnes, on peut utiliser l'astuce d'itérer sur la transposée de l'array a, c'est-à-dire a.T :

1 for col in a.T:

2 print(col, type(col))
[1 4 7] <class 'numpy.ndarray'>
[2 5 8] <class 'numpy.ndarray'>
[3 6 9] <class 'numpy.ndarray'>

À chaque itération, la variable *col* est un *array* 1D correspondant à chaque colonne de *a*.

On se souvient de l'affectation multiple x, y = 1, 2 qui permettait d'affecter des valeurs à plusieurs variables à la fois. Il est possible d'utiliser cette fonctionnalité aussi avec les arrays NumPy :

1 a

array([[1, 2, 3],
      [4, 5, 6],
      [7, 8, 9]])

1 a1, a2, a3 = a
2 a1

array([1, 2, 3])

1 a2

array([4, 5, 6])

1 a3

array([7, 8, 9])

Par défaut, l'affectation multiple se fait sur les lignes de l'array 2D. Cette fonctionnalité s'explique à nouveau par le fait que pour NumPy, `a[i]` correspond à la ligne d'indice *i* d'un array 2D.

Pour utiliser l'affectation multiple sur les colonnes, il suffit d'utiliser la transposée `a.T` :

1 c1, c2, c3 = a.T
2 c1

array([1, 4, 7])

1 c2

array([2, 5, 8])

1 c3

array([3, 6, 9])

## 20.9 Masques booléens

Une fonctionnalité puissante des arrays NumPy est l'utilisation des masques booléens. Avant de les définir, il est important d'introduire le concept d'arrays de booléens. Jusqu'à maintenant nous avions définis uniquement des arrays avec des types numériques *int* ou *float*. Il est tout à fait possible de définir des arrays de booléens. La fonction `np.full()` vue précédemment nous permet d'en construire facilement :

1 np.full((2, 2), True)
array([[ True, True],
      [ True, True]])

1 np.full((2, 2), False)

array([[False, False],
      [False, False]])

Au premier abord, nous n'en voyons pas forcément l'utilité... Mais qu'en est-il lorsqu'on utilise les opérateurs de comparaison avec un `array` ? Et bien cela renvoie un `array` de booléens !

1 a = np.reshape(np.arange(1, 10), (3, 3))

2 a

array([[1, 2, 3],
      [4, 5, 6],
      [7, 8, 9]])

1 a > 5

array([[False, False, False],
      [False, False, True],
      [ True, True, True]])

1 a == 2

array([[False, True, False],
      [False, False, False],
      [False, False, False]])

Tous les éléments de l'`array` satisfaisant la condition seront à True, les autres à False. Il est même possible de combiner plusieurs conditions avec les opérateurs logiques & et | (respectivement **ET** et **OU**) :

1 a = np.reshape(np.arange(1, 10), (3, 3))

2 a

array([[1, 2, 3],
      [4, 5, 6],
      [7, 8, 9]])

1 (a > 3) & (a % 2 == 0)

array([[False, False, False],
      [ True, False, True],
      [False, True, False]])

1 (a > 3) | (a % 2 == 0)

array([[False, True, False],
      [ True, True, True],
      [ True, True, True]])

* Les opérateurs logiques & et | s'appliquent sur les `arrays` et sont différents des opérateurs logiques `and` et `or`, qui eux s'appliquent sur les booléens (`True` ou `False`).

* Il est conseillé de mettre entre parenthèses chaque condition afin d'éviter les ambiguïtés.
Maintenant que les arrays de booléens ont été introduits, nous pouvons définir les masques booléens :

## Définition

Les masques booléens sont des arrays de booléens qui sont utilisés en tant qu'« indice » d'un array initial. Cela permet de récupérer ou de modifier une partie de l'array initial.

Concrètement, il suffira d'utiliser un array et un opérateur de comparaison entre les crochets qui étaient dédiés à l'individage :

1 a = np.reshape(np.arange(1, 10), (3, 3))

2 a

array([[1, 2, 3],
      [4, 5, 6],
      [7, 8, 9]])

Pour isoler tous les éléments de l'array a qui sont supérieurs à 5 :

1 a[a > 5]

array([6, 7, 8, 9])

Pour isoler tous les éléments de l'array a qui sont égaux à 2 :

1 a[a == 2]

array([2])

Pour isoler tous les éléments de l'array a qui sont non nuls :

1 a[a != 0]

array([1, 2, 3, 4, 5, 6, 7, 8, 9])

À chaque fois, on ne récupère que les éléments de l'array a qui satisfont la sélection. Toutefois, il est important de remarquer que l'array renvoyé perd la dimensionnalité de l'array a initial, il s'agit systématiquement d'un array 1D.

La grande puissance de ce mécanisme est que l'on peut utiliser les masques booléens pour modifier les éléments que l'on sélectionne :

1 a = np.reshape(np.arange(1, 10), (3, 3))

2 a

array([[1, 2, 3],
      [4, 5, 6],
      [7, 8, 9]])

On sélectionne les éléments de l'array a supérieurs à 5 :

1 a[a > 5]

array([6, 7, 8, 9])

On affecte la valeur -1 aux éléments de l'array a supérieurs à 5 :

1 a[a > 5] = -1

2 a
```
array([[ 1,  2,  3],
      [ 4,  5, -1],
      [-1, -1, -1]])
```

On peut bien sûr combiner plusieurs conditions avec les opérateurs logiques :

```
1 a = np.reshape(np.arange(1, 10), (3, 3))
2 a
```

```
array([[1,  2,  3],
      [4,  5,  6],
      [7,  8,  9]])
```

```
1 a[(a > 3) | (a % 2 == 0)] = 0
2 a
```

```
array([[1,  0,  3],
      [0,  0,  0],
      [0,  0,  0]])
```

Ce mécanisme de sélection avec des masques booléens se révèle très puissant pour manipuler de grandes quantités de données. On verra qu'il peut être également utilisé avec les *Dataframes* du module *pandas*.

### Remarque

Les masques booléens ne doivent pas être confondus avec les *masked arrays*<fnref n="17" />, qui sont des *arrays* dans lesquels on peut trouver des valeurs manquantes ou invalides.

Enfin, une application possible des masques est de « binariser » une matrice de nombre :

```
1 import random
2 import numpy as np
3 a = npomatize([random.random() for i in range(16)], (4, 4))
4 a
```

```
array([[0.58704728, 0.50212977, 0.70652863, 0.24158108],
      [0.93102132, 0.41864373, 0.45807961, 0.98288744],
      [0.48198211, 0.16877376, 0.14431518, 0.74784176],
      [0.92913469, 0.08383269, 0.10670144, 0.14554345]])
```

```
1 seuil = 0.3
2 a[a < seuil] = 0
3 a[a > seuil] = 1
4 a
```

```
array([[1.,  1.,  1.,  0.],
      [1.,  1.,  1.,  1.],
      [1.,  0.,  0.,  1.],
      [1.,  0.,  0.,  0.]])
```

On obtient ce résultat avec deux lignes de code en utilisant des *arrays*, alors qu'il aurait fallu faire des boucles avec des listes classiques.

## 20.10 Quelques conseils

Nous vous avons présenté une petite partie du module `NumPy`, mais vous avez pu en constater son extraordinaire puissance. On pourrait au premier abord être tenté d'abandonner les listes, toutefois elles gardent toute leur importance.

<footnote n="17">17. https://numpy.org/doc/1.18/reference/maskedarray.html</footnote>
Alors, quand utiliser les listes ou quand utiliser les arrays NumPy ? Voici une liste non exhaustive d'éléments qui peuvent guider votre choix :

Utilisez NumPy pour :

* les opérations vectorielles (éléments par éléments);

* lorsque vous souhaitez manipuler des objets mathématiques (vecteurs, matrices, etc.) et les outils associés (algèbre linéaire);

* tout ce qui est numérique de manière générale.

Utilisez les listes :

* Lorsque vous avez besoin d'un conteneur pour accumuler des valeurs (fussent-elles des sous-listes), surtout lorsqu'elles ne sont pas homogènes (c'est-à-dire du même type).

* Lorsque vous souhaitez accumuler des valeurs au fur et à mesure des itérations d'une boucle. Pour cela, la méthode .append() des listes est bien plus efficace que de faire grandir un array ligne par ligne (c'est-à-dire en ajoutant une ligne avec np.concatenate() à chaque itération).

* Lorsqu'on ne peut pas utiliser les fonctions de lecture de fichier de NumPy pour quelque raison que ce soit, il est tout à fait classique de faire grandir une liste au fur et à mesure de la lecture du fichier puis de la convertir à la fin en array. De manière générale, utilisez np.concatenate() seulement pour concaténer des gros arrays, pas pour ajouter une seule ligne.

Enfin, comme nous vous le conseillons depuis le début, soignez votre documentation (docstrings) et vos commentaires lorsque vous utilisez des arrays. NumPy permet de réaliser des opérations vectorielles de manière très compacte. Il est donc essentiel de se mettre à la place du lecteur de votre script (y compris vous dans quelques semaines ou mois) et de documenter ce que contient chaque array ainsi que sa dimensionnalité (1D, 2D, etc.).

Le module NumPy est la brique de base du calcul numérique en Python. Associé aux modules SciPy<fnref n="18" /> et matplotlib, ainsi qu'aux notebooks Jupyter (voir le chapitre précédent), il permet de faire du calcul scientifique de manière très efficace. On verra dans le chapitre 22 Module Pandas que la puissance de NumPy est également utilisée par le module pandas pour faire de l'analyse de données.

Pour aller plus loin

* Le livre de Nicolas Rougier *From Python to Numpy*<fnref n="19" /> est une excellente ressource pour explorer plus en détails les possibilités de NumPy.

* Les tutoriels<fnref n="20" /> proposés par les développeurs de NumPy sont également un bon moyen de poursuivre votre exploration de cette bibliothèque incontournable en sciences.

## 20.11 Exercices

### Conseil

Pour ces exercices, utilisez des notebooks Jupyter.

### 20.11.1 Nombres pairs et impairs

Soit impairs un array NumPy qui contient les nombres :

1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21

En une seule instruction, construisez l'array pairs dans lequel tous les éléments de impairs sont incrémentés de 1. Comparez ce que vous venez de faire avec l'exercice « Nombres pairs et impairs » du chapitre 5 Boucles et comparaisons.

<footnote n="18">18. https://scipy.org/</footnote>

<footnote n="19">19. https://www.labri.fr/perso/nrougier/from-python-to-numpy/</footnote>

<footnote n="20">20. https://numpy.org/numpy-tutorials/index.html</footnote>
## 20.11.2 Distance entre deux atomes carbones alpha consécutifs de la barstar

La barstar est un inhibiteur de ribonucléase. C'est une protéine relativement simple qui contient 89 acides aminés. Sa structure tridimensionnelle, obtenue par résonance magnétique nucléaire (RMN), se trouve dans la *Protein Data Bank* (PDB) sous le code 1BTA.

L'objectif de cet exercice est de calculer la distance entre carbones alpha consécutifs le long de la chaîne peptidique avec module NumPy et de découvrir une anomalie.

Le morceau de code suivant vous sera utile pour extraire les coordonnées atomiques des carbones alpha de la barstar depuis un fichier PDB :

```python
1 with open("1bta.pdb", "r") as f_pdb, open("1bta_CA.txt", "w") as f_CA:
2     for ligne in f_pdb:
3         if ligne startwith("ATOM") and ligne[12:16].strip() == "CA":
4             x = ligne[30:38]
5             y = ligne[38:46]
6             z = ligne[46:54]
7         f_CA.write(f"{x} {y} {z} ")
```

* **Ligne 1.** On ouvre deux fichiers simultanément. Ici, le fichier `1bta.pdb` est ouvert en lecture (r) et le fichier `1bta_CA.txt` est ouvert en écriture (w).

* Pour chaque ligne du fichier PDB (**ligne 2**), si la ligne débute par `ATOM` et le nom de l'atome est `CA` (**ligne 3**), alors on extrait les coordonnées atomiques (**lignes 4 à 6**) et on les écrit dans le fichier `1bta_CA.txt` (**ligne 7**). Les coordonnées sont toutes enregistrées sur une seule ligne, les unes après les autres.

Voici les étapes à suivre :

### 1. Extraction des coordonnées atomiques

* Téléchargez le fichier `1bta.pdb` qui correspond à la structure de la barstar<fnref n="21" /> sur le site de la PDB (lien direct vers le fichier <fnref n="22" />).

* Utilisez le code précédent pour extraire les coordonnées atomiques des carbones alpha de la barstar.

### 2. Lecture des coordonnées

* Ouvrez le fichier `1bta_CA.txt` avec Python et créez une liste contenant toutes les coordonnées sous forme de `floats` avec les fonctions `split()` et `float()`.

* Affichez à l'écran le nombre total de coordonnées.

### 3. Construction de la matrice de coordonnées

* En ouvrant dans un éditeur de texte le fichier `1bta.pdb`, trouvez le nombre d'acides aminés qui constituent la barstar.

* Avec la fonction `array()` du module `NumPy`, convertissez la liste de coordonnées en `array`. Avec la fonction `reshape()` de `NumPy`, construisez ensuite une matrice à deux dimensions contenant les coordonnées des carbones alpha de la barstar. Affichez les dimensions de cette matrice.

### 4. Calcul de la distance

* Créez maintenant une matrice qui contient les coordonnées des $n-1$ premiers carbones alpha et une autre qui contient les coordonnées des $n-1$ derniers carbones alpha. Affichez les dimensions des matrices pour vérification.

* En utilisant les opérateurs mathématiques habituels (`-`, `+`, `**2`) et les fonctions `sqrt()` et `sum()` du module `NumPy`, calculez la distance entre les atomes $n$ et $n+1$.

* Pour chaque atome, affichez le numéro de l'atome et la distance entre carbones alpha consécutifs avec un chiffre après la virgule. Repérez la valeur surprenante.

## 20.11.3 Jour le plus chaud

Le fichier `temperature.dat`<fnref n="23" /> contient un relevé de quatre températures pour chaque jour de la semaine :

```
Lun 12 11 14 12
Mar 12 10 14 11
Mer 11 11 14 13
[...]
```

<footnote n="21">21. http://www.rcsb.org/pdb/explore.do?structureId=1BTA</footnote>

<footnote n="22">22. https://files.rcsb.org/download/1BTA.pdb</footnote>

<footnote n="23">23. https://python.sdv.u-paris.fr/data-files/temperatures.dat</footnote>
À l'aide du module `NumPy`, on souhaite déterminer quel est le jour de la semaine le plus chaud. Pour cela nous vous proposons les étapes suivantes :

1. Récupérez le nom des jours de la semaine depuis le fichier et stockez-les dans une liste `days`.

2. Récupérez les valeurs de températures depuis le fichier et stockez-les dans un array 2D. La fonction `np.loadtxt()`<sup>24</sup> et son argument `usecols` vous seront utiles.

3. Parcourez chaque ligne de la matrice, calculez la température moyenne de chaque jour puis stockez-la dans une liste `mean_temp`.

4. À l'aide des deux listes `days` et `mean_temp`, déterminez et affichez le jour le plus chaud.

### 20.11.4 Calcul du centre de masse d'une membrane

L'image de gauche de la figure 20.2 montre le cliché d'une membrane de POPC (cyan) entourée d'eau (bleu) (coordonnées trouvées ici<sup>25</sup>). Les atomes de phosphore des groupes phosphates sont représentés en boule de van der Waals brune. Dans cet exercice, on cherche à calculer le centre de masse de la membrane, ainsi que le centre de masse (COM) de chaque monocouche de phosphores. Ces COM sont représentés sous forme de croix dans le graphique de droite de la figure 20.2.

<figure><img src="image_43.png" /><figcaption>FIGURE 20.2 – Cliché d'une membrane de POPC.</figcaption></figure>

Les coordonnées cartésiennes $(x,y,z)$ de chaque atome de phosphore (en Å) sont stockées dans le fichier `coors_P.dat`<sup>26</sup>, à raison d'un atome par ligne.

Nous vous proposons les étapes suivantes pour résoudre cet exercice à l'aide du module `NumPy` :

1. Récupérez les coordonnées des atomes de phosphore depuis le fichier `coors_P.dat` et stockez-les dans un array 2D (matrice) `coors_P`. La dimensionnalité de cette matrice est $n \times 3$, avec $n$ le nombre de phosphores.

2. Calculez le $z$ moyen de tous les phosphores (nombre réel) et stockez-le dans la variable `mean_z`. La méthode `mean()` vous sera utile.

3. Avec des masques de booléens, récupérez les coordonnées des phosphores de la monocouche du haut dans un array 2D `upper`. Faites de même avec la monocouche du bas dans un array 2D `lower`.

4. Calculez le centre de masse COM de la membrane, ainsi que de la monocouche du haut `COM_lower` et du bas `COM_lower`. Pensez aux méthodes de calcul sur les arrays et l'argument `axis`.

5. Une fois tout cela effectué, créez un graphique 3D pour représenter les différents centres de masse. Utilisez la fonction `scatter()` du module ` matplotlib` pour l'affichage en 3D<sup>27</sup>. Voici un squelette de programme pour vous

<footnote n="24">24. https:// numpy.org/ doc/ stable/ reference/ generated/ numpy. loadtxt. html</footnote>

<footnote n="25">25. https:// zenodo.org/ record/ 153944</footnote>

<footnote n="26">26. https:// python.sdv.u-paris.fr/ data-files/ coors_P.dat</footnote>

<footnote n="27">27. https:// matplotlib.org/ 3.2.1/ gallery/ mplot3d/ scatter3d.html</footnote>
aider :

1 # Initialisation du graphique.
2 from mpl_toolkits.mplot3d import Axes3D
3 import matplotlib.pyplot as plt
4 fig = plt.figure()
5 ax = fig.add_subplot(111, projection="3d")
6 [...]
7 # X, Y et Z sont des arrays 1D de n éléments.
8 # Par exemple X représente tous les x des P de la monocouche upper.
9 [...]
10 # Affichage de la couche upper.
11 ax.scatter(X, Y, Z, c="salmon", marker="o")
12 # Affichage du COM de la couche upper.
13 ax.scatter(x, y, z, c="red", marker="x")
14 [...]
15 # Affichage des étiquettes des axes et du titre.
16 ax.set_xlabel("x (Å)")
17 ax.set_ylabel("y (Å)")
18 ax.set_zlabel("z (Å)")
19 ax.set_title("Graphe 3D des phosphores")
20 plt.show()
# CHAPITRE 21

## Module Matplotlib

Le module `matplotlib`<fnref n="1" /> permet de générer des graphiques depuis Python. Il est l'outil complémentaire des modules `NumPy`, `scipy` ou `pandas` (que l'on verra juste après) lorsqu'on veut faire de l'analyse de données.

### 21.1 Installation et convention

Le module `matplotlib` n'est pas fourni avec la distribution Python de base. Avec la distribution Miniconda que nous vous conseillons d'utiliser (consultez pour cela la documentation en ligne<fnref n="2" />), vous pouvez l'installer avec la commande :

```
$ conda install -c conda-forge matplotlib
```

Dans ce chapitre, nous vous montrerons quelques exemples d'utilisation du module ` matplotlib ` pour vous convaincre de sa pertinence. Ces exemples seront exécutés dans un *notebook* Jupyter.

1. Les cellules de code apparaîtront de cette manière

2. dans un *notebook* Jupyter, avec des numéros de lignes à gauche.

### 21.2 Chargement du module

On importe le module ` matplotlib ` avec la commande :

1. import matplotlib.pyplot as plt

#### Remarque

On n'importe pas le module ` matplotlib ` directement, mais plutôt son sous-module `pyplot`. Par convention, et pour l'utiliser plus rapidement, ce sous-module prendre l'alias `plt`.

### 21.3 Représentation en nuage de points

Dans cet exemple, nous considérons l'évolution de la concentration d'un produit dans le sang (exprimé en mg/L) en fonction du temps (exprimé en heures). Cet exemple est purement fictif.

<footnote n="1">1. https:// matplotlib.org /</footnote>

<footnote n="2">2. https://python.sdv.u-paris.fr / livre-dunod</footnote>
Voici les valeurs mesurées :

<table><thead><tr><td>Temps (h)</td><td>Concentration (mg/L)</td></tr></thead><tbody><tr><td>1</td><td>3.5</td></tr><tr><td>2</td><td>5.8</td></tr><tr><td>3</td><td>9.1</td></tr><tr><td>4</td><td>11.8</td></tr><tr><td>6</td><td>17.5</td></tr><tr><td>7</td><td>21.3</td></tr><tr><td>9</td><td>26.8</td></tr></tbody></table>

Nous allons maintenant représenter l'évolution de la concentration en fonction du temps :

```python
import matplotlib.pyplot as plt

temps = [1, 2, 3, 4, 6, 7, 9]
concentration = [5.5, 7.2, 11.8, 13.6, 19.1, 21.7, 29.4]

fig, ax = plt.subplots()
ax.scatter(temps, concentration, marker="o", color="blue")
ax.set_xlabel("Temps (h)")
ax.set_ylabel("Concentration (mg/L)")
ax.set_title("Concentration de produit en fonction du temps")
ax.show()
```

Dans un *notebook* Jupyter, vous devriez obtenir un graphique ressemblant à celui de la figure 21.1.

<figure><img src="image_44.png" /><figcaption>FIGURE 21.1 – Graphique produit par *matplotlib*.</figcaption></figure>

Revenons maintenant sur le code :
* **Ligne 1.** Tout d'abord, on importe le sous-module `pyplot` du module ` matplotlib` et on lui donne l'alias ` plt` pour l'utiliser plus rapidement ensuite. Cet alias est standard, utilisez-le systématiquement.

* **Lignes 3 et 4.** On définit les variables `temps` et `concentration` comme des listes. Les deux listes doivent avoir la même longueur (sept éléments dans le cas présent).

* **Ligne 6.** On crée une figure avec la fonction `subplots()` qui renvoie deux objets : une figure (fig) et un axe (ax). L'axe est l'objet qui contient le graphique à proprement dit. On peut avoir plusieurs axes dans une même figure.

* **Ligne 7.** La méthode `.scatter()` permet de représenter des points sous forme de nuage de points. Les deux premiers arguments correspondent aux valeurs en abscisse et en ordonnée des points, fournis sous forme de listes. Des arguments facultatifs sont ensuite précisés comme le symbole (marker) et la couleur (color).

* **Lignes 8 et 9.** Les méthodes `.set_xlabel()` et `.set_ylabel()` donnent une légende aux axes des abscisses et des ordonnées.

* **Ligne 10.** La méthode `.set_title()` définit le titre du graphique.

* **Ligne 11.** L'instruction ` plt.show()` affiche le graphique. Elle n'est pas nécessaire dans un *notebook* Jupyter, car le graphique est affiché automatiquement, mais elle est indispensable dans un script Python.

## 21.4 Représentation sous forme de courbe

On sait par ailleurs que l'évolution de la concentration du produit en fonction du temps peut-être modélisée par la fonction $f(x) = 2 + 3 \times x$.

### Remarque

Le modèle présenté ici est purement fictif. Vous découvrirez dans le chapitre 22 *Module Pandas* comment réaliser une régression linéaire pour modéliser des données expérimentales.

Représentons ce modèle avec les points expérimentaux et sauvegardons le graphique obtenu sous forme d'une image :

```python
1 import numpy as np
2 import matplotlib.pyplot as plt
3
4 temps = [1, 2, 3, 4, 6, 7, 9]
5 concentration = [5.5, 7.2, 11.8, 13.6, 19.1, 21.7, 29.4]
6
7 fig, ax = plt.subplots()
8 ax.scatter(temps, concentration, marker="o", color="blue")
9 ax.set_xlabel("Temps (h)")
10 ax.set_ylabel("Concentration (mg/L)")
11 ax.set_title("Concentration de produit en fonction du temps")
12
13 x = np.linspace(min(temps), max(temps), 50)
14 y = 2 + 3 * x
15
16 ax.plot(x, y, color="green", ls="--")
17 ax.grid()
18 fig.savefig("concentration_vs_temp_1.png", bbox_inches="tight", dpi=200)
```

Le résultat est représenté sur la figure 21.2.

Les étapes supplémentaires par rapport au graphique précédent (figure 21.1) sont :

* **Ligne 1.** On charge le module ` numpy ` sous le nom ` np `.

* **Ligne 13.** On crée la variable `x` avec la fonction `linspace()` du module `NumPy`, qui renvoie une liste de valeurs régulièrement espacées entre deux bornes, ici entre le minimum (`min(temps)`) et le maximum (`max(temps)`) de la variable `temps`. Dans notre exemple, nous générons une liste de 50 valeurs. La variable `x` ainsi créée est du type `array`.

* **Ligne 14.** On construit ensuite la variable `y` à partir de la formule modélisant l'évolution de la concentration du produit en fonction du temps. Cette manipulation n'est possible que parce que `x` est du type `array`. Cela ne fonctionnerait pas avec une liste classique.

* **Ligne 16.** La méthode `.plot()` construit une courbe à partir des coordonnées en abscisse et en ordonnées des
<figure><img src="image_45.png" /><figcaption>FIGURE 21.2 – Concentration du produit en fonction du temps.</figcaption></figure>

points à représenter. On indique ensuite des arguments facultatifs comme le style de la ligne (ls pour line style) et sa couleur (color).

* **Ligne 17.** La méthode `grid()` affiche une grille.

* **Ligne 18.** Enfin, l'instruction `fig.savefig()` enregistre le graphique produit sous la forme d'une image au format png. Des arguments par mot-clé définissent la manière de générer les marges autour du graphique (bbox_inches) et la résolution de l'image (dpi).

Pour terminer, on peut améliorer un peu plus le graphique en ajoutant une légende et en modifiant l'étendue des axes des abscisses et des ordonnées :
```python
import numpy as np
import matplotlib.pyplot as plt

temps = [1, 2, 3, 4, 6, 7, 9]
concentration = [5.5, 7.2, 11.8, 13.6, 19.1, 21.7, 29.4]

fig, ax = plt.subplots()
ax.scatter(temps, concentration, marker="o", color="blue", label="mesures")
ax.set_xlabel("Temps (h)")
ax.set_ylabel("Concentration (mg/L)")
ax.set_title("Concentration de produit en fonction du temps")

x = np.linspace(min(temps), max(temps), 50)
y = 2 + 3 * x
ax.plot(x, y, color="green", ls="--", label="modèle")
ax.grid()
ax.set_xlim(0, 10)
ax.set_ylim(0, 35)
ax.legend(loc="upper left")
fig savefig("concentration_vs_temps_2.png", bbox_inches="tight", dpi=200)
```

On obtient alors le graphique représenté dans la figure 21.3.

<figure><img src="image_46.png" /><figcaption>FIGURE 21.3 – Concentration du produit en fonction du temps, version améliorée.</figcaption></figure>

Les différences notables par rapport au code précédent sont :

* **Lignes 8 et 16.** On ajoute le paramètre `label` pour donner un nom au nuage de points (`.scatter()`) ou à la courbe (`.plot()`).

* **Lignes 19 et 20.** On définit l'étendue de l'axe des abscisses avec la méthode `.set_xlim()` et de l'axe des
Prenons le temps d'examiner les différentes étapes du script précédent :

* **Lignes 4 à 6.** On définit les variables *sequence*, *bases* et *distribution*.

* **Lignes 7 et 8.** On calcule la distribution des différentes bases dans la séquence. On utilise pour cela la méthode `count()`, qui renvoie le nombre de fois qu'une chaîne de caractères (les différentes bases) se trouve dans une autre (la séquence).

* **Ligne 10.** On définit la position en abscisse des barres. Dans cet exemple, la variable `x` vaut `array([0, 1, 2, 3])`.

* **Ligne 12.** On crée le graphique.

* **Ligne 13.** La méthode `.bar()` construit le diagramme en bâtons. Elle prend en argument la position des barres (`x`) et leurs hauteur (`distribution`).

* **Ligne 14.** La méthode `.set_xtics()` redéfinit les étiquettes (c'est-à-dire le nom des bases) sur l'axe des abscisses.

* **Lignes 15 à 17.** On définit les légendes des axes et le titre du graphique. On insère un retour à la ligne `\n` dans le titre pour qu'il soit réparti sur deux lignes.

* **Ligne 18.** Enfin, on enregistre le graphique généré au format `png`.

On espère que ces courts exemples vous auront convaincu de l'utilité du module `matplotlib`. Sachez qu'il peut faire bien plus, par exemple générer des histogrammes ou toutes sortes de graphiques utiles en analyse de données. Il est existe par ailleurs d'autres bibliothèques pour produire des graphiques avec Python, comme *Seaborn*<fnref n="3" />, *Bokeh*<fnref n="4" /> ou *Plotly*<fnref n="5" />. Ces deux dernières permettent de générer des graphiques interactifs, c'est-à-dire des graphiques dans lesquels on peut zoomer, se déplacer, etc. Nous vous invitons à les découvrir par vous-même.

Pour aller plus loin

* Le site de `matplotlib` fournit de nombreux exemples détaillés<fnref n="6" />, n'hésitez pas à le consulter.

* Le site Python Graph Gallery<fnref n="7" /> propose aussi des exemples de code pour différents types de graphiques, réalisés avec `matplotlib` ou d'autres bibliothèques.

* Enfin, des *cheat sheets*<fnref n="8" /> de `matplotlib` sont extrêmement utiles et très bien faites.

<footnote n="3">3. https://seaborn.pydata.org/</footnote>

<footnote n="4">4. http://bokeh.org/</footnote>

<footnote n="5">5. https://plotly.com/</footnote>

<footnote n="6">6. https:// matplotlib.org / gallery / index.html</footnote>

<footnote n="7">7. https://www.python-graph-gallery.com/matplotlib/</footnote>

<footnote n="8">8. https:// matplotlib.org / cheatsheets/</footnote>
# CHAPITRE 22

## Module Pandas

Le module *pandas*<fnref n="1" /> a été conçu pour l'analyse de données. Il est particulièrement puissant pour manipuler des données structurées sous forme de tableau.

### 22.1 Installation et convention

Le module *pandas* n'est pas fourni avec la distribution Python de base. Avec la distribution Miniconda que nous vous conseillons d'utiliser (consultez pour cela la documentation en ligne <fnref n="2" />), vous pouvez rapidement l'installer avec la commande :

```
$ conda install -c conda-forge pandas
```

Vous aurez également besoin des modules ` matplotlib` pour créer des graphiques et ` scipy` pour réaliser une régression linaire, que vous pouvez installer ainsi :

```
$ conda install -c conda-forge matplotlib scipy
```

Dans ce chapitre, nous vous montrerons quelques exemples d'utilisation du module *pandas* pour vous convaincre de sa pertinence. Ces exemples seront exécutés dans un *notebook* Jupyter.

1. Les cellules de code apparaîtront de cette manière

2. dans un notebook Jupyter, avec des numéros de lignes à gauche.

Les résultats seront affichés de cette manière, éventuellement sur plusieurs lignes.

### 22.2 Chargement du module

Pour charger *pandas* dans la mémoire de Python, on utilise la commande `import habituelle` :

1. `import pandas`

Par convention, on utilise `pd` comme nom raccourci pour *pandas* :

1. `import pandas as pd`

<footnote n="1">1. https://pandas.pydata.org/</footnote>

<footnote n="2">2. https://python.sdv.u-paris.fr/livre-dunod</footnote>
## 22.3 Series

Le premier type de données apporté par *pandas* est la *Series*, qui correspond à un vecteur à une dimension.

1 s = pd.Series([10, 20, 30, 40], index = ['a', 'b', 'c', 'd'])

2 s

a 10

b 20

c 30

d 40

dtype: int64

### 22.3.1 Sélections par étiquette ou indice

Avec *pandas*, chaque élément de la série de données possède une étiquette qui permet d'appeler les éléments qui la composent. Ainsi, pour appeler le premier élément de la série, on peut utiliser son étiquette (ici, "a") :

1 s["a"]

10

Pour accéder au premier élément par son indice (ici 0), comme on le ferait avec une liste, on utilise la méthode `iloc` :

1 s.iloc[0]

10

Bien sûr, on peut extraire plusieurs éléments, par leurs indices ou leurs étiquettes :

1 s[["b", "d"]]

b 20

d 40

dtype: int64

et

1 s.iloc[[1, 3]]

b 20

d 40

dtype: int64

### 22.3.2 Modifications de Series

Les étiquettes permettent de modifier et d'ajouter des éléments :

1 s["c"] = 300

2 s["z"] = 50

3 s

a 10

b 20

c 300

d 40

z 50

dtype: int64
### 22.3.3 Filtres

Enfin, on peut filtrer une partie de la *Series* :

1 s[s>30]

```
c   300
d   40
z   50
dtype: int64
```

**Remarque**

Cette écriture rappelle celle des masques booléens dans le chapitre 20 *Module NumPy*.

Enfin, on peut aussi combiner plusieurs critères de sélection avec les opérateurs logiques & (pour **ET**) et | (pour **OU**) :

1 s[(s>20) & (s<100)]

```
d   40
z   50
dtype: int64
```

1 s[(s<15) | (s>150)]

```
a   10
c   300
dtype: int64
```

## 22.4 Dataframes

Un autre type d'objet particulièrement intéressant introduit par *pandas* sont les *Dataframes*. Ceux-ci correspondent à des tableaux à deux dimensions avec des étiquettes pour nommer les lignes et les colonnes.

**Remarque**

Si vous êtes familier avec le langage de programmation et d'analyse statistique R, les *Dataframes* de *pandas* se rapprochent de ceux trouvés dans R.

### 22.4.1 Création

Voici comment créer un *Dataframe* avec *pandas* à partir de données fournies comme liste de lignes :

```
1 import numpy as np
2 df = pd.DataFrame columns = ["a", "b", "c", "d"],
3 index = ["chat", "singe", "souris"],
4 data = [np. arange (10, 14),
5 np. arange (20, 24),
6 np. arange (30, 34)]
7 df
```

```
       a   b   c   d
chat   10  11  12  13
singe  20  21  22  23
souris  30  31  32  33
```

Voici quelques commentaires sur le code précédent :
* **Ligne 1.** On charge le module `NumPy` utilisé ensuite.

* **Ligne 2.** Le *Dataframe* est créé avec la fonction `Frame()` à laquelle on fournit plusieurs arguments. L'argument `columns` indique le nom des colonnes, sous forme d'une liste.

* **Ligne 3.** L'argument `index` définit le nom des lignes, sous forme de liste également.

* **Lignes 4 à 6.** L'argument `data` fournit le contenu du *Dataframe*, sous la forme d'une liste de valeurs correspondantes à des lignes. Ainsi, `np.arange(10, 14)` qui est équivalent à `[10, 11, 12, 13]` correspond à la première ligne du *Dataframe*.

Le même *Dataframe* peut aussi être créé à partir des valeurs fournies en colonnes sous la forme d'un dictionnaire :

```
1 data = {"a": np.arange(10, 40, 10),
         "b": np.arange(11, 40, 10),
         "c": np.arange(12, 40, 10),
         "d": np.arange(13, 40, 10)}
```

```
5 df = pd.DataFrame(data)
```

```
6 df.index = ["chat", "singe", "souris"]
```

```
7 df
```

<table><thead><tr><th></th><th>a</th><th>b</th><th>c</th><th>d</th></tr></thead><tbody><tr><td>chat</td><td>10</td><td>11</td><td>12</td><td>13</td></tr><tr><td>singe</td><td>20</td><td>21</td><td>22</td><td>23</td></tr><tr><td>souris</td><td>30</td><td>31</td><td>32</td><td>33</td></tr></tbody></table>

* **Lignes 1 à 4.** Le dictionnaire `data` contient les données en colonnes. La clé associée à chaque colonne est le nom de la colonne.

* **Ligne 5.** Le *dataframe* est créé avec la fonction `pd.DataFrame()` à laquelle on passe `data` en argument.

* **Ligne 6.** On peut définir les étiquettes des lignes de n'importe quel *dataframe* avec l'attribut `df.index`.

### 22.4.2 Quelques propriétés

Les dimensions d'un *dataframe* sont données par l'attribut `.shape` :

```
1 df.shape
```

(3, 4)

Ici, le *dataframe* `df` possède trois lignes et quatre colonnes.

L'attribut `.columns` renvoie le nom des colonnes et permet aussi de renommer les colonnes d'un *dataframe* :

```
1 df.columns
```

```
Index(['a', 'b', 'c', 'd'], dtype='object')
```

```
1 df.columns = ['Paris', 'Lyon', 'Nantes', 'Pau']
```

```
2 df
```

<table><thead><tr><th></th><th>Paris</th><th>Lyon</th><th>Nantes</th><th>Pau</th></tr></thead><tbody><tr><td>chat</td><td>10</td><td>11</td><td>12</td><td>13</td></tr><tr><td>singe</td><td>20</td><td>21</td><td>22</td><td>23</td></tr><tr><td>souris</td><td>30</td><td>31</td><td>32</td><td>33</td></tr></tbody></table>

La méthode `.head(n)` renvoie les n premières lignes du *Dataframe* (par défaut, n vaut 5) :

```
1 df head(2)
```

<table><thead><tr><th></th><th>Paris</th><th>Lyon</th><th>Nantes</th><th>Pau</th></tr></thead><tbody><tr><td>chat</td><td>10</td><td>11</td><td>12</td><td>13</td></tr><tr><td>singe</td><td>20</td><td>21</td><td>22</td><td>23</td></tr></tbody></table>
### Remarque

Les Dataframes utilisés ici comme exemples sont volontairement petits. Si vous êtes confrontés à des Dataframes de grande taille, ceux-ci seront affichés partiellement dans un notebook Jupyter. Des ascenseurs en bas et à droite du Dataframe permettront de naviguer dans les données.

## 22.4.3 Sélections

Les mécanismes de sélection fournis avec `pandas` sont très puissants. En voici un rapide aperçu :

### 22.4.3.1 Sélection de colonnes

On peut sélectionner une colonne par son étiquette :

1 df["Lyon"]

<table><tr><td>chat</td><td>11</td></tr><tr><td>singe</td><td>21</td></tr><tr><td>souris</td><td>31</td></tr></table>

La notation `df["Lyon"]` sélectionne une colonne et renvoie un objet `Series` :

1 type(df["Lyon"])

pandas.core series.Series

### Attention

On trouve parfois l'écriture `df.Lyon` pour sélectionner une colonne. C'est une très mauvaise pratique, car cette écriture peut être confondue avec un attribut de l'objet `df` (par exemple `.shape`). Par ailleurs, elle ne fonctionne pas pour des noms de colonnes qui contiennent des espaces ou des caractères spéciaux (ce qui n'est pas non plus une bonne pratique).

Nous vous conseillons de toujours utiliser la notation `df["nom_de_colonne"]`.

Pour sélectionner plusieurs colonnes, il faut fournir une liste de noms de colonnes :

1 df["Lyon", "Pau"]

<table><thead><tr><th></th><th>Lyon</th><th>Pau</th></tr></thead><tbody><tr><td>chat</td><td>11</td><td>13</td></tr><tr><td>singe</td><td>21</td><td>23</td></tr><tr><td>souris</td><td>31</td><td>33</td></tr></tbody></table>

On obtient cette fois un `Dataframe` avec les colonnes sélectionnées :

1 type(df["Lyon", "Pau"])

pandas.core frame.DataFrame

### Remarque

La sélection de plusieurs colonnes nécessite une liste entre les crochets, par exemple `df["Lyon", "Pau"]`. Si on utilise un tuple du type `df["Lyon", "Pau"]`, Python renvoie une erreur `KeyError: ('Lyon', 'Pau')`.
### 22.4.3.2 Sélection de lignes

Pour sélectionner une ligne, il faut utiliser l'instruction `.loc` et l'étiquette de la ligne :

1. df.loc["singe"]

```
Paris      20
Lyon       21
Nantes     22
Pau        23
Name: singe, dtype: int64
```

Ici aussi, on peut sélectionner plusieurs lignes :

1. df.loc["singe", "chat"]

```
          Paris   Lyon   Nantes   Pau
singe      20     21     22      23
chat       10     11     12      13
```

Enfin, on peut aussi sélectionner des lignes avec l'instruction `.iloc` et l'indice de la ligne (la première ligne ayant l'indice 0) :

1. df iloc [1]

```
Paris      20
Lyon       21
Nantes     22
Pau        23
Name: singe, dtype: int64
```

1. df .iloc[[1, 0]]

```
          Paris   Lyon   Nantes   Pau
singe      20     21     22      23
chat       10     11     12      13
```

On peut également utiliser les tranches (comme pour les listes) :

1. df .iloc[0:2]

```
          Paris   Lyon   Nantes   Pau
chat       10     11     12      13
singe      20     21     22      23
```

### 22.4.3.3 Sélection sur les lignes et les colonnes

On peut bien sûr combiner les deux types de sélection (en ligne et en colonne) :

1. df .loc["souris", "Pau"]

33

1. df .loc["singe", "souris"], ["Nantes", "Lyon"]

```
          Nantes   Lyon
singe      22     21
souris     32     31
```
Notez qu'à partir du moment où on souhaite effectuer une sélection sur des lignes, il faut utiliser `.loc` (ou `.iloc` si on utilise les indices).

### 22.4.3.4 Sélection par condition

Remémorons-nous d'abord le contenu du *dataframe* `df` :

1 df

<table><thead><tr><th></th><th>Paris</th><th>Lyon</th><th>Nantes</th><th>Pau</th></tr></thead><tbody><tr><th>chat</th><td>10</td><td>11</td><td>12</td><td>13</td></tr><tr><th>singe</th><td>20</td><td>21</td><td>22</td><td>23</td></tr><tr><th>souris</th><td>30</td><td>31</td><td>32</td><td>33</td></tr></tbody></table>

Sélectionnons maintenant toutes les lignes pour lesquelles les effectifs à Pau sont supérieurs à 15 :

1 df[ df["Pau"]>15 ]

<table><thead><tr><th></th><th>Paris</th><th>Lyon</th><th>Nantes</th><th>Pau</th></tr></thead><tbody><tr><th>singe</th><td>20</td><td>21</td><td>22</td><td>23</td></tr><tr><th>souris</th><td>30</td><td>31</td><td>32</td><td>33</td></tr></tbody></table>

De cette sélection, on ne souhaite garder que les valeurs pour Lyon :

1 df[ df["Pau"]>15 ]["Lyon"]

```
singe      21
souris     31
Name: Lyon, dtype: int64
```

On peut aussi combiner plusieurs conditions avec `&` pour l'opérateur `et` :

1 df[ (df["Pau"]>15) & (df["Lyon"])>25 ]

<table><thead><tr><th></th><th>Paris</th><th>Lyon</th><th>Nantes</th><th>Pau</th></tr></thead><tbody><tr><th>souris</th><td>30</td><td>31</td><td>32</td><td>33</td></tr></tbody></table>

et | pour l'opérateur **ou** :

1 df[ (df["Pau"]>15) | (df["Lyon"])>25 ]

<table><thead><tr><th></th><th>Paris</th><th>Lyon</th><th>Nantes</th><th>Pau</th></tr></thead><tbody><tr><th>singe</th><td>20</td><td>21</td><td>22</td><td>23</td></tr><tr><th>souris</th><td>30</td><td>31</td><td>32</td><td>33</td></tr></tbody></table>

### 22.4.4 Combinaison de dataframes

En biologie, on a souvent besoin de combiner deux tableaux à partir d'une colonne commune. Par exemple, si on considère les deux *dataframes* suivants :

1 data1 = {

2 df1 = pd.DataFrame.from_dict(data1)

3 df1.index = ["chat", "singe", "souris"]

4 df1

<table><thead><tr><th></th><th>Lyon</th><th>Paris</th></tr></thead><tbody><tr><th>chat</th><td>10</td><td>3</td></tr><tr><th>singe</th><td>23</td><td>15</td></tr><tr><th>souris</th><td>17</td><td>20</td></tr></tbody></table>

et
1 data2 = {"Nantes": [3, 9, 14], "Strasbourg": [5, 10, 8]}

2 df2 = pd.DataFrame.from_dict(data2)

3 df2.index = ["chat", "souris", "lapin"]

4 df2

<table><thead><tr><th></th><th>Nantes</th><th>Strasbourg</th></tr></thead><tbody><tr><td>chat</td><td>3</td><td>5</td></tr><tr><td>souris</td><td>9</td><td>10</td></tr><tr><td>lapin</td><td>14</td><td>8</td></tr></tbody></table>

On souhaite combiner ces deux *dataframes*, c'est-à-dire connaître pour les quatre villes (Lyon, Paris, Nantes et Strasbourg) le nombre d'animaux. On remarque d'ores et déjà qu'il y a des singes à Lyon et Paris, mais pas de lapin et qu'il y a des lapins à Nantes et Strasbourg, mais pas de singe. Nous allons voir comment gérer cette situation.

Pandas propose pour cela la fonction `concat()`<fnref n="3" />, qui prend comme argument une liste de *dataframes* :

1 pd.concat([df1, df2])

<table><thead><tr><th></th><th>Lyon</th><th>Nantes</th><th>Paris</th><th>Strasbourg</th></tr></thead><tbody><tr><td>chat</td><td>10.0</td><td>NaN</td><td>3.0</td><td>NaN</td></tr><tr><td>singe</td><td>23.0</td><td>NaN</td><td>15.0</td><td>NaN</td></tr><tr><td>souris</td><td>17.0</td><td>NaN</td><td>20.0</td><td>NaN</td></tr><tr><td>chat</td><td>NaN</td><td>3.0</td><td>NaN</td><td>5.0</td></tr><tr><td>souris</td><td>NaN</td><td>9.0</td><td>NaN</td><td>10.0</td></tr><tr><td>lapin</td><td>NaN</td><td>14.0</td><td>NaN</td><td>8.0</td></tr></tbody></table>

Ici, `NaN` indique des valeurs manquantes, cela signifie littéralement *Not a Number*. Mais le résultat obtenu n'est pas celui que nous attendions, puisque les lignes de deux *dataframes* ont été recopiées.

L'argument supplémentaire `axis=1` produit le résultat attendu :

1 pd.concat([df1, df2], axis=1)

<table><thead><tr><th></th><th>Lyon</th><th>Paris</th><th>Nantes</th><th>Strasbourg</th></tr></thead><tbody><tr><td>chat</td><td>10.0</td><td>3.0</td><td>3.0</td><td>5.0</td></tr><tr><td>lapin</td><td>NaN</td><td>NaN</td><td>14.0</td><td>8.0</td></tr><tr><td>singe</td><td>23.0</td><td>15.0</td><td>NaN</td><td>NaN</td></tr><tr><td>souris</td><td>17.0</td><td>20.0</td><td>9.0</td><td>10.0</td></tr></tbody></table>

Par défaut, *pandas* va conserver le plus de lignes possible. Si on ne souhaite conserver que les lignes communes aux deux *dataframes*, il faut ajouter l'argument `join="inner"` :

1 pd.concat([df1, df2], axis=1, join="inner")

<table><thead><tr><th></th><th>Lyon</th><th>Paris</th><th>Nantes</th><th>Strasbourg</th></tr></thead><tbody><tr><td>chat</td><td>10</td><td>3</td><td>3</td><td>5</td></tr><tr><td>souris</td><td>17</td><td>20</td><td>9</td><td>10</td></tr></tbody></table>

Un autre comportement par défaut de `concat()` est que cette fonction va combiner les *dataframes* en se basant sur leurs index. Il est néanmoins possible de préciser, pour chaque *dataframe*, le nom de la colonne qui sera utilisée comme référence avec l'argument `join_axes`.

### 22.4.5 Opérations vectorielles

Pour cette rubrique, créons un *Dataframe* composé de nombres aléatoires compris entre 100 et 200, répartis en trois colonnes (a, b et c) et 1 000 lignes :

<footnote n="3">3. https://pandas.pydata.org/pandas-docs/stable/merging.html</footnote>
```python
1 import numpy as np
2 import pandas as pd
3
4 nb_rows = 1000
5 df = pd.DataFrame(
6     {
7         "a": np.random.random(100, 200, nb_rows),
8         "b": np.random.random(100, 200, nb_rows),
9         "c": np.random.random(100, 200, nb_rows),
10     }
11 )
```

Vérifions que ce Dataframe a bien les propriétés attendues :

```python
1 df.shape
```

(1000, 3)

```python
1 df head()
```

<table><thead><tr><th></th><th>a</th><th>b</th><th>c</th></tr></thead><tbody><tr><td>0</td><td>105</td><td>156</td><td>122</td></tr><tr><td>1</td><td>116</td><td>135</td><td>138</td></tr><tr><td>2</td><td>125</td><td>190</td><td>113</td></tr><tr><td>3</td><td>196</td><td>175</td><td>179</td></tr><tr><td>4</td><td>129</td><td>184</td><td>153</td></tr></tbody></table>

On souhaite maintenant créer une nouvelle colonne (d) qui sera le résultat de la multiplication des colonnes a et b, à laquelle on ajoute ensuite la colonne c.

Une première manière de faire est de procéder ligne par ligne. La méthode `.itertools()` permet de parcourir les lignes d'un Dataframe et renvoie un tuple contenant l'indice de la ligne (sous la forme d'un entier) et la ligne elle-même (sous la forme d'une Series) :

```python
1 for idx, row in df.itertools():
2     df.at[idx, "d"] = (row["a"] * row["b"]) + row["c"]
```

Ici, l'instruction `.at` ajoute une cellule à la ligne d'indice `idx` et de colonne `d`. Cette instruction est plus efficace que `.loc` pour ajouter une cellule à un Dataframe.

L'approche précédente produit le résultat attendu, mais elle n'est pas optimale, car très lente. Pour évaluer le temps moyen pour réaliser ces opérations, on utilise la commande magique `%%timeit` abordée dans le chapitre 18 *Jupyter et ses notebooks* :

```python
1 %%timeit
2 for idx, row in df.itertools():
3     df.at[idx, "d"] = (row["a"] * row["b"]) + row["c"]
```

qui renvoie :

52.4 ms ± 3.6 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Cette cellule de code s'exécute en moyenne en 52,4 ms.

Une autre approche, plus efficace, consiste à réaliser les opérations directement sur les colonnes (et non plus ligne par ligne) :

```python
1 %%timeit
2 df["d"] = (df["a"] * df["b"]) + df["c"]
```

qui renvoie :

250 µs ± 36.1 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

Ici, la cellule de code s'exécute en moyenne en 250 µs, soit environ 200 fois (52400/250) plus rapidement qu'avec
.itertools() . Tout comme avec les arrays du chapitre 20 Numpy , les opérations vectorielles avec les Dataframes sont rapides et efficaces. Privilégiez toujours ce type d’approche avec les arrays de NumPy ou les Series et Dataframes de pandas .

Remarque

Dans l'exemple précédent, l'utilisation de la commande magique %%timeit calcule le temps d'exécution moyen d'une
cellule. Python détermine automatiquement le nombre d'itérations à réaliser pour que le calcul se fasse dans un temps
raisonnable. Ainsi, pour la méthode .itertools(), le calcul est réalisé 10 fois sur sept répétitions alors que pour les
opérations vectorielles, le calcul est effectué 1000 fois sur sept répétitions.

22.5 Un exemple plus concret avec les kinases

Pour illustrer les possibilités de *pandas*, voici un exemple plus concret sur un jeu de données de kinases<fnref n="4" />. Les kinases sont des protéines responsables de la phosphorylation d'autres protéines.

Le fichier kinases.csv que vous pouvez télécharger en ligne<fnref n="5" /> contient des informations tirées de la base de données de séquences UniProt pour quelques kinases.

Si vous n'êtes pas familier avec le format de fichier .csv, nous vous conseillons de consulter l'annexe A *Quelques formats de données en biologie*.

Remarque

Avant de nous lancer dans l'analyse de ce fichier, nous vous proposons cette petite devinette :

Qu'est-ce qu'une protéine dans une piscine ?

La réponse sera donnée à la fin de ce chapitre.

22.5.1 Prise de contact avec le jeu de données

Une fonctionnalité très intéressante de *pandas* est d'ouvrir très facilement un fichier au format .csv :

1 df = pd.read_csv("kinases.csv")

Le contenu est chargé sous la forme d'un Dataframe dans la variable df.

Le fichier contient 1 442 lignes de données plus une ligne d'en-tête. Cette dernière est automatiquement utilisée par
*pandas* pour nommer les différentes colonnes. Voici un aperçu des premières lignes :

1 df head()

<table>
  <thead>
    <tr>
      <th></th>
      <th>Entry</th>
      <th>Organism</th>
      <th>Length</th>
      <th>Creation date</th>
      <th>Mass</th>
      <th>PDB</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>A0A0B4J2F2</td>
      <td>Human</td>
      <td>783</td>
      <td>2018-06-20</td>
      <td>84930</td>
      <td>NaN</td>
    </tr>
    <tr>
      <td>1</td>
      <td>A4L9P5</td>
      <td>Rat</td>
      <td>1211</td>
      <td>2007-07-24</td>
      <td>130801</td>
      <td>NaN</td>
    </tr>
    <tr>
      <td>2</td>
      <td>A0A1D6E0S8</td>
      <td>Maize</td>
      <td>856</td>
      <td>2023-05-03</td>
      <td>93153</td>
      <td>NaN</td>
    </tr>
    <tr>
      <td>3</td>
      <td>A0A8I5ZNK2</td>
      <td>Rat</td>
      <td>528</td>
      <td>2023-09-13</td>
      <td>58360</td>
      <td>NaN</td>
    </tr>
    <tr>
      <td>4</td>
      <td>A1Z7T0</td>
      <td>Fruit fly</td>
      <td>1190</td>
      <td>2012-01-25</td>
      <td>131791</td>
      <td>NaN</td>
    </tr>
  </tbody>
</table>

Nous avons six colonnes de données :

* l'identifiant de la protéine (Entry);

* l'organisme d'où provient cette protéine (Organism);

* le nombre d'acides aminés qui constituent la protéine (Length);

* la date à laquelle cette protéine a été déréférencée dans UniProt (Creation date);

* la masse de la protéine (Mass), exprimée en Dalton ;

* les éventuelles structures 3D de la protéine (PDB).

<footnote n="4">4. https://fr.wikipedia.org/wiki/Kinase</footnote>

<footnote n="5">5. https://python.sdv.u-paris.fr/data-files/kinases.csv</footnote>
La colonne d'entiers tout à gauche est un index automatiquement créé par *pandas*.

Nous pouvons demander à *pandas* d'utiliser une colonne particulière comme index. On utilise pour cela le paramètre `index_col` de la fonction `read_csv()`. Ici, la colonne `Entry` s'y prête très bien, car cette colonne ne contient que des identifiants uniques :

1 df = pd.read_csv("kinases.csv", index_col="Entry")

2 df head()

<table><thead><tr><th></th><th>Organism</th><th>Length</th><th>Creation date</th><th>Mass</th><th>PDB</th></tr></thead><tbody><tr><td>Entry</td><td></td><td></td><td></td><td></td><td></td></tr><tr><td>A0A0B4J2F2</td><td>Human</td><td>783</td><td>2018-06-20</td><td>84930</td><td>NaN</td></tr><tr><td>A4L9P5</td><td>Rat</td><td>1211</td><td>2007-07-24</td><td>130801</td><td>NaN</td></tr><tr><td>A0A1D6E0S8</td><td>Maize</td><td>856</td><td>2023-05-03</td><td>93153</td><td>NaN</td></tr><tr><td>A0A8I5ZNK2</td><td>Rat</td><td>528</td><td>2023-09-13</td><td>58360</td><td>NaN</td></tr><tr><td>A1Z7T0</td><td>Fruit fly</td><td>1190</td><td>2012-01-25</td><td>131791</td><td>NaN</td></tr></tbody></table>

## Remarque

La fonction `.read_csv()` permet également d'ouvrir un fichier au format TSV (voir l'annexe A *Quelques formats de données en biologie*). Il faut pour cela préciser que le séparateur des colonnes de données est une tabulation (`\t`), avec l'argument `sep="\t"`.

Avant d'analyser un jeu de données, il est intéressant de l'explorer un peu. Par exemple, connaître ses dimensions :

1 df.shape

(1442, 5)

Notre jeu de données contient donc 1 442 lignes et 5 colonnes. En effet, la colonne `Entry` est maintenant utilisée comme index et n'est donc plus prise en compte.

Il est aussi intéressant de savoir de quel type de données est constituée chaque colonne :

1 df.dtypes

<table><tr><td>Organism</td><td>object</td></tr><tr><td>Length</td><td>int64</td></tr><tr><td>Creation date</td><td>object</td></tr><tr><td>Mass</td><td>int64</td></tr><tr><td>PDB</td><td>object</td></tr><tr><td>dtpe: object</td><td></td></tr></table>

Les colonnes `Length` et `Mass` contiennent des valeurs numériques, en l'occurrence des entiers (`int64`). Le type `object` est un type par défaut.

La méthode `.info()` permet d'aller un peu plus loin dans l'exploration du jeu de données en combinant les informations produites par les propriétés `.shape` et `.dtypes` :

1 df.info()

```python
<class 'pandas.core.frame.DataFrame'>
Index: 1442 entries, A0A0B4J2F2 to Q5F361
Data columns (total 5 columns):
#   Column          Non-Null Count  Dtype
---   -----------     -----------------   -----
0   Organism       1442 non-null    object
1   Length         1442 non-null    int64
2   Creation date  1442 non-null    object
3   Mass           1442 non-null    int64
4   PDB            488 non-null     object
dtypes: int64(2), object(3)
memory usage: 67.6+ KB
```
Mais le type de données de la colonne `Creation date` est maintenant une date (`datetime64[ns]`):

1 df.dtypes

```
Organism          object
Length           int64
Creation date    datetime64[ns]
Mass             int64
PDB              object
dtype: object
```

### 22.5.4 Statistiques descriptives et table de comptage

Pour les colonnes qui contiennent des données numériques, on peut obtenir rapidement quelques statistiques descriptives avec la méthode `.describe()`:

1 df.Describe()

```
          Length      Creation date      Mass
count   1442.000000   1442              1442.000000
mean    756.139390   2001-01-25 16:10:39.112344064   84710.753814
min     81.000000    1986-07-21 00:00:00    9405.000000
25%     476.250000   1996-10-01 00:00:00    54059.000000
50%     632.000000   2002-03-10 00:00:00    71613.000000
75%     949.250000   2005-11-22 00:00:00   105485.250000
max     2986.000000   2023-09-13 00:00:00   340261.000000
std     404.195273    NaN               44764.273097
```

On apprend ainsi que la taille de la protéine (colonne `Length`) a une valeur moyenne de 756,14 acides aminés et que la plus petite protéine est composée de 81 acides aminés et la plus grande de 2 986. Pratique !

Des statistiques sont également proposées pour la colonne `Creation date`. La protéine la plus récente a ainsi été référencée le 13 septembre 2023.

La colonne `Organism` contient des chaînes de caractères, on peut rapidement déterminer le nombre de protéines pour chaque organisme :

1 df.["Organism").value_counts()

```
Organism
Human          489
Mouse          489
Rat            253
Fruit fly     103
Chicken        75
Rabbit         25
Maize          8
Name: count, dtype: int64
```

On apprend ainsi que 489 protéines sont d'origine humaine (Human) et 8 proviennent du maïs (Maize).

### 22.5.5 Statistiques par groupe

On peut aussi déterminer, pour chaque organisme, la taille et la masse moyenne des kinases :

1 df.groupby(["Organism"]).["Length", "Mass"]).mean()

```
          Length      Mass
Organism
Chicken      720.160000  81120.880000
Fruit fly   784.844660  88154.669903
Human        771.004090  86281.190184
Maize        666.875000  73635.000000
Mouse        768.092025  85942.274029
Rabbit       591.480000  66754.200000
Rat          722.379447  81081.822134
```
La méthode `groupby()` rassemble d'abord les données suivant la colonne `Organism`. Puis on sélectionne les colonnes `Length` et `Mass`. Enfin, la méthode `.mean()` calcule la moyenne pour chaque groupe.

Si on souhaite obtenir deux statistiques (par exemple les valeurs minimale et maximale) en une seule fois, il convient alors d'utiliser la méthode `.pivot_table()`, méthode plus complexe, mais aussi beaucoup plus puissante :

```python
1 df.pivot_table(
2   index="Organism",
3   values=["Length", "Mass"],
4   aggfunc=["min", "max"]
5 )
```

<table><thead><tr><th rowspan="2"></th><th>min</th><th colspan="2">max</th></tr><tr><th>Length</th><th>Mass</th><th>Length</th><th>Mass</th></tr></thead><tbody><tr><td>Organism</td><td></td><td></td><td></td><td></td></tr><tr><td>Chicken</td><td>303</td><td>34688</td><td>2311</td><td>260961</td></tr><tr><td>Fruit fly</td><td>294</td><td>33180</td><td>2554</td><td>287025</td></tr><tr><td>Human</td><td>253</td><td>28160</td><td>2986</td><td>340261</td></tr><tr><td>Maize</td><td>294</td><td>33834</td><td>996</td><td>105988</td></tr><tr><td>Mouse</td><td>244</td><td>27394</td><td>2964</td><td>337000</td></tr><tr><td>Rabbit</td><td>81</td><td>9405</td><td>1382</td><td>158347</td></tr><tr><td>Rat</td><td>274</td><td>31162</td><td>2959</td><td>336587</td></tr></tbody></table>

* L'argument `index` précise la colonne dont on veut agréger les données.

* L'argument `values` indique sur quelles colonnes les statistiques sont calculées.

* Enfin, `aggfunc` liste les statistiques calculées, ici les valeurs minimale et maximale.

Notez que les valeurs renvoyées sont d'abord les valeurs minimales pour `Length` et `Mass` puis les valeurs maximales pour `Length` et `Mass`.

### 22.5.6 Analyse de données numériques

On peut, sans trop de risque, émettre l'hypothèse que plus il y a d'acides aminés dans la protéine, plus sa masse va être élevée.

Pour vérifier cela graphiquement, on représente la masse de la protéine en fonction de sa taille (c'est-à-dire du nombre d'acides aminés) :

```python
1 import matplotlib.pyplot as plt
2
3 fig, ax = plt.subplots()
4 ax.scatter(df["Length"], df["Mass"])
5 ax.set_xlabel("Taille (nombre d'acides aminés)")
6 ax.set_ylabel("Masse (Dalton)")
7 fig.savefig("kinases1.png")
```

On obtient un graphique similaire à celui de la figure 22.1.

Avec `pandas`, on peut aussi appeler une méthode `.plot()` sur un `Dataframe` pour obtenir une représentation graphique identique à la figure 22.1 :

```python
1 import matplotlib.pyplot as plt
2
3 df.plot(
4   kind="scatter",
5   x="Length",
6   y="Mass",
7   xlabel="Taille (nombre d'acides aminés)",
8   ylabel="Masse (Dalton)"
9 )
10 plt.savefig("kinases1.png")
```

* **Ligne 4.** On spécifie le type de graphique. Ici, un nuage de points.

* **Lignes 5 et 6.** On précise les colonnes à utiliser pour les abscisses et les ordonnées.

Le graphique de la figure 22.1 met en évidence une relation linéaire entre le nombre de résidus d'une protéine et sa masse.
<figure><img src="image_47.png" /><figcaption>FIGURE 22.1 – Masse en fonction de la taille.</figcaption></figure>

En réalisant une régression linéaire, on peut déterminer les paramètres de la droite qui passent le plus proche possible des points du graphique. On utilise pour cela la fonction `linregress()`<fnref n="6" /> du module `scipy.stats`:

```python
1 from scipy.stats import linregress
2 model = linregress(df["Length"], df["Mass"])
3 model
```

```
LinregressResult(slope=110.63478918698122, intercept=1055.431834679228,
rvalue=0.9989676084416755, pvalue=0.0, stderr=0.13258187632073232,
intercept_stderr=113.66584551734655)
```

Ce modèle linéaire nous indique qu'un résidu a une masse d'environ 111 Dalton, ce qui est cohérent. On peut également
comparer ce modèle aux différentes protéines :

```python
1 fig, ax = plt.subplots()
2 ax.scatter(df["Length"], df["Mass"], label="données")
3 ax.plot(
4     df["Length"],
5     df["Length"]*model.slope + model.intercept,
6     ls=":",
7     label="modèle"
8 )
9 ax.set_xlabel("Taille (nombre d'acides aminés)")
10 ax.set_ylabel("Masse (Dalton)")
11 ax.legend()
12 fig.savefig("kinases2.png")
```

On obtient ainsi le graphique de la figure 22.2.

### 22.5.7 Analyse de données temporelles

Il peut être intéressant de savoir, pour chaque organisme, quand les premières et les dernières séquences de kinases ont été référencées dans UniProt.

<footnote n="6">6. https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.linregress.html</footnote>
<figure><img src="image_48.png" /><figcaption>FIGURE 22.2 – Masse en fonction de la taille des protéines.</figcaption></figure>

La méthode .pivot_table() apporte des éléments de réponse :

1 df.pivot_table(
2 index="Organism",
3 values=["Creation date"],
4 aggfunc=["min", "max"]
5 )

<table><thead><tr><th></th><th>min</th><th>max</th></tr></thead><tbody><tr><td>Organism</td><td></td><td></td></tr><tr><td>Chicken</td><td>1986-07-21</td><td>2021-02-10</td></tr><tr><td>Fruit fly</td><td>1986-07-21</td><td>2023-09-13</td></tr><tr><td>Human</td><td>1986-07-21</td><td>2018-06-20</td></tr><tr><td>Maize</td><td>1990-08-01</td><td>2023-05-03</td></tr><tr><td>Mouse</td><td>1986-07-21</td><td>2017-03-15</td></tr><tr><td>Rabbit</td><td>1986-07-21</td><td>2010-03-02</td></tr><tr><td>Rat</td><td>1986-07-21</td><td>2023-09-13</td></tr></tbody></table>

Chez le poulet (Chicken), la première séquence a été référencée le 21 juillet 1986 et la dernière le 10 février 2021.

Une autre question est de savoir combien de kinases ont été référencées en fonction du temps.

La méthode .value_counts() peut être utilisée, mais elle ne renvoie que le nombre de protéines référencées dans UniProt pour un jour donné. Par exemple, 40 structures ont été référencées le 28 novembre 2006 :

1 df["Creation date").value_counts().head()

```
Creation date
1997-11-01   72
1996-10-01   58
2000-12-01   43
2000-05-30   41
2006-11-28   40
Name: count, dttype: int64
```
Si on souhaite une réponse plus globale, par exemple à l'échelle de l'année, la méthode `resample()` calcule le nombre de protéines référencées par année (en fournissant l'argument `YE`). En utilisant le *method chaining* présenté dans le chapitre 11 *Plus sur les chaînes de caractères*, nous pouvons écrire toutes ces transformations en une seule instruction, répartie sur plusieurs lignes pour plus de lisibilité (en utilisant des parenthèses) :

```
1 (df["Creation date"]
2 .value_counts()
3 .resample("YE")
4 .count()
5 .head()
6 )
```

```
Creation date
1986-12-31 2
1987-12-31 1
1988-12-31 4
1989-12-31 2
1990-12-31 4
Freq: YE-DEC, Name: count, dtype: int64
```

Les dates apparaissent maintenant comme le dernier jour de l'année, mais désignent bien l'année complète. Dans cet exemple, une seule kinase a été référencée dans UniProt entre le 1er janvier et le 31 décembre 1987.

Pour connaître en quelle année le plus de kinases ont été référencées dans UniProt, il faut trier les valeurs obtenues du plus grand au plus petit avec la méthode `sort_values()`. Comme on ne veut connaître que les premières dates (celles où il y a eu le plus de protéines référencées), on utilisera également la méthode `head()` :

```
1 (df["Création date"]
2 .value_counts()
3 .resample("YE")
4 .count()
5 .sort_values(ascending=False)
6 .head()
7 )
```

```
Creation date
2005-12-31 21
2004-12-31 20
2003-12-31 19
2006-12-31 18
2007-12-31 17
Name: count, dtype: int64
```

En 2005, 21 kinases ont été référencées dans UniProt. La deuxième « meilleure » année est 2004 avec 20 protéines. Toutes ces méthodes, enchaînées les unes à la suite des autres, peuvent vous sembler complexes, mais chacune d'elles correspond à une étape du traitement des données. Bien sûr, on aurait pu créer des variables intermédiaires pour chaque étape, mais cela aurait été plus lourd :

```
1 date1 = df["Creation date").value_counts()
2 date2 = date1.resample("YE")
3 date3 = date2.count()
4 date4 = date3.sort_values(ascending=False)
5 date4-head()
```

On aurait obtenu exactement le même résultat.

## Remarque

Le *method chaining*<sup>7</sup> est une manière efficace et élégante de traiter des données avec *pandas*.

Enfin, pour obtenir un graphique de l'évolution du nombre de kinases référencées dans UniProt en fonction du temps,

<footnote n="7">7. https://www.youtube.com/watch?v=39MEeDLxGGg</footnote>
on peut encore utiliser le *method chaining* :

```python
1 (df["Creation date"]
2 .value_counts()
3 .resample("YE")
4 .count()
5 .plot()
6 )
7 plt.savefig("kinases3.png")
```

On obtient ainsi le graphique de la figure 22.3.

<figure><img src="image_49.png" /><figcaption>FIGURE 22.3 – Évolution temporelle du nombre de kinases référencées dans UniProt.</figcaption></figure>

On observe un pic du nombre de kinases référencées dans UniProt sur la période 2001-2009.

### 22.5.8 Transformation d'une colonne

Nous avons vu précédemment que la colonne PDB contenait de nombreuses valeurs manquantes (NaN). Toutefois, il est intéressant de savoir ce que peut contenir cette colonne quand elle n'est pas vide :

```python
1 (df
2 .loc[~ df["PDB"].isna() ]
3 .head(3)
4 )
```

<table><thead><tr><th>Entry</th><th>Organism</th><th>Length</th><th>Creation date</th><th>Mass</th><th>PDB</th></tr></thead><tbody><tr><td>A2CG49</td><td>Mouse</td><td>2964</td><td>2007-10-23</td><td>337000</td><td>1WFW;7UR2;</td></tr><tr><td>D3ZMK9</td><td>Rat</td><td>1368</td><td>2018-07-18</td><td>147716</td><td>6EWX;</td></tr><tr><td>000141</td><td>Human</td><td>431</td><td>1998-12-15</td><td>48942</td><td>2R5T;3HDM;3HDN;7PUE;</td></tr></tbody></table>

* **Ligne 2.** La méthode `isna()` sélectionne les lignes qui contiennent des valeurs manquantes dans la colonne PDB, puis l'opérateur `~` inverse cette sélection.

* **Ligne 3.** On limite l'affichage aux trois premières lignes.

On découvre que la colonne PDB contient des identifiants de structures 3D de protéines. Ces identifiants sont séparés par des points-virgules, y compris pour la dernière valeur.
Nous souhaitons compter le nombre de structures 3D pour chaque protéine. Pour cela, nous allons d'abord créer une fonction qui compte le nombre de points-virgules dans une chaîne de caractères :

```python
1 def count_structures(row):
2     if pd.isna(row['PDB']):
3         return 0
4     else:
5         return row['PDB'].count(";")
```

Dans la ligne 2, la méthode `isna()` teste si la valeur est manquante et si ce n'est pas le cas, la fonction renvoie le nombre de points-virgules dans la chaîne de caractères de la colonne `PDB` (ligne 5).

On applique ensuite la fonction `count_structures()` au *Dataframe* avec la méthode `apply()`. On crée la nouvelle colonne `nb_structures` en même temps :

```python
1 df['nb_structures'] = df.apply(count_structures, axis=1)
```

```python
2 df head()
```

<table><thead><tr><th>Entry</th><th>Organism</th><th>Length</th><th>Creation date</th><th>Mass</th><th>PDB</th><th>nb_structures</th></tr></thead><tbody><tr><td>A0A0B4J2F2</td><td>Human</td><td>783</td><td>2018-06-20</td><td>84930</td><td>NaN</td><td>0</td></tr><tr><td>A4L9P5</td><td>Rat</td><td>1211</td><td>2007-07-24</td><td>130801</td><td>NaN</td><td>0</td></tr><tr><td>A0A1D6E0S8</td><td>Maize</td><td>856</td><td>2023-05-03</td><td>93153</td><td>NaN</td><td>0</td></tr><tr><td>A0A8I5ZNK2</td><td>Rat</td><td>528</td><td>2023-09-13</td><td>58360</td><td>NaN</td><td>0</td></tr><tr><td>A1Z7T0</td><td>Fruit fly</td><td>1190</td><td>2012-01-25</td><td>131791</td><td>NaN</td><td>0</td></tr></tbody></table>

Les premières lignes ne sont pas très intéressantes, car elles ne contiennent pas de structures 3D. Mais on peut chercher les kinases qui ont le plus de structures 3D :

```python
1 (df
2 .sort_values(by="nb_structures", ascending=False)
3 .filter(["Organism", "nb_structures"])
4 .head()
5 )
```

* Ligne 2. On trie les données par ordre décroissant de la colonne `nb_structures`.

* Ligne 3. On ne conserve que les colonnes `Organism` et `nb_structures` à afficher.

* Ligne 4. On limite l'affichage aux cinq premières lignes.

<table><thead><tr><th>Organism</th><th>nb_structures</th></tr></thead><tbody><tr><td>P24941</td><td>Human 453</td></tr><tr><td>P00533</td><td>Human 284</td></tr><tr><td>Q16539</td><td>Human 245</td></tr><tr><td>P68400</td><td>Human 238</td></tr><tr><td>P11309</td><td>Human 176</td></tr></tbody></table>

La kinase P24941 possède 453 structures 3D référencées dans UniProt. Les cinq kinases qui ont le plus de structures 3D sont toutes d'origine humaine.

### Pour aller plus loin

Les ouvrages *Python for Data Analysis* (2022) de Wes McKinney et *Effective Pandas* (2021) de Matt Harrison sont d'excellentes références pour *pandas*.

### Remarque

La réponse à la devinette précédente est :

*Une protéine kinase*

*(Une protéine qui nage... dans une piscine... Vous l'avez ?)*
## 22.6 Exercices

**Conseil**

Pour ces exercices, utilisez des notebooks Jupyter.

### 22.6.1 Analyse d'un jeu de données

Le jeu de données `people.csv` contient les caractéristiques de quelques individus : prénom, sexe, taille (en cm) et âge (en années). Par exemple :

<table><thead><tr><th>name</th><th>sex</th><th>size</th><th>age</th></tr></thead><tbody><tr><td>simon</td><td>male</td><td>175</td><td>33</td></tr><tr><td>clara</td><td>female</td><td>167</td><td>45</td></tr><tr><td>serge</td><td>male</td><td>181</td><td>44</td></tr><tr><td>claire</td><td>female</td><td>174</td><td>31</td></tr><tr><td>...</td><td>...</td><td>...</td><td>...</td></tr></tbody></table>

L'objectif de cet exercice est de manipuler ce jeu de données avec `pandas`, de sélectionner des données et d'en calculer quelques statistiques.

**Conseil**

Si vous n'êtes pas familier avec le format de fichier `.tsv`, nous vous conseillons de consulter l'annexe A *Quelques formats de données en biologie*.

#### 1. Chargement du jeu de données

* Téléchargez le fichier `people.csv`<fnref n="8" />.

* Ouvrez ce fichier avec `pandas` et la fonction `read_csv()`. N'oubliez pas de préciser le séparateur par défaut avec l'argument `sep="\t"`. Utilisez également l'argument `index_col` pour utiliser la colonne `name` comme index.

* Affichez les six premières lignes du jeu de données.

* Combien de lignes contient le jeu de données ?

#### 2. Sélections

* Déterminez la taille de Claire.

* Déterminez l'âge de Baptiste.

* Affichez, en une seule commande, l'âge de Paul et Bob.

#### 3. Statistiques descriptives et table de comptage

* Déterminez la moyenne et la valeur minimale de la taille et l'âge des individus.

* Comptez ensuite le nombre de personnes de chaque sexe.

#### 4. Statistiques par groupe

* Déterminez la taille et l'âge moyen chez les hommes et les femmes. Utilisez pour cela la méthode `groupby()`.

#### 5. Sélections par filtre

* Déterminez combien de d'individus mesurent plus de 1,80 m.

* Quelle femme a moins de 35 ans ?

#### 6. Sélections et statistiques

* Déterminez l'âge moyen des individus qui mesurent plus de 1,80 m.

* Déterminez la taille maximale des femmes qui ont plus de 35 ans.

<footnote n="8">8. https://python.sdv.u-paris.fr/data-files/people.tsv</footnote>
# Avoir la classe avec les objets

La programmation orientée objet (POO) est un concept de programmation très puissant qui permet de structurer ses programmes d'une manière nouvelle. En POO, on définit un « objet » qui peut contenir des « attributs » ainsi que des « méthodes » qui agissent sur lui-même. Par exemple, on définit un objet « citron » qui contient les attributs « saveur » et « couleur », ainsi qu'une méthode « presser » permettant d'en extraire le jus. En Python, on utilise une « classe » pour construire un objet. Dans notre exemple, la classe correspondrait au « moule » utilisé pour construire autant d'objets citrons que nécessaire.

## Définition

Une **classe** définit des **objets**, qui sont des **instances** (des représentants) de cette classe. Dans ce chapitre, on utilisera les mots **objet** ou **instance** pour désigner la même chose. Les objets peuvent posséder des **attributs** (variables associées aux objets) et des **méthodes** (qui sont des fonctions associées aux objets et qui peuvent agir sur ces derniers, ou encore les utiliser).

Dans les chapitres précédents, nous avons déjà mentionné qu'en Python tout est objet. Une variable de type *int* est en fait un objet de type *int*, donc construit à partir de la classe *int*. Même chose pour les *float* et *string*, mais aussi pour les *list*, *tuple*, *dict*, etc. Voilà pourquoi nous avons rencontré de nombreuses notations et mots de vocabulaire associés à la POO depuis le début de ce cours.

La POO permet de produire du code plus compact et plus facilement réutilisable. L'utilisation de classes évite l'utilisation de variables globales en créant ce qu'on appelle un *espace de noms*, propre à chaque objet et permettant d'y *encapsuler* des attributs et des méthodes. De plus, la POO amène de nouveaux concepts tels que le *polymorphisme* (capacité à redéfinir le comportement des opérateurs), ou bien encore l'héritage (capacité à définir une classe à partir d'une classe pré-existante et d'y ajouter de nouvelles fonctionnalités). Tous ces concepts seront définis dans ce chapitre.

Malgré tous ces avantages, la POO peut paraître difficile à aborder pour le débutant, spécialement dans la conception des programmes. Elle nécessite donc la lecture de nombreux exemples, mais surtout beaucoup de pratique. Bien structurer ses programmes en POO est un véritable art. Il existe même des langages qui formalisent la construction de programmes orientés objets, par exemple le langage UML<fnref n="1" />.

Dans ce chapitre, nous vous donnerons tous les éléments pour démarrer la construction de vos premières classes. Le chapitre 24 *Avoir plus la classe avec les objets* (en ligne) abordera des aspects plus poussés de la POO, comme le polymorphisme, la composition, l'héritage, certains pièges à éviter, ainsi que des bonnes pratiques.

<footnote n="1">1. https://fr.wikipedia.org/wiki/UML_(informatique)</footnote>
Après la lecture de ces deux chapitres sur la POO avec Python, vous verrez d'un autre œil de nombreux exemples évoqués dans les chapitres précédents, et vous comprendrez sans doute de nombreuses subtilités qui avaient pu vous paraître absconses.

Enfin, il est vivement recommandé de lire ces deux chapitres sur la POO avant d'aborder le chapitre 25 *Fenêtres graphiques et Tkinter* (en ligne).

## 23.1 Construction d'une classe

Nous allons voir dans cette rubrique comment définir une classe en reprenant notre exemple sur le citron, que nous allons faire évoluer et complexifier. Attention, certains exemples sont destinés à vous montrer comment les classes fonctionnent, mais leur utilisation n'aurait pas de sens dans un vrai programme. Ainsi, nous vous donnerons plus loin dans ce chapitre les pratiques recommandées.

### 23.1.1 La classe minimale

En Python, le mot-clé *class* permet de créer sa propre classe, suivi du nom de cette classe. On se souvient, un nom de classe commence toujours par une majuscule (voir le chapitre 16 *Bonnes pratiques en programmation Python*). Comme d'habitude, cette ligne attend un bloc d'instructions indenté définissant le corps de la classe. Voyons un exemple simple dans l'interpréteur :

```python
1 >>> class Citron:
2     ... pass
3     ...
4 >>> Citron
5 <class '__main__.Citron'>
6 >>> type(Citron)
7 <class 'type'>
8 >>> citron1 = Citron()
9 >>> citron1
10 <__main__.Citron object at 0x7ff2193a20f0>
11 >>>
```

**Ligne 1.** La classe *Citron* est définie. Pas besoin de parenthèses comme avec les fonctions dans un cas simple comme celui-là (nous verrons d'autres exemples plus loin où elles seront nécessaires).

**Ligne 2.** La classe ne contient rien, mais il faut mettre au moins une ligne, on met donc ici le mot-clé Python *pass* qui ne fait rien (comme dans une fonction qui ne fait rien).

**Lignes 4 et 5.** Quand on tape le nom de notre classe *Citron*, Python nous indique que cette classe est connue.

**Lignes 6 et 7.** Lorsqu'on regarde le type de notre classe *Citron*, Python nous indique qu'il s'agit d'un type au même titre que *type(int)*. Nous avons donc créé un nouveau type !

**Ligne 8.** On crée une instance de la classe *Citron*, c'est-à-dire qu'on fabrique un représentant ou objet de la classe *Citron*, que nous nommons *citron1*.

**Lignes 9 et 10.** Lorsqu'on tape le nom de l'instance *citron1*, l'interpréteur nous rappelle qu'il s'agit d'un objet de type *Citron*, ainsi que son adresse en mémoire.

Il est également possible de vérifier qu'une instance est bien issue d'une classe donnée avec la fonction `isinstance()` :

```python
1 >>> isinstance(citron1, Citron)
2 True
```

### 23.1.2 Ajout d'un attribut d'instance

Reprenons notre classe *Citron* et l'instance *citron1* créée précédemment. Regardons les attributs et méthodes que cet objet possède, puis tentons de lui ajouter un attribut :

```python
1 >>> dir(citron1)
2 ['__class__', '__delattr__', '__dict__', [...], '__weakref__']
3 >>> citron1.couleur = "jaune"
4 >>> dir(citron1)
5 ['__class__', '__delattr__', '__dict__', [...], '__weakref__', 'couleur']
6 >>> citron1.couleur
7 'jaune'
```
**Lignes 1 et 2.** L'objet possède de nombreuses méthodes ou attributs, qui commencent et qui se terminent par deux caractères *underscores*. On se souvient que les *underscores* indiquent qu'il s'agit de méthodes ou attributs destinés au fonctionnement interne de l'objet. Nous reviendrons sur certains d'entre-eux dans la suite.

**Ligne 3.** Ici on ajoute un attribut `.couleur` à l'instance `citron1`. Notez bien la syntaxe `instance.attrribut` et le point qui lie les deux.

**Lignes 4 à 5.** La fonction `dir()` nous montre que l'attribut `.couleur` a bien été ajouté à l'objet.

**Lignes 6.** La notation `instance.attrribut` donne accès à l'attribut de l'objet.

L'attribut nommé `.__dict__` est particulièrement intéressant. Il s'agit d'un dictionnaire qui listera les attributs créés dynamiquement dans l'instance en cours :

```python
1 >>> citron1 = Citron()
2 >>> citron1.__dict__
3 {}
4 >>> citron1.couleur = "jaune"
5 >>> citron1.__dict__
6 {'couleur': 'jaune'}
```

L'ajout d'un attribut depuis l'extérieur de la classe (on parle aussi du côté « client ») avec une syntaxe `instance.nouvel_attrribut = valeur`, créera ce nouvel attribut uniquement pour cette instance :

```python
1 citron1 = Citron()
2 citron1.couleur = "jaune"
3 >>> citron1.__dict__
4 {'couleur': 'jaune'}
5 >>> citron2 = Citron()
6 >>> citron2.__dict__
7 {}
```

Si on crée une nouvelle instance de Citron, ici `citron2`, elle n'aura pas l'attribut `couleur` à sa création.

### Définition

Une variable ou attribut d'instance est une variable accrochée à une instance et qui lui est spécifique. Cet attribut n'existe donc pas forcément pour toutes les instances d'une classe donnée et, d'une instance à l'autre, il ne prendra pas forcément la même valeur. On peut retrouver tous les attributs d'instance d'une instance donnée avec une syntaxe `instance.__dict__`.

L'instruction `del` fonctionne bien sûr pour détruire un objet (par exemple : `del citron1`), mais permet également de détruire un attribut d'instance. Si on reprend notre exemple `citron1 ci-dessus :`

```python
1 >>> citron1.__dict__
2 {'couleur': 'jaune'}
3 >>> del citron1.couleur
4 >>> citron1.__dict__
5 {}
```

Dans la suite, on montrera du code à tester dans un script : n'hésitez pas, comme d'habitude, à le tester par vous-même.

### 23.1.3 Les attributs de classe

Si on ajoute une variable dans une classe comme on créait une variable locale dans une fonction, on crée ce qu'on appelle un attribut de classe :

```python
1 class Citron:
2     couleur = "jaune"
```

#### Définition
Une **variable de classe** ou **attribut de classe** est un attribut qui sera identique pour chaque instance. On verra plus bas que de tels attributs suivent des règles différentes par rapport aux attributs d'instance.

À l'extérieur ou à l'intérieur d'une classe, un attribut de classe peut se retrouver avec une syntaxe `NomClasseattribut` :

```python
1 print(Citron.couleur)
```

Ce code affiche jaune. L'attribut de classe est aussi visible depuis n'importe quelle instance :

```python
1 class Citron:
2     couleur = "jaune"
3
4
5 if __name__ == "__main__":
6     citron1 = Citron()
7     print(citron1.couleur)
8     citron2 = Citron()
9     print(citron2.couleur)
```

L'exécution de ce code affichera :

```
jaune
jaune
```

### Attention

Même si on peut retrouver un attribut de classe avec la syntaxe `instance.attrribut`, un tel attribut ne peut pas être modifié avec une instruction de cette forme :

```python
1 instance.attrribut = nouvelle_valeur
```

(voir la rubrique *Différence entre les attributs de classe et d'instance*).

### 23.1.4 Les méthodes

Dans notre classe, on pourra aussi ajouter des fonctions.

#### Définition

Une fonction définie au sein d'une classe est appelée **méthode**. Pour exécuter une méthode à l'extérieur de la classe, la syntaxe générale est `instance.méthode()`. En général, on distingue attributs et méthodes (comme nous le ferons systématiquement dans ce chapitre). Toutefois, il faut garder à l'esprit qu'une méthode est finalement un objet de type fonction. Ainsi, elle peut être vue comme un attribut également, concept que vous croiserez peut-être en consultant de la documentation externe.

Voici un exemple d'ajout d'une fonction, ou plus exactement d'une méthode, au sein d'une classe (attention à l'indentation !) :

```python
1 class Citron:
2     def coucou(self):
3         print("Coucou, je suis la mth .coucou() dans la classe Citron !")
4
5
6 if __name__ == "__main__":
7     citron1 = Citron()
8     citron1.coucou()
```

**Lignes 2 et 3.** On définit une méthode nommée `.coucou()`, qui va afficher un petit message. Attention, cette méthode prend obligatoirement un argument que nous avons nommé ici `self`. Nous verrons dans les deux prochaines
rubriques la signification de ce self. Si on a plusieurs méthodes dans une classe, on saute toujours une ligne entre elles afin de faciliter la lecture (comme pour les fonctions).

**Ligne 7 et 8.** On crée l'instance citron1 de la classe Citron, puis on exécute la méthode .coucou() avec une syntaxe instance.méthode().

Une méthode étant une fonction, elle peut bien sûr retourner une valeur :

```python
1 class Citron:
2     def recup_saveur(self):
3         return "acide"
4
5
6 if __name__ == "__main__":
7     citron1 = Citron()
8     saveur_citron1 = citron1.recup_saveur()
9     print.saveur_citron1)
```

Vous l'aurez deviné, ce code affichera acide à l'écran. Comme pour les fonctions, une valeur retournée par une méthode est récupérable dans une variable, ici saveur_citron1.

### 23.1.5 Le constructeur

Lors de l'instanciation d'un objet à partir d'une classe, il peut être intéressant de lancer des instructions, comme, d'initialiser certaines variables. Pour cela, on ajoute une méthode spéciale nommée`__init__()`: cette méthode s'appelle le « constructeur » de la classe. Il s'agit d'une méthode spéciale dont le nom est entouré de doubles underscores : en effet, elle sert au fonctionnement interne de notre classe et, sauf cas extrêmement rare, elle n'est pas supposée être lancée comme une fonction classique par l'utilisateur de la classe. Ce constructeur est exécuté à chaque instanciation de notre classe, et ne renvoie pas de valeur, il ne possède donc pas de return.

#### Remarque

Pour les débutants, vous pouvez sauter cette remarque. Certains auteurs préfèrent nommer `__init__()` « instantiateur » ou « initialisateur », pour signifier qu'il existe une autre méthode appelée `__new__()`, qui participe à la création d'une instance. Vous n'avez bien sûr pas à retenir ces détails pour continuer la lecture de ce chapitre, retenez simplement que nous avons décidé de nommer la méthode `__init__()` « constructeur » dans cet ouvrage.

Pour bien comprendre comment cela fonctionne, nous allons suivre un exemple simple avec le site Python Tutor<fnref n="2" /> (déjà utilisé dans les chapitres 10 et 13 sur les fonctions). N'hésitez pas à copier/coller ce code dans Python Tutor pour le tester vous-même :

```python
1 class Citron:
2     def __init__(self):
3         self.couleur = "jaune"
4
5
6 if __name__ == "__main__":
7     citron1 = Citron()
8     print(citron1.couleur)
```

#### Étape 1

Figure 23.1. Au départ, Python Tutor nous montre que la classe Citron a été mise en mémoire, elle contient pour l'instant la méthode `__init__()`.

#### Étape 2

Figure 23.2. Nous créons ensuite l'instance citron1 à partir de la classe Citron. Notre classe Citron contenant une méthode `__init__()` (le constructeur), celle-ci est immédiatement exécutée au moment de l'instanciation. Cette méthode prend un argument nommé self : cet argument est obligatoire. Il s'agit en fait d'une référence vers l'instance

<footnote n="2">2. http://www.python tutor.com</footnote>
<figure><img src="image_50.png" /><figcaption>FIGURE 23.1 – Fonctionnement d'un constructeur (étape 1).</figcaption></figure>

en cours (instance que nous appellerons citron1 dans le programme principal, mais cela serait vrai pour n'importe quel autre nom d'instance). Python Tutor nous indique cela par une flèche pointant vers un espace nommé Citron instance. La signification du self est expliquée en détail dans la rubrique suivante.

<figure><img src="image_51.png" /><figcaption>FIGURE 23.2 – Fonctionnement d'un constructeur (étape 2).</figcaption></figure>

### Étape 3

Figure 23.3. Un nouvel attribut est créé s'appelant self.couleur. La chaîne de caractères couleur est ainsi « accrochée » (grâce au caractère point) à l'instance en cours référencée par le self. Python Tutor nous montre cela par une flèche qui pointe depuis le self vers la variable couleur (qui se trouve elle-même dans l'espace nommé Citron instance). Si d'autres attributs étaient créés, ils seraient tous répertoriés dans cet espace Citron instance. Vous l'aurez compris, l'attribut couleur est donc une variable d'instance (voir rubrique Ajout d'un attribut d'instance ci-dessus). La méthode __init__() étant intrinsèquement une fonction, Python Tutor nous rappelle qu'elle ne renvoie rien (d'où le None dans la case Return value), une fois son exécution terminée. Et comme avec les fonctions classiques, l'espace mémoire contenant les variables locales à cette méthode va être détruit une fois son exécution terminée.
<figure><img src="image_52.png" /><figcaption>FIGURE 23.3 – Fonctionnement d'un constructeur (étape 3).</figcaption></figure>

### Étape 4

Figure 23.4. De retour dans le programme principal, *Python Tutor* nous indique que `citron1` est une instance de la classe `Citron` par une flèche pointant vers l'espace `Citron` instance. Cette instance contient un attribut nommé `couleur` auquel on accéde avec la syntaxe `citron1.couleur` dans le `print()`. Notez que si l'instance s'était appelée `enorme_citron`, on aurait utilisé `enorme_citron.couleur` pour accéder à l'attribut `couleur`.

<figure><img src="image_53.png" /><figcaption>FIGURE 23.4 – Fonctionnement d'un constructeur (étape 4).</figcaption></figure>

### Conseil

Dans la mesure du possible, nous vous conseillons de créer tous les attributs d'instance dont vous aurez besoin dans le constructeur `__init__()` plutôt que dans toute autre méthode. Ainsi, ils seront visibles dans toute la classe dès l'instanciation.

### 23.1.6 Passage d'argument(s) à l'instanciation

Lors de l'instanciation, il est possible de passer des arguments au constructeur. Comme pour les fonctions, on peut passer des arguments positionnels ou par mot-clé, et en créer autant que l'on veut (voir chapitre 10 *Fonctions*). Voici un exemple :
```python
1 class Citron:
2     def __init__(self, masse, couleur="jaune"):
3         self.masse = masse
4         self.couleur = couleur
5
6
7     if __name__ == "__main__":
8         citron1 = Citron(100)
9         print("citron1:", citron1.__dict__)
10        citron2 = Citron(150, couleur="blanc")
11        print("citron2:", citron2.__dict__)
```

On a ici un argument positionnel (masse) et un autre par mot-clé (couleur). Le code donnera la sortie suivante :

```
1 citron1: {'masse': 100, 'couleur': 'jaune'}
```

```
2 citron2: {'masse': 150, 'couleur': 'blanc'}
```

### 23.1.7 Mieux comprendre le rôle du self

Cette rubrique va nous aider à mieux comprendre le rôle du self à travers quelques exemples simples. Regardons le code suivant dans lequel nous créons une nouvelle méthode .affiche_attributes() :

```python
1 class Citron:
2     def __init__(self, couleur="jaune"):
3         self.couleur = couleur
4         var = 2
5
6     def affiche_attributes(self):
7         print(self)
8         print(self.couleur)
9         print(var)
10
11
12     if __name__ == "__main__":
13         citron1 = Citron()
14         citron1.affiche_attributes()
```

**Ligne 3.** On crée l'attribut couleur que l'on accroche à l'instance avec self.

**Ligne 4.** Nous créons cette fois-ci une variable var sans l'accrocher à self.

**Ligne 6.** Nous créons une nouvelle méthode dans la classe Citron qui se nomme .affiche_attributes(). Comme pour le constructeur, cette méthode prend comme premier argument une variable obligatoire, que nous avons à nouveau nommée self. Il s'agit encore une fois d'une référence vers l'objet ou instance créé(e).

**Attention**

On peut appeler cette référence comme on veut, toutefois nous vous conseillons vivement de l'appeler self, car c'est une convention en Python. Ainsi, quelqu'un qui lira votre code comprendra immédiatement de quoi il s'agit.

**Ligne 7.** Cette ligne va afficher le contenu de la variable self.

**Lignes 8 et 9.** On souhaite que notre méthode .affiche_attributes() affiche ensuite l'attribut de classe .couleur ainsi que la variable var créée dans le constructeur .__init__() .

L'exécution de ce code donnera :

```python
$ python classe_exemple1.py
<__main__.Citron object at 0x7f4e5fb71438>
jaune
Traceback (most recent call last):
  File "classe_exemple1.py", line 14, in <module>
    citron1.affiche_attributes()
  File "classe_exemple1.py", line 9, in affiche_attributes
    print(var)
NameError: name 'var' is not defined. Did you mean: 'vars'?
```
**Ligne 2.** La méthode `.affiche_attributes()` montre que le self est bien une référence vers l'instance (ou objet) citron1 (ou vers n'importe quelle autre instance : par exemple, si on crée `citron2 = Citron()`, le self sera une référence vers citron2).

**Ligne 3.** La méthode `.affiche_attributes()` affiche l'attribut `.couleur`, qui avait été créé précédemment dans le constructeur. Vous voyez ici l'intérêt principal de l'argument self passé en premier à chaque méthode d'une classe : il « accroche » n'importe quel attribut qui sera visible partout dans la classe, y compris dans une méthode où il n'a pas été défini.

**Lignes 4 à 9.** La création de la variable var dans la méthode `__init__()` sans l'accrocher à l'objet self fait qu'elle n'est plus accessible en dehors de `__init__()`. C'est exactement comme pour les fonctions classiques, var est finalement une variable locale au sein de la méthode `__init__()` et n'est plus visible lorsque l'exécution de cette dernière est terminée (voir les chapitres 10 et 13 sur les fonctions). Ainsi, Python renvoie une erreur, car var n'existe pas lorsque `.affiche_attributes()` est en exécution.

En résumé, le self est nécessaire lorsqu'on a besoin d'accéder à différents attributs dans les différentes méthodes d'une classe. Le self est également nécessaire pour appeler une méthode de la classe depuis une autre méthode :

```python
1 class Citron:
2     def __init__(self, couleur="jaune"):
3         self.couleur = couleur
4         self.affiche_message()
5
6     def affiche_message(self):
7         print("Le citron c'est trop bon !")
8
9
10    if __name__ == "__main__":
11        citron1 = Citron("jaune pâle")
```

**Ligne 4.** Nous appelons ici la méthode `.affiche_message()` depuis le constructeur. Pour appeler cette méthode interne à la classe Citron, on doit utiliser une syntaxe `self.méthode()`. Le self sert donc pour accéder aux attributs, mais aussi aux méthodes, ou plus généralement à tout ce qui est accroché à la classe.

**Lignes 6 et 7.** La méthode `.affiche_message()` est exécutée. On peut se poser la question « Pourquoi passer l'argument self à cette méthode alors qu'on ne s'en sert pas dans celle-ci ? »

**Attention**

Même si on ne se sert d'aucun attribut dans une méthode, l'argument self (ou quel que soit son nom) est **stricte-ment obligatoire**. En fait, la notation `citron1.affiche_message()` est équivalente à `Citron.affiche_message(citron1)`. Testez les deux pour voir ! Dans cette dernière instruction, on appelle la méthode accrochée à la classe Citron et on lui passe explicitement l'instance `citron1` en tant qu'argument. La notation `citron1.affiche_message()` contient donc en filigrane un argument, à savoir la référence vers l'instance `citron1` que l'on appelle self au sein de la méthode.

**Conseil**

C'est la première notation `citron1.affiche_attributes()` (ou plus généralement `instance.méthode()`), plus compacte, qui sera toujours utilisée.

**Ligne 11.** On crée l'instance `citron1` en lui passant l'argument `"jaune pâle"`. La variable d'instance couleur prendra ainsi cette valeur au lieu de celle par défaut (`"jaune"`). À noter, l'instanciation affichera le message `Le citron c'est trop bon ! puisque la méthode .affiche_attributes() est appelée dans le constructeur __init__()`.

Afin de bien comprendre les différentes étapes des codes de cette rubrique, nous vous conseillons de les retester de votre côté dans *Python Tutor*.
### 23.1.8 Remarque finale

Dans ce chapitre, nous avons vu les bases pour construire une classe. Toutefois, nous avons encore de nombreuses notions à vous montrer afin de pouvoir utiliser la POO à plein régime. Dans le chapitre 24 Avoir plus la classe avec les objets (en ligne), nous verrons les concepts de polymorphisme, composition et héritage qui donnent toute la puissance à la POO. D'autres notions comme les décorateurs property seront abordées permettant le contrôle des attributs par un utilisateur de la classe. Nous donnerons également des conseils généraux quand vous utilisez la POO. Le chapitre 25 Fenêtres graphiques et Tkinter (en ligne) illustrera l'utilisation de la POO pour concevoir des fenêtres graphiques avec le module Tkinter.

## 23.2 Exercices

**Conseil**

Pour ces exercices, créez des scripts puis exécutez-les dans un shell.

### 23.2.1 Classe Rectangle

Téléchargez le script `rectangle.py`<fnref n="3" /> qui implémente la classe `Rectangle`.

Complétez le programme principal pour que le script :

* crée une instance `rectangle` de la classe `Rectangle`;

* affiche les attributs d'instance `largeur`, `longueur` et `couleur`;

* calcule et affiche la surface de `rectangle`;

* affiche une ligne vide;

* change le rectangle en carré de 30 m de côté;

* calcule et affiche la surface de ce carré;

* crée une autre instance `rectangle2`, aux dimensions et à la couleur que vous souhaitez (soyez créatif!) et qui affiche les attributs et la surface de ce nouveau rectangle.

### 23.2.2 Classe Rectangle améliorée

Entraînez-vous avec la classe `Rectangle`. Créez la méthode `calcule_perimetre()` qui calcule le périmètre d'un objet `rectangle`. Testez sur un exemple simple (`largeur = 10 m, longueur = 20 m`).

### 23.2.3 Classe Atome

Créez une nouvelle classe `Atome` avec les attributs `x`, `y`, `z`, qui contiennent les coordonnées atomiques, et la méthode `calcul_distance()`, qui calcule la distance entre deux atomes. Testez cette classe sur plusieurs exemples.

### 23.2.4 Classe Atome améliorée

Améliorez la classe `Atome` en lui ajoutant un nouvel attribut `masse`, qui correspond à la masse atomique, ainsi qu'une nouvelle méthode `.calcule_centre_masse()`. Que se passe-t-il quand vous utilisez l'instruction `print()` avec une instance d'un objet `Atome` ? Dans votre classe, ajoutez la méthode suivante :

```python
1 def __str__(self):
2     """Redéfinition du comportement avec print()."""
3     return f"coords({self.x}, {self.y}, {self.z})"; mass = {self.masse}"
```

Utilisez à nouveau l'instruction `print()` avec un objet de la classe `Atome`. Que constatez-vous par rapport au précédent `print()` ?

<footnote n="3">3. https://python.sdv.u-paris.fr/data-files/rectangle.py</footnote>
# CHAPITRE 24

## Avoir plus la classe avec les objets

Dans le chapitre précédent, nous avons vu les bases sur comment créer une classe, les notions d'attributs d'instance et de classe, le fonctionnent d'un constructeur et comment passer des arguments lors de l'instanciation. Nous avons vu qu'une classe pouvait être vue comme un constructeur de conteneur (chaque conteneur construit est une instance), qu'on pouvait y mettre tout un tas de variables ou objets (les attributs d'instance), mais également nous pouvions définir des méthodes réalisant des actions pour modifier ce que contient l'objet.

Dans le présent chapitre, nous abordons de nouvelles notions qui augmentent la puissance des classes, à savoir le polymorphisme, l'héritage et la composition. Nous verrons également les décorateurs *property* permettant le contrôle de l'accès aux attributs. À la fin du chapitre, nous vous donnerons des bonnes pratiques pour construire vos classes. Mais avant d'aborder ces sujets, nous revenons sur un concept important en Python, à savoir les espaces de noms.

### 24.1 Espace de noms

La notion d'espace de noms est importante lorsqu'on étudie les classes. Nous avons déjà croisé ce concept à plusieurs reprises. D'abord dans le chapitre 13 *Plus sur les fonctions*, puis dans le chapitre 15 *Création de modules*, et maintenant dans ce chapitre. De quoi s'agit-il ?

**Définition**

Dans la documentation officielle<fnref n="1" />, un espace de noms est défini comme suit : « *a namespace is a mapping from names to objects* ». Un espace de noms, c'est finalement une correspondance entre des noms et des objets. Un espace de noms peut être vu aussi comme une capsule dans laquelle on trouve des noms d'objets. Par exemple, le programme principal ou une fonction représentent chacun un espace de noms, un module aussi, et bien sûr une classe ou l'instance d'une classe également.

Différents espaces de noms peuvent contenir des objets de même nom sans que cela ne pose de problème. Parce qu'ils sont chacun dans un espace différent, ils peuvent cohabiter sans risque d'écrasement de l'un par l'autre. Par exemple, à chaque fois que l'on appelle une fonction, un espace de noms est créé pour cette fonction. *Python Tutor* nous montre cet espace sous la forme d'une zone dédiée (voir les chapitres 10 et 13 sur les fonctions). Si cette fonction appelle une autre fonction, un nouvel espace est créé, bien distinct de la fonction appelante (ce nouvel espace peut donc contenir

<footnote n="1">1. https://docs.python.org/fr/3/tutorial/classes.html#python-scopes-and-namespaces</footnote>
un objet de même nom). En définitive, ce qui va compter, c'est de savoir quelles règles Python va utiliser pour chercher dans les différents espaces de noms pour finalement accéder à un objet.

Nous allons dans cette rubrique refaire le point sur ce que l'on a appris dans cet ouvrage sur les espaces de noms en Python, puis se pencher sur les spécificités de ce concept dans les classes.

### 24.1.1 Rappel sur la règle LGI

Comme vu dans le chapitre 10 *Fonctions*, la règle LGI peut être résumée ainsi : *Local > Global > Interne*. Lorsque Python rencontre un objet, il utilise cette règle de priorité pour accéder à la valeur de celui-ci. Si on est dans une fonction (ou une méthode), Python va d'abord chercher l'espace de noms *local* à cette fonction. S'il ne trouve pas de nom il va ensuite chercher l'espace de noms du programme principal (ou celui du module), donc des variables *globales* s'y trouvant. S'il ne trouve pas de nom, il va chercher dans les commandes *internes* à Python (on parle des *Built-in Functions*<fnref n="2" /> et des *Built-in Constants*<fnref n="3" />). Si aucun objet n'est trouvé, Python renvoie une erreur.

### 24.1.2 Gestion des noms dans les modules

Les modules représentent aussi un espace de noms en soi. Afin d'illustrer cela, jetons un coup d'œil à ce programme *test_var_module.py* :

```python
1 import mod
2
3 i = 1000000
4 j = 2
5
6 print("Dans prog principal i:", i)
7 print("Dans prog principal j:", j)
8
9 mod.fct()
10 mod.fct2()
11
12 print("Dans prog principal i:", i)
13 print("Dans prog principal j:", j)
```

Le module *mod.py* contient les instructions suivantes :

```python
1 def fct():
2     i = -27478524
3     print("Dans module, i local:", i)
4
5
6 def fct2():
7     print("Dans module, j global:", j)
8
9
10 i = 3.14
11 j = -76
```

L'exécution de *test_var_module.py* donnera :

```
$ python ./test_var_module.py
Dans prog principal i: 1000000
Dans prog principal j: 2
Dans module, i local: -27478524
Dans module, j global: -76
Dans prog principal i: 1000000
Dans prog principal j: 2
```

Lignes 3 et 4. On a bien les valeurs de *i* et *j* définies dans le programme principal de *test.py*.

Lignes 9 et 10. Lorsqu'on exécute *mod.fct()*, la valeur de *i* sera celle définie localement dans cette fonction. Lorsqu'on exécute *mod.fct2()*, la valeur de *j* sera celle définie de manière globale dans le module.

Lignes 12 et 13. De retour dans notre programme principal, les variables *i* et *j* existent toujours et n'ont pas été modifiées par l'exécution de fonctions du module *mod.py*.

<footnote n="2">2. https://docs.python.org/fr/3/library/functions.html%20comme%20par%20exemple%20%60print()%60</footnote>

<footnote n="3">3. https://docs.python.org/fr/3/library/constants.html</footnote>
En résumé, lorsqu'on lance une méthode d'un module, c'est l'espace de noms de celui-ci qui est utilisé. Bien sûr, toutes les variables du programme principal / fonction / méthode appelant ce module sont conservées telles quelles, et on les retrouve intactes lorsque l'exécution de la fonction du module est terminée. Un module a donc son propre espace de noms qui est bien distinct de tout programme principal / fonction / méthode appelant un composant de ce module. Enfin, les variables globales créées dans notre programme principal ne sont pas accessibles dans le module lorsque celui-ci est en exécution.

### 24.1.3 Gestion des noms avec les classes

On vient de voir qu'un module avait son propre espace de noms, mais qu'en est-il des classes ? En utilisant les exemples vus depuis le début de ce chapitre, vous avez certainement la réponse. Une classe possède par définition son propre espace de noms qui ne peut être en aucun cas confondu avec celui d'une fonction ou d'un programme principal. Reprenons un exemple simple :

```python
1 class Citron:
2     def __init__(self, saveur="acide", couleur="jaune"):
3         self.saveur = saveur
4         self.couleur = couleur
5         print("Dans __init__() , vous venez de créer un citron:",
6              self.affiche_attrIBUTS())
7
8     def affiche_attrIBUTS(self):
9         return f{self.saveur}, {self.couleur}"
10
11
12 if __name__ == "__main__":
13     saveur = "sucrée"
14     couleur = "orange"
15     print(f"Dans le programme principal: {saveur}, {couleur}")
16     citron1 = Citron("très acide", "jaune foncé")
17     print("Dans citron1.affiche_attrIBUTS():", citron1.affiche_attrIBUTS())
18     print(f"Dans le programme principal: {saveur}, {couleur}")
```

Lorsqu'on exécutera ce code, on obtiendra :

```
Dans le programme principal: sucrée, orange
Dans __init__() , vous venez de créer un citron: très acide, jaune foncé
Dans citron1.affiche_attrIBUTS(): très acide, jaune foncé
Dans le programme principal: sucrée, orange
```

Les deux variables globales saveur et couleur du programme principal ne peuvent pas être confondues avec les variables d'instance portant le même nom. Au sein de la classe, on utilisera pour récupérer ces dernières self.saveur et self.couleur. À l'extérieur, on utilisera instance.saveur et instance.couleur. Il n'y a donc aucun risque de confusion possible avec les variables globales saveur et couleur, on accède à chaque variable de la classe avec un nom distinct (qu'on soit à l'intérieur ou à l'extérieur de la classe).

Ceci est également vrai pour les méthodes. Si par exemple, on a une méthode avec un certain nom, et une fonction du module principal avec le même nom, regardons ce qui se passe :
```python
1 class Citron:
2     def __init__(self):
3         self.couleur = "jaune"
4         self.affiche_coucou()
5         affiche_coucou()
6
7     def affiche_coucou(self):
8         print("Coucou interne !")
9
10
11 def affiche_coucou():
12     print("Coucou externe")
13
14
15 if __name__ == "__main__":
16     citron1 = Citron()
17     citron1.affiche_coucou()
18     affiche_coucou()
```

Lorsqu'on va exécuter le code, on obtiendra :

```
Coucou interne !
Coucou externe
Coucou interne !
Coucou externe
```

À nouveau, il n'y a pas de conflit possible pour l'utilisation d'une méthode ou d'une fonction avec le même nom. À l'intérieur de la classe on utilise `self.affiche_coucou()` pour la méthode et `affiche_coucou()` pour la fonction. À l'extérieur de la classe, on utilise `instance.affiche_coucou()` pour la méthode et `affiche_coucou()` pour la fonction.

Dans cette rubrique, nous venons de voir une propriété des classes extrêmement puissante : **une classe crée automatiquement son propre espace de noms**. Cela permet d'encapsuler à l'intérieur tous les attributs et méthodes dont on a besoin, sans avoir aucun risque de conflit de nom avec l'extérieur (variables locales, globales ou provenant de modules). L'utilisation de classes évitera ainsi l'utilisation de variables globales qui, on l'a vu aux chapitres 10 et 13 sur les fonctions, sont à proscire absolument. Tout cela concourt à rendre le code plus lisible.

Dans le chapitre 25 *Fenêtres graphiques et Tkinter* (en ligne), vous verrez une démonstration de l'utilité de tout encapsuler dans une classe afin d'éviter les variables globales.

### 24.1.4 Gestion des noms entre les attributs de classe et d'instance

Si vous lisez cette rubrique sur l'espace de noms sans avoir lu ce chapitre depuis le début, nous vous conseillons vivement de lire attentivement la rubrique *Différence entre les attributs de classe et d'instance*. La chose importante à retenir sur cette question est la suivante : si un attribut de classe et un attribut d'instance ont le même nom, c'est l'attribut d'instance qui est **prioritaire**.

**Pour aller plus loin**

Il existe d'autres règles concernant les espace de noms. L'une d'elle, que vous pourriez rencontrer, concerne la gestion des noms avec des fonctions imbriquées. Et oui, Python autorise cela ! Par exemple :

```python
1 def fonction1():
2     [...]
3
4     def fct_dans_fonction1():
5     [...]
```

Là encore, il existe certaines règles de priorités d'accès aux objets spécifiques à ce genre de cas, avec l'apparition d'un nouveau mot-clé nommé `nonlocal`. Toutefois ces aspects vont au-delà du présent ouvrage. Pour plus d'informations sur les fonctions imbriquées et la directive `nonlocal`, vous pouvez consulter la documentation officielle<fnref n="4" />.

D'autres subtilités concerneront la gestion des noms en cas de définition d'une nouvelle classe héritant d'une classe

<footnote n="4">4. https://docs.python.org/3/tutorial/classes.html#python-scopes-and-namespaces</footnote>
mère. Ces aspects sont présentés dans la rubrique *Héritage* de ce chapitre.

## 24.2 Polymorphisme

Nous allons voir maintenant des propriétés très importantes des classes en Python, le polymorphisme dans cette rubrique et l'héritage dans la suivante. Ces deux concepts donnent un surplus de puissance à la POO par rapport à la programmation classique.

### 24.2.1 Principe

Commençons par le polymorphisme. Dans la vie, celui-ci évoque la capacité à prendre plusieurs apparences, qu'en est-il en programmation ?

**Définition**

En programmation, le polymorphisme est la capacité d'une fonction (ou méthode) à se comporter différemment en fonction de l'objet qui lui est passé. Une fonction donnée peut donc avoir plusieurs définitions.

Prenons un exemple concret de polymorphisme : la fonction Python `sorted()` va trier par ordre ASCII si l'argument est une chaîne de caractères, et elle va trier par ordre croissant lorsque l'argument est une liste d'entiers :

1 >>> sorted("citron")

2 ['c', 'i', 'n', 'o', 'r', 't']

3 >>> sorted([1, -67, 42, 0, 81])

4 [-67, 0, 1, 42, 81]

Le polymorphisme est intimement lié au concept de *redéfinition des opérateurs* que nous avons déjà croisé à plusieurs reprises dans ce livre.

**Définition**

La redéfinition des opérateurs est la capacité à redéfinir le comportement d'un opérateur en fonction des opérandes utilisées (on rappelle dans l'expression `1 + 1, +` est l'opérateur d'addition et les deux `1` sont les opérandes).

Un exemple classique de redéfinition des opérateurs concerne l'opérateur `+`. Si les opérandes sont de type numérique, il fait une addition, si elles sont des chaînes de caractère il fait une concatenation :

1 >>> 2 + 2

2 4

3 >>> "ti" + "ti"

4 'titi'

Nous verrons dans la rubrique suivante sur *l'héritage* qu'il est également possible de redéfinir des méthodes d'une classe, c'est-à-dire leur donner une nouvelle définition.

### 24.2.2 Méthodes magiques ou *dunder methods*

Comment Python permet-il ces prouesses que sont le polymorphisme et la redéfinition des opérateurs ? Et bien, il utilise des méthodes dites *magiques*.

**Définition**

Une méthode magique (*magic method*) est une méthode spéciale dont le nom est entouré de double *underscores*. Par exemple, la méthode `__init__()` est une méthode magique. Ces méthodes sont, la plupart du temps, destinées au fonctionnement interne de la classe. Nombre d'entre elles sont destinées à changer le comportement de fonctions ou opérateurs internes à Python avec les instances d'une classe que l'on a créée. On parle aussi de *dunder method*, le mot *dunder* signifiant littéralement *double underscore*.
Nous allons prendre un exemple concret. Imaginons que suite à la création d'une classe, nous souhaitions que Python affiche un message personnalisé lors de l'utilisation de la fonction `print()` avec une instance de cette classe. La méthode magique qui permettra cela est nommée `__str__()` : elle redéfinit le comportement d'une instance avec la fonction `print()`.

```python
1 class CitronBasique:
2     def __init__(self, couleur="jaune", taille="standard"):
3         self.couleur = "jaune"
4         self.taille = "standard"
5
6
7 class CitronCool:
8     def __init__(self, couleur="jaune", taille="standard"):
9         self.couleur = couleur
10        self.taille = taille
11
12 def __str__(self):
13     return ("Votre citron est de couleur {self.couleur} ")
14     f"et de taille {self.taille}")
15
16
17 if __name__ == "__main__":
18     citron1 = CitronBasique()
19     print(citron1)
20     citron2 = CitronCool("jaune foncée", "minuscule")
21     print(citron2)
```

Lignes 1 à 4. Création d'une classe CitronBasique dans laquelle il n'y a qu'un constructeur.

Lignes 7 à 14. Création d'une classe CitronCool où nous avons ajouté la nouvelle méthode `__str__()`. Cette dernière renvoie une chaîne de caractères contenant la description de l'instance.

Lignes 18 à 21. On crée une instance de chaque classe, et on utilise la fonction `print()` pour voir leur contenu.

L'exécution de ce code affichera la sortie suivante :

```
1 <__main__.CitronBasique object at 0x7ffe23e717b8>
2 Votre citron est de couleur jaune foncée et de taille minuscule 8-)
```

L'utilisation de la fonction `print()` sur l'instance `citron1` construite à partir de la classe CitronBasique affiche le message abscons que nous avons déjà croisé. Par contre, pour l'instance `citron2` de la classe CitronCool, le texte correspond à celui retourné par la méthode magique `__str__()`. Nous avons donc redéfini comment la fonction `print()` se comportait avec une instance de la classe CitronCool. Notez que `str(citron2)` donnerait le même message que `print(citron2)`.

Ce mécanisme pourra être reproduit avec de très nombreux opérateurs et fonctions de bases de Python. En effet, il existe une multitude de méthodes magiques, en voici quelques unes :

* `__repr__()` : redéfinit le message obtenu lorsqu'on tape le nom de l'instance dans l'interpréteur;

* `__add__()` : redéfinit le comportement de l'opérateur +;

* `__mul__()` : redéfinit le comportement de l'opérateur *;

* `__del__()` : redéfinit le comportement de la fonction `del`.

Si on conçoit une classe produisant des objets séquentiels (comme des listes ou des tuples), il existe des méthodes magiques telles que :

* `__len__()` : redéfinit le comportement de la fonction `len()`;

* `__getitem__()` : redéfinit le comportement pour récupérer un élément;

* `__getslice__()` : redéfinit le comportement avec les tranches.

Certaines méthodes magiques font des choses assez impressionnantes. Par exemple, la méthode `__call__()` crée des instances que l'on peut appeler comme des fonctions ! Dans cet exemple, nous allons vous montrer que l'on peut ainsi créer un moyen inattendu pour mettre à jour des attributs d'instance :
```python
1 class Citronnier:
2     def __init__(self, nb_citrons, age):
3         self.nb_citrons, self.age = nb_citrons, age
4
5     def __call__(self, nb_citrons, age):
6         self.nb_citrons, self.age = nb_citrons, age
7
8     def __str__(self):
9         return ("Ce citronnier a {self.age} ans "
10                 "f"et {self.nb_citrons} citrons")
11
12
13     if __name__ == "__main__":
14         citronnier1 = Citronnier(10, 3)
15         print(citronnier1)
16         citronnier1(30, 4)
17         print(citronnier1)
```

À la ligne 16, on utilise une notation `instance(arg1, arg2)`, ce qui va automatiquement appeler la méthode magique `__call__()` qui mettra à jour les deux attributs d'instance `nbcitrons` et `age` (lignes 5 et 6). Ce code affichera la sortie suivante :

```
Ce citronnier a 3 ans et 10 citrons
```

```
Ce citronnier a 4 ans et 30 citrons
```

### Pour aller plus loin

* Nous vous avons montré l'idée qu'il y avait derrière le polymorphisme, et avec cela vous avez assez pour vous jeter à l'eau et commencer à construire vos propres classes. L'apprentissage de toutes les méthodes magiques va bien sûr au-delà du présent ouvrage. Toutefois, si vous souhaitez aller plus loin, nous vous conseillons la page de Rafe Kettler<fnref n="5" /> qui est particulièrement complète et très bien faite.

## 24.3 Héritage

### 24.3.1 Prise en main

L'héritage peut évoquer la capacité qu'ont nos parents à nous transmettre certains traits physiques ou de caractère (ne dit-on pas, j'ai hérité ceci ou cela de ma mère ou de mon père?). Qu'en est-il en programmation ?

#### Définition

En programmation, l'héritage est la capacité d'une classe d'hériter des propriétés d'une classe pré-existante. On parle de classe mère et de classe fille. En Python, l'héritage peut être multiple lorsqu'une classe fille hérite de plusieurs classes mères.

En Python, lorsque l'on veut créer une classe héritant d'une autre classe, on ajoutera après le nom de la classe fille le nom de la ou des classe(s) mère(s) entre parenthèses :

<footnote n="5">5. https://rszalski.github.io/magicmethods</footnote>
```python
1 class Mere1:
2     # contenu de la classe mère 1
3
4
5 class Mere2:
6     # contenu de la classe mère 2
7
8
9 class Fille1(Mere1):
10     # contenu de la classe fille 1
11
12
13 class Fille2(Mere1, Mere2):
14     # contenu de la classe fille 2
```

Dans cet exemple, la classe Fille1 hérite de la classe Mere1 et la classe Fille2 hérite des deux classes Mere1 et Mere2. Dans le cas de la classe Fille2, on parle d'héritage multiple. Voyons maintenant un exemple concret :

```python
1 class Mere:
2     def bonjour(self):
3         return "Vous avez le bonjour de la classe mère !"
4
5
6 class Fille(Mere):
7     def salut(self):
8         return "Un salut de la classe fille !"
9
10
11 if __name__ == "__main__":
12     fille = Fille()
13     print(fille.salut())
14     print(fille.bonjour())
```

Lignes 1 à 3. On définit une classe Mere avec une méthode .bonjour().

Lignes 6 à 8. On définit une classe Fille qui hérite de la classe Mere. Cette classe fille contient une nouvelle méthode .salut().

Lignes 12 à 14. Après instanciation de la classe Fille, on utilise la méthode .salut(), puis la méthode .bonjour() héritée de la classe mère.

Ce code affiche la sortie suivante :

```
Un salut de la classe fille !
Vous avez le bonjour de la classe mère !
```

Nous commençons à entrevoir la puissance de l'héritage. Si on possède une classe avec de nombreuses méthodes et que l'on souhaite en ajouter de nouvelles, il suffit de créer une classe fille héritant d'une classe mère.

En revenant à notre exemple, une instance de la classe Fille sera automatiquement une instance de la classe Mere. Regardons dans l'interpréteur :

```
1 >>> fille = Fille()
2 >>> isinstance(fille, Fille)
3 True
4 >>> isinstance(fille, Mere)
5 True
```

Si une méthode de la classe fille possède le même nom que celle de la classe mère, c'est la première qui prend la priorité. Dans ce cas, on dit que la méthode est redéfinie (en anglais on parle de *method overriding*), tout comme on parlait de *redéfinition des opérateurs* un peu plus haut. C'est le même mécanisme, car la redéfinition des opérateurs revient finalement à redéfinir une méthode magique (comme par exemple la méthode `__add__()` pour l'opérateur +).

Voyons un exemple :
```python
1 class Mere:
2     def bonjour(self):
3         return "Vous avez le bonjour de la classe mère !"
4
5
6 class Fille2(Mere):
7     def bonjour(self):
8         return "Vous avez le bonjour de la classe fille !"
9
10
11 if __name__ == "__main__":
12     fille = Fille2()
13     print(fille.bonjour())
```

Ce code va afficher `Vous avez le bonjour de la classe fille !`. La méthode `bonjour()` de la classe fille a donc pris la priorité sur celle de la classe mère. Ce comportement provient de la gestion des espaces de noms par Python, il est traité en détail dans la rubrique suivante.

### Remarque

À ce point, nous pouvons faire une note de sémantique importante. Python utilise le mécanisme de *redéfinition de méthode (method overriding)*, c'est-à-dire qu'on redéfinit une méthode héritée d'une classe mère. Il ne faut pas confondre cela avec la *surcharge de fonction (function overloading)* qui désigne le fait d'avoir plusieurs définitions d'une fonction selon le nombres d'arguments et/ou leur type (la surcharge n'est pas supportée par Python contrairement à d'autres langages orientés objet).

## 24.3.2 Ordre de résolution des noms

Vous l'avez compris, il y aura un ordre pour la résolution des noms d'attributs ou de méthodes en fonction du ou des héritage(s) de notre classe (à nouveau, cela provient de la manière dont Python gère les espaces de noms). Prenons l'exemple d'une classe déclarée comme suit `class Fille(Mere1, Mere2):`. Si on invoque un attribut ou une méthode sur une instance de cette classe, Python cherchera d'abord dans la classe `Fille`. S'il ne trouve pas, il cherchera ensuite dans la première classe mère (`Mere1` dans notre exemple). S'il ne trouve pas, il cherchera dans les ancêtres de cette première mère (si elle en a), et ce en remontant la filiation (d'abord la grand-mère, puis l'arrière grand-mère, etc). S'il n'a toujours pas trouvé, il cherchera dans la deuxième classe mère (`Mere2` dans notre exemple) puis dans tous ses ancêtres. Et ainsi de suite, s'il y a plus de deux classes mères. Bien sûr, si aucun attribut ou méthode n'est trouvé, Python renverra une erreur.

Il est en général possible d'avoir des informations sur l'ordre de résolution des méthodes d'une classe en évoquant la commande `help()` sur celle-ci ou une de ses instances. Par exemple, nous verrons dans le chapitre suivant le module `Tkinter`, imaginons que nous créions une instance de la classe principale du module `Tkinter` nommée `Tk` :

```python
1 >>> import tkinter as tk
```

```python
2 >>> racine = tk.Tk()
```

En invoquant la commande `help(racine)`, l'interpréteur nous montre :

```python
1 Help on class Tk in module tkinter:
2
3 class Tk(Misc, Wm)
4     | TLEVEL widget of Tk which represents mostly the main window
5     | of an application. It has an associated Tcl interpreter.
6     |
7     | Method resolution order:
8     |     Tk
9     |     Misc
10    |     Wm
11    | builtinsobject
12 [...]
```

On voit tout de suite que la classe `Tk` hérite de deux autres classes `Misc` et `Wm`. Ensuite, le `help` indique l'ordre de résolution des méthodes : d'abord la classe `Tk` elle-même, ensuite ses deux mères `Misc` puis `Wm`, et enfin une dernière
classe nommée `builtinsobject` dont nous allons voir la signification maintenant.

### Remarque

En Python, il existe une classe interne nommée `object` qui est en quelque sorte la classe ancêtre de tous les objets. Toutes les classes héritent de `object`.

Pour vous en convaincre, nous pouvons recréer une classe vide :

```python
1 >>> class Citron:
2     ... pass
```

Puis ensuite regarder l'aide sur l'une de ses instances :

```python
1 Help on class Citron in module __main__:
2
3 class Citron(builtinsobject)
4   | Data descriptors defined here:
5   |
6   |   __dict__
7   |   dictionary for instance variables (if defined)
8   [...]
```

L'aide nous montre que `Citron` a hérité de `builtinsobject` bien que nous ne l'ayons pas déclaré explicitement. Cela se fait donc de manière implicite.

### Remarque

Le module `builtins` possède toutes les fonctions internes à Python. Il est donc pratique pour avoir une liste de toutes ces fonctions internes en un coup d'œil. Regardons cela avec les deux instructions `import builtins` puis `dir(builtins)` :

```python
1 >>> import builtins
2 >>> dir(builtins)
3 ['ArithmeticError', 'AssertionError', 'AttributeError', [...]
4 'ascii', 'bin', 'bool', 'bytearray', 'bytes', 'callable', 'chr', [...]
5 'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next', 'object', [...]
6 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']
```

Au début, on y trouve les exceptions commençant par une lettre majuscule (voir le chapitre 26 *Remarques complémentaires* (en ligne) pour la définition d'une exception), puis les fonctions Python de base tout en minuscule. On retrouve par exemple `list` ou `str`, mais il y a aussi `object`. Toutefois ces fonctions étant chargées de base dans l'interpréteur, l'importation de `builtins` n'est pas obligatoire : par exemple `list` revient au même que `builtins.list`, ou `object` revient au même que `builtinsobject`.

En résumé, la syntaxe `class Citron`: sera équivalente à
`class Citron(builtinsobject)`:
ou à `class Citron(object):`.

Ainsi, même si on crée une classe `Citron` vide (contenant seulement une commande `pass`), elle possède déjà tout une panoplie de méthodes héritées de la classe `object`. Regardez l'exemple suivant :
```python
1 >>> class Citron:
2     ...     pass
3     ...
4 >>> c = Citron()
5 >>> dir(c)
6 ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__',
7 '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__',
8 '__init__', '__le__', '__lt__', '__module__', '__ne__', '__new__',
9 '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__',
10 '__str__', '__subclasshook__', '__weakref__']
11 >>> o = object()
12 >>> dir(o)
13 ['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__',
14 '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__',
15 '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__',
16 '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
```

La quasi-totalité des attributs / méthodes de base de la classe Citron sont donc hérités de la classe object. Par exemple, lorsqu'on instancie un objet Citron `c = Citron()`, Python utilisera la méthode `__init__()` héritée de la classe object (puisque nous ne l'avons pas définie dans la classe Citron).

### 24.3.3 Un exemple concret d'héritage

Nous allons maintenant prendre un exemple un peu plus conséquent pour illustrer la puissance de l'héritage en programmation. D'abord quelques mots à propos de la conception. Imaginons que nous souhaitions créer plusieurs classes correspondant à nos fruits favoris, par exemple le citron (comme par hasard !), l'orange, le kaki, etc. Chaque fruit a ses propres particularités, mais il y a aussi de nombreux points communs. Nous pourrions donc concevoir une classe `Fruit` permettant, par exemple, d'instancier un fruit et ajouter des méthodes d'affichage commune à n'importe quel fruit, et ajouter (ou toute autre méthode) pouvant être utilisée pour n'importe quel fruit. Nous pourrions alors créer des classes comme `Citron`, `Orange`, etc., héritant de la classe `Fruit` et ainsi nous économiser des lignes de code identiques à ajouter pour chaque fruit. Regardons l'exemple suivant que nous avons garni de `print()` pour bien comprendre ce qui se passe :
```python
class Fruit:
    def __init__(self, taille=None, masse=None, saveur=None, forme=None):
        print("(2) Je suis dans le constructeur de la classe Fruit")
        self.taille = taille
        self.masse = masse
        self.saveur = saveur
        self.forme = forme
        print("Je viens de créer self.taille, self.masse, self.saveur ")
        "et self.forme")

    def affiche_conseil(self, type_fruit, conseil):
        print("(2) Je suis dans la méthode .affiche_conseil() de la ")
        "classe Fruit\n")
        return (f"Instance {type_fruit}\n"
                f"taille: {self.taille}, masse: {self.masse}\n"
                f"saveur: {self.saveur}, forme: {self.forme}\n"
                f"conseil: {conseil}\n")

    class Citron(Fruit):
        def __init__(self, taille=None, masse=None, saveur=None, forme=None):
            print("(1) Je rentre dans le constructeur de Citron, et je vais ")
            "appeler\n"
            "le constructeur de la classe mère Fruit !")
            Fruit.__init__(self, taille, masse, saveur, forme)
            print("(3) J'ai fini dans le constructeur de Citron, ")
            "les attributs sont :\n"
            f"self.taille: {self.taille}, self.masse: {self.masse}\n"
            f"self.saveur: {self.saveur}, self.forme: {self.forme}\n")

        def __str__(self):
            print("(1) Je rentre dans la méthode .__str__() de la classe ")
            "Citron")
            print("Je vais lancer la méthode .affiche_conseil() héritée ")
            "de la classe Fruit")
            return self.affiche_conseil("Citron", "Bon en tarte :-p !")

        if __name__ == "__main__":
            # On crée un citron.
            citron1 = Citron(taille="petite", saveur="acide",
                             forme="ellipsoïde", masse=50)
            print(citron1)
```

Lignes 1 à 9. On crée la classe `Fruit` avec son constructeur qui initialisera tous les attributs d'instance décrivant le fruit.

Lignes 11 à 17. Création d'une méthode `.affiche_conseil()` qui retourne une chaîne contenant le type de fruit, les attributs d'instance du fruit, et un conseil de consommation.

Lignes 20 à 29. Création de la classe `Citron` qui hérite de la classe `Fruit`. Le constructeur de `Citron` prend les mêmes arguments que ceux du constructeur de `Fruit`. La ligne 24 est une étape importante que nous n'avons encore jamais vue : l'instruction `Fruit.__init__()` est un appel au constructeur de la classe mère (cf. explications plus bas). Notez bien que le premier argument passé au constructeur de la classe mère sera systématiquement l'instance en cours `self`. Le `print()` en lignes 26-29 illustre qu'après l'appel du constructeur de la classe mère tous les attributs d'instance (`self.taille`, `self.poids`, etc.) ont bel et bien été créés.

Lignes 31 à 36. On définit la méthode `.__str__()` qui va modifier le comportement de notre classe avec `print()`. Celle-ci fait également appel à une méthode héritée de la classe mère nommée `.affiche_conseil()`. Comme on a l'a héritée, elle est directement accessible avec un `self.méthode()` (et de l'extérieur ce serait `instance.méthode()`).

Lignes 39 à 43. Dans le programme principal, on instancie un objet `Citron`, puis on utilise `print()` sur l'instance.

L'exécution de ce code affichera la sortie suivante :
(1) Je rentre dans le constructeur de Citron, et je vais appeler
le constructeur de la classe mère Fruit !

(2) Je suis dans le constructeur de la classe Fruit
Je viens de créer self.taille, self.masse, self.saveur et self.forme

(3) J'ai fini dans le constructeur de Citron, les attributs sont:
self.taille: petite, self.masse: 50
self.saveur: acide, self.forme: ellipsoïde

(1) Je rentre dans la méthode __str__() de la classe Citron
Je vais lancer la méthode .affiche_conseil() héritée de la classe Fruit

(2) Je suis dans la méthode .affiche_conseil() de la classe Fruit

Instance Citron
taille: petite, masse: 50
saveur: acide, forme: ellipsoïde
conseil: Bon en tarte :-p !

Prenez bien le temps de suivre ce code pas à pas pour bien en comprendre toutes les étapes.

Vous pourrez vous poser la question « *Pourquoi utilise-t-on en ligne 24 la syntaxe Fruit.__init__() ?* ». Cette syntaxe est souvent utilisée lorsqu'une classe hérite d'une autre classe pour faire appel au constructeur de la classe mère. La raison est que nous souhaitons appeler une méthode de la classe mère qui a le même nom qu'une méthode de la classe fille. Dans ce cas, si on utilisait self.__init__() , cela correspondrait à la fonction de notre classe fille Citron. En mettant systématiquement une syntaxe

ClasseMere.__init__() on indique sans ambiguïté qu'on appelle le constructeur de la classe mère, en mettant explicitement son nom. Ce mécanisme est assez souvent utilisé dans le module Tkinter (voir le chapitre 25 Fenêtres graphiques et Tkinter (en ligne)) pour la construction d'interfaces graphiques, nous en verrons de nombreux exemples.

## Remarque

Si vous utilisez des ressources externes, il se peut que vous rencontrez une syntaxe `super().__init__()`. La fonction Python interne `super()` appelle automatiquement la classe mère sans que vous ayez à donner son nom. Même si cela peut paraître pratique, nous vous conseillons d'utiliser dans un premier temps la syntaxe `ClasseMere.__init__()` qui est selon nous plus lisible (on voit explicitement le nom de la classe employée, même s'il y a plusieurs classes mères).

Ce mécanisme n'est pas obligatoirement utilisé, mais il est très utile lorsqu'une classe fille a besoin d'initialiser des attributs définis dans la classe mère. On le croise donc souvent car :

* Cela donne la garantie que toutes les variables de la classe mère sont bien initialisées. On réduit ainsi les risques de dysfonctionnement des méthodes héritées de la classe mère.

* Finalement, autant ré-utiliser les « moulinettes » de la classe mère, c'est justement à ça que sert l'héritage ! Au final, on écrit moins de lignes de code.

## Conseil

Pour les deux raisons citées ci-dessus, nous vous conseillons de systématiquement utiliser le constructeur de la classe mère lors de l'instanciation.

Vous avez à présent bien compris le fonctionnement du mécanisme de l'héritage. Dans notre exemple, nous pourrions créer de nouveaux fruits avec un minimum d'effort. Ceux-ci pourraient hériter de la classe mère `Fruit` à nouveau, et nous n'aurions pas à réécrire les mêmes méthodes pour chaque fruit, simplement à les appeler. Par exemple :
```python
1 class Kaki(Fruit):
2     def __init__(self, taille=None, masse=None, saveur=None, forme=None):
3         Fruit.__init__(self, taille, masse, saveur, forme)
4 
5     def __str__(self):
6         return Fruit.affiche_conseil(self, "Kaki",
7                                     "Bon à manger cru, miam !")
8 
9 
10 class Orange(Fruit):
11     def __init__(self, taille=None, masse=None, saveur=None, forme=None):
12         Fruit.__init__(self, taille, masse, saveur, forme)
13 
14     def __str__(self):
15         return Fruit.affiche_conseil(self, "Orange", "Trop bon en jus !")
```

Cet exemple illustre la puissance de l'héritage et du polymorphisme et la facilité avec laquelle on les utilise en Python. Pour chaque fruit, on utilise la méthode `.affiche_conseil()` définie dans la classe mère sans avoir à la réécrire. Bien sûr cet exemple reste simpliste et n'est qu'une « mise en bouche ». Vous verrez des exemples concrets de la puissance de l'héritage dans le chapitre 25 *Fenêtres graphiques et Tkinter* (en ligne) ainsi que dans les exercices du présent chapitre. Avec le module `Tkinter`, chaque objet graphique (bouton, zone de texte, etc.) est en fait une classe. On peut ainsi créer de nouvelles classes héritant des classes `Tkinter` afin de personnaliser chaque objet graphique.

## 24.4 Composition

Un autre concept puissant rencontré en POO est la composition.

### Définition

La composition désigne le fait qu'une classe peut contenir des instances provenant d'autres classes. On parle parfois de classe *Composite* contenant des instances d'une classe *Component* (qu'on pourrait traduire par élément).

Pour vous illustrer cela, nous allons prendre un exemple sur notre fruit préféré, le citron. Un citron (classe *Composite*) contient de la pulpe (classe *Component*). Voilà comment nous pourrions l'implémenter :
```python
1 class Pulpe:
2     def __init__(self, quantite_jus):
3         self.quantite_jus = quantite_jus  # En cL.
4
5     def __str__(self):
6         return f"Cette pulpe contient {self.quantite_jus} cL de jus"
7
8
9 class Citron:
10     def __init__(self, pulpe=None):
11         self.pulpe = pulpe
12
13     def presse_citron(self):
14         if self.pulpe:
15             print(f"Le pressage de la pulpe délivre "
16                  f"{self.pulpe.quantite_jus} cL de jus")
17         self.pulpe = None
18     else:
19         print("Il n'y a plus rien à presser dans votre citron !")
20
21     def __str__(self):
22         if self.pulpe:
23             return f"Votre citron contient {self.pulpe.quantite_jus} cL de jus"
24     else:
25         return "Ce citron ne contient pas de pulpe"
26
27
28 if __name__ == "__main__":
29     pulpe = Pulpe(10)
30     print(pulpe)
31     citron1 = Citron()
32     print(citron1)
33     print()
34     citron2 = Citron(pulpe)
35     print(citron2.pulpe)
36     print(citron2)
37     print()
38     citron2.presse_citron()
39     citron2.presse_citron()
40     print(citron2)
```

**Lignes 1 à 6.** On crée une classe `Pulpe` qui prend en argument à l'instanciation une quantité de jus (en cL) qu'elle peut délivrer si on la presse.

**Lignes 9 à 25.** On crée une classe `Citron` qui prend un objet `Pulpe` à l'instanciation. Si aucun objet est passé, on affecte `None`. Cette classe contient une méthode `.presse_citron()` qui pressera la pulpe pour délivrer le jus de citron. Une fois le pressage effectué, il n'y aura plus de jus à délivrer.

La sortie sera la suivante :

```
Cette pulpe contient 10 cL de jus
Ce citron ne contient pas de pulpe

Cette pulpe contient 10 cL de jus
Votre citron contient 10 cL de jus

Le pressage de la pulpe délivre 10 cL de jus
Il n'y a plus rien à presser dans votre citron !
Ce citron ne contient pas de pulpe
```

Dans cet exemple, la classe `Citron` a utilisé une instance de la classe `Pulpe` pour fonctionner. Un avantage de la composition est qu'on pourrait réutiliser cette classe `Pulpe` dans une classe `Orange` ou `Pamplemousse`. Par ailleurs, si on change des détails dans la classe `Pulpe`, cela affectera peu la classe `Citron` à partir du moment où on garde l'attribut `.quantite_jus`.

De manière générale, la composition est considérée comme plus flexible que l'héritage car les classes `Composite` et `Component` sont peu couplées. Le changement de l'une d'entre elle aura peu d'effet sur l'autre. Au contraire, pour l'héritage le changement d'une classe mère peut avoir des répercussions importantes pour les classes filles. Toutefois,
dans certains cas l'héritage peut s'avérer plus naturel. Nous vous parlions en introduction du chapitre 23 *Avoir la classe avec les objets* de l'art pour concevoir des classes interagissant harmonieusement entre elles. Et bien nous y sommes !

Si on a deux classes A et B, la relation entre elles dans l'héritage sera de type `B is a A` (avec B qui hérite de A). Dans la composition, ce sera plutôt `A has a B`. Cela peut vous servir de piste dans la conception des relations entre vos classes. A-t-il plus de sens d'y avoir une relation `is a` ou bien `has a` ? Dans le premier cas vous irez plutôt vers l'héritage, alors que dans le deuxième plutôt vers la composition. C'est ici que le langage UML <sup>6</sup> peut être pratique pour avoir une vision d'ensemble sur comment les classes interagissent entre elles.

Bien sûr, il faudra vous entraîner sur des cas concrets pour acquérir l'expérience qui vous mènera aux bons choix. A la fin de ce chapitre, nous vous présentons un exercice pour vous entraîner dans un premier temps à la composition. Dans le chapitre 25 *Fenêtres graphiques et Tkinter* (en ligne), vous aurez des illustrations et des exercices sur l'héritage qui est très utilisé en Tkinter.

### Pour aller plus loin

Nous vous conseillons ce très bon article sur le site *RealPython* qui explique de manière approfondie la problématique entre la composition et l'héritage<sup>7</sup>.

### Pour aller plus loin

Le polymorphisme, l'héritage et la composition donnent toute la puissance à la POO. Toutefois, concevoir ses classes sur un projet, surtout au début de celui-ci, n'est pas chose aisée. Nous vous conseillons de lire d'autres ressources et de vous entraîner sur un maximum d'exemples. Si vous souhaitez allez plus loin sur la POO, nous vous conseillons de lire des ressources supplémentaires. En langue française, vous trouverez les livres de Gérard Swinnen<sup>8</sup>, Bob Cordeau et Laurent Pointal<sup>9</sup>, Vincent Legoff<sup>10</sup> et Xavier Olive<sup>11</sup>.

## 24.5 Différence entre les attributs de classe et d'instance

Dans cette rubrique, nous souhaitons éclairer le rôle des attributs de classe et des attributs d'instance, et comment ils sont gérés par Python.

On a vu dans le chapitre précédent comment créer un attribut de classe, il suffit de créer une variable au sein de la classe (en dehors de toute méthode). En général, les attributs de classe contiennent des propriétés générales à la classe puisqu'ils vont prendre la même valeur quelle que soit l'instance.

Au contraire, les attributs d'instance sont spécifiques à chaque instance. Pour en créer, on a vu qu'il suffisait de les initialiser dans la méthode `__init__()` en utilisant une syntaxe `self.nouvel_attrribut = valeur`. On a vu aussi dans la rubrique *Ajout d'un attribut d'instance* que l'on pouvait ajouter un attribut d'instance de l'extérieur avec une syntaxe `instance.nouvel_attrribut = valeur`.

Bien que les deux types d'attributs soient fondamentalement différents au niveau de leur finalité, il existe des similitudes lorsqu'on veut accéder à leur valeur. Le code suivant illustre cela :

<footnote n="6">6. https://fr.wikipedia.org/wiki/UML_(informatique)</footnote>

<footnote n="7">7. https://realpython.com/inheritance-composition-python/</footnote>

<footnote n="8">8. https://inforef.be/swi/python.htm</footnote>

<footnote n="9">9. https://perso.limsi.fr/pointal/python:courspython3</footnote>

<footnote n="10">10. https://openclassrooms.com/fr/courses/235344-apprenez-a-programmer-en-python</footnote>

<footnote n="11">11. https://www.xoolive.org/python/</footnote>
```python
1 class Citron:
2     forme = "ellipsoïde" # attribut de classe
3     saveur = "acide" # attribut de classe
4
5     def __init__(self, couleur="jaune", taille="standard", masse=0):
6         self.couleur = couleur # attribut d'instance
7         self.taille = taille # attribut d'instance
8         self.masse = masse # attribut d'instance (masse en gramme)
9
10    def augmente_masse(self, valeur):
11        self.masse += valeur
12
13
14    if __name__ == "__main__":
15        citron1 = Citron()
16        print("Attributs de classe :", citron1 forme, citron1.saveur)
17        print("Attributs d'instance :", citron1.taille, citron1.couleur,
18              citron1.masse)
19        citron1.augmente_masse(100)
20        print("Attributs d'instance :", citron1.taille, citron1.couleur,
21              citron1.masse)
```

Lignes 2 et 3. Nous créons deux variables de classe qui seront communes à toutes les instances (disons qu'un citron sera toujours ellipsoïde et acide !).

Lignes 6 à 8. Nous créons trois variables d'instance qui seront spécifiques à chaque instance (disons que la taille, la couleur et la masse d'un citron peuvent varier!), avec des valeurs par défaut.

Lignes 10 et 11. On crée une nouvelle méthode `augmente_masse()` qui augmente l'attribut d'instance `masse`.

Ligne 14 à 21. Dans le programme principal, on instancie la classe Citron sans passer d'argument (les valeurs par défaut "jaune", "standard" et 0 seront donc prises), puis on imprime les attributs.

La figure 24.1 montre l'état des variables après avoir exécuté ce code grâce au site Python Tutor<fnref n="12" />.

<figure><img src="image_54.png" /><figcaption>FIGURE 24.1 – Illustration de la signification des attributs de classe et d'instance avec Python Tutor.</figcaption></figure>

Python Tutor montre bien la différence entre les variables de classe `forme` et `saveur` qui apparaissent directement dans les attributs de la classe Citron lors de sa définition et les trois variables d'instance `couleur`, `taille` et `masse`.

<footnote n="12">12. http://www.pythontutor.com</footnote>
qui sont liées à l'instance `citron1`. Pour autant, on voit dans la dernière instruction `print()` qu'on peut accéder de la même manière aux variables de classe ou d'instance, lorsqu'on est à l'extérieur, avec une syntaxe `instanceattribut`.

Au sein des méthodes, on accède également de la même manière aux attributs de classe ou d'instance, avec une syntaxe `selfattribut`:

```python
1 class Citron:
2     saveur = "acide" # attribut de classe
3
4     def __init__(self, couleur="jaune"):
5         self.couleur = couleur # attribut d'instance
6
7     def afficheAttributs(self):
8         print(f"attribut de classe: {self.saveur}")
9         print(f"attribut d'instance: {self.couleur}")
10
11
12     if __name__ == "__main__":
13         citron1 = Citron()
14         citron1.afficheAttributs()
```

Ce code va afficher la phrase :

```
attribut de classe: acide
attribut d'instance: jaune
```

En résumé, qu'on ait des attributs de classe ou d'instance, on peut accéder à eux de l'extérieur par `instanceattribut` et de l'intérieur par `selfattribut`.

Qu'en est-il de la manière de modifier ces deux types d'attributs ? Les attributs d'instance peuvent se modifier sans problème de l'extérieur avec une syntaxe `instanceattribut_d_instance = nouvelle_valeur` et de l'intérieur avec une syntaxe `selfattribut_d_instance = nouvelle_valeur`. Ce n'est pas du tout le cas avec les attributs de classe.

### Attention

Les attributs de classe ne peuvent pas être modifiés ni à l'extérieur d'une classe via une syntaxe `instanceattribut_de_classe = nouvelle_valeur`, ni à l'intérieur d'une classe via une syntaxe `selfattribut_de_classe = nouvelle_valeur`. Puisqu'ils sont destinés à être identiques pour toutes les instances, cela est logique de ne pas pouvoir les modifier via une instance. Les attributs de classe Python ressemblent en quelque sorte aux attributs statiques du C++.

Regardons l'exemple suivant illustrant cela :

```python
1 class Citron:
2     saveur = "acide"
3
4
5     if __name__ == "__main__":
6         citron1 = Citron()
7         print(citron1.saveur)
8         citron1.saveur = "sacrée"
9         print(citron1.saveur) # on regarde ici avec Python Tutor
10     del citron1.saveur
11     print(citron1.saveur) # on regarde ici avec Python Tutor
12     del citron1.saveur
```

À la ligne 7, on pourrait penser qu'on modifie l'attribut de classe `saveur` avec une syntaxe `instanceattribut_de_classe = nouvelle_valeur`. Que se passe-t-il exactement ? La figure 24.3 nous montre l'état des variables grâce au site *Python Tutor*. Celui-ci indique que la ligne 7 a en fait créé un nouvel attribut d'instance `citron1.saveur` (contenant la valeur `sacrée`) qui est bien distinct de l'attribut de classe auquel on accédait avant par le même nom ! Tout ceci est dû à la manière dont Python gère les **espaces de noms** (voir rubrique *Espaces de noms*). Dans ce cas, l'attribut d'instance est **prioritaire** sur l'attribut de classe.

À la ligne 9, on détruit finalement l'attribut d'instance `citron1.saveur` qui contenait la valeur `sacrée`. Python