Eléments de programmation en pour Calcul Scientifique - Statistique

Résumé: Compléments de programmation en python: structures de contrôle, programmation fonctionnelle (map, reduce, lambda), introduction aux classes et objets.

Introduction

L'objectif de ce tutoriel est d'introduire quelques outils et concepts plus avancés de la programmation en Python pour dans le but d'améliorer la performance et la lisibilité des codes. Les notions de classe, de programmation objet et celle de programmation fonctionnelle qui en découle sont fondamentales. Elles sont fondamentales pour le bon usage de certaines librairie dont Scikit-learn (section 3.3). C'est aussi une introduction à l'utilisation des fonctionnalités MapReduce parallélisables et donc à la base de l'algorithmique pour données distribuées (Hadoop) avec PySpark.

1 Structures de contrôle

1.1 Structure itérative for

Une boucle for permet, comme dans la pluspart des langages de programmation, de parcourir les éléments d'un objet itérable. En python cela peut-être une liste, un tuple, une chaîne de caractères (string), mais également des objets spécialement conçus pour cela, appelés iterator.

Syntaxe de la structure for:

for variable in range iterator:
     instruction

La ligne for se termine par deux points ':'. Le bloc de codes à l'intérieur de la boucle est indenté.

Voici comment parcourir différentes structures itératives en se souvenant que: de façon générale, il faut éviter les boucles for dans un langage interprété en utilisant les autres fonctionnalités (section 2) prévues dans les librairies pour parcourir des tableaux ou matrices.

range

La fonction range produit une liste d'entiers mais aussi un objet itérable d'entiers utilisés au fur et à mesure des itérations de la boucle.

Attention xrange de python2 est remplacé par range en python3.


In [ ]:
for i in range(5):
    print (i)

L'appel à la fonction range(n) permet d'itérer sur les entiers de 0 à n-1 mais il est possible de spécifier des intervalles des valeurs de chaque pas d'itération: range(2,11,2) range(10,0,-1)

Strings

Les chaînes de caractères sont également des objets parcourables avec la boucle for: caractère après caractère.


In [ ]:
for character in "Hi There!":
    print (character)
Dictionnaires

Un dictionnaire peut être parcouru terme à terme, cependant, comme la pluspart des objets python, les dictionnaires possèdent des fonctions permettant de les transformer en une liste:

.items()
.keys()
.values()

ou un iterator

.iteritems()
.iterkeys()
.itervalues()


Les dictionnaires n'ont pas de structure ordonnée, les valeurs ne s'affichent pas forcément dans l'ordre dans lequel on les a entrées.


In [ ]:
dico={"a":1,"b":2,"c":3}
for k in dico.keys():
    print (k)

Les fonctions .items() et .iteritems() permettent de parcourir les couples (clés, objets) des dictionnaires.


In [ ]:
# Si une seule variable itérative est spécifiée, celle-ci est un-tuple.
for kv in dico.items():
    print (kv)

In [ ]:
# Si deux variables itératives sont spécifiées, la première est la clé, la seconde la valeur correspondante
for k,v in dico.items():
    print (k)
    print (v)

In [ ]:
# Si la clé ou la valeur du dictionnaire n'est pas nécessaire, elle n'est pas stockée dans une variable en utilisant "_"
for k,_ in dico.items():
    print (k)
Numpy Array

La fonction nditer permet de parcourir tous les éléments d'un tableau (array) Numpy.


In [ ]:
import numpy as np
a = np.arange(6).reshape(2,3)
for x in np.nditer(a):
    print (x)

Appliquer directement la boucle for sur le tableau Numpy permet de parcourir les différentes lignes de ce tableau.


In [ ]:
for x in a:
    print (x)

Pour parcourir les colonnes, la métode la plus simple consiste à parcourir les lignes de la matrice transposée.


In [ ]:
for x in a.T:
    print (x)

Il est cependant très important de comprendre que les arrays numpy ont été conçus pour appliquer des fonctions directement sur l'ensemble du tableau en évitant des boucles. Ainsi nombre de fonctions natives permettent de résoudre de nombreux problèmes sans avoir à parcourir l'ensemble de la matrice: sum, max, etc... permettent d'obtenir les résultats escomptés de manière bien plus rapide qu'en utilisant les boucles for.


In [ ]:
mina = a.max()              # Retourne la valeur maximum de l'array a
minxa = a.max(axis=1)       # Retourne la valeur maximum de chaque ligne de l'array a
minya = a.max(axis=0)       # Retourne la valeur maximum de chaque colonne de l'array a
print(mina, minxa, minya)

Pour appliquer des fonctions plus complexes sur chaque ligne et/ou colonne de l'array, il existe également des fonctions natives de la librairie numpy permettant d'appliquer ces fonctions efficacement comme par exemple apply_along_axis, similaire à apply de R.


In [ ]:
def scale(x):
    xcr = (x-np.mean(x))/np.std(x)
    return xcr
X = np.random.randint(5, size=(3, 3))
Xcr = np.apply_along_axis(scale,1,X)
print(X, Xcr)
Pandas DataFrame

De la même manière que pour les arrays numpy, les DataFrame Pandas ne sont pas conçus pour être parcourus facilement. Il est, là encore, préférable d'utiliser au maximum les méthodes natives de pandas ou de numpy, ou de convertir au préalable les colonnes souhaitées en listes ou encore même de convertir un DataFrame en array avant un traitemnte itératif.

Il éxiste deux fonctions permettant de parcourir les différentes colonnes d'une DataFrame Pandas:

  • iterrows(): retourne le couple (index, ligne). Chaque ligne est parcourue sous la forme d'un objet Series de Pandas. Se construction est très coûteuse et rend la méthode très lente .
  • itertuples(): retoure un object specifique Pandas, où l'index, et les valeurs des colonnes de chaque ligne est accesible via des méthodes spécifiques. Plus rapide que iterrows.

In [ ]:
import pandas as pd
df = pd.DataFrame([["A",4],["B",5],["C",6]],index=[1,2,3],columns=["Letter","Number"])

for i,r in df.iterrows():
    print(i,r)

for ir in df.itertuples():
    print(ir)

Librarie Itertools

La librairie native de python itertools possède de nombreuses fonctions générant des iterators. Plus d'exemples dans la documentation.


In [ ]:
import itertools
#zip : Concatenne les éléments de plusieurs objets itérables
zip_list = []
for k in zip('ABCD',[1,2,3,4],"wxyz"):
    zip_list.append(k)
print("zip")
print(zip_list)

In [ ]:
#permutation : retourne tous les arrangements possibles de liste de longueur n.
permutation_list = []
for k in itertools.permutations("ABCD",2):
    permutation_list.append(k)
print("permutations")
print(permutation_list)

One-Line Statement

Après avoir parouru différents éléments d'un objet itérable et pour enregistrer un résultat pour chaque étape dans une liste, il est possible d'écrire la boucle for sur une seule ligne de la manière suivante:

[result for variable in iterator]


In [ ]:
# Version1
A1=[]
for k in range(10):
    A1.append(k*k)
    
# Version 2
A2 = [k*k for k in range(10)]
print(A1,A2)

Affectation des indices

Des expressions telles que x = x + 1 ou x = x - 2 apparaissent très souvent dans le corps des boucles. Le language Python permet de simplifier ces notations.


In [ ]:
a = 17
s = "hi"
a += 3           # Equivalent to a = a + 3
a -= 3           # Equivalent to a = a - 3
a *= 3           # Equivalent to a = a * 3
a /= 3           # Equivalent to a = a / 3
a %= 3           # Equivalent to a = a % 3
s += " there"    # Equivalent to s = s + “ there"

1.2 Structure itérative while

La boucle for permet de parcourir l'ensemble des éléments d'un objet itérable, ou un nombre déterminé d'éléments de ce dernier. Cependant, ce nombre n'est pas toujours prévisible et il est possible de parcourir ces éléments et arrêter le parcours lorsqu'une condition est respéctée ou non. C'est l'objet de l'instruction while avec la syntaxe:

while condition:
     instructions

Cette instruction permet de répéter en boucle les instructions jusqu'à ce que la condition soit vérifiée.


In [ ]:
# Incrémentation de `count` jusqu'à ce qu'elle dépasse la valeur 100.000
count = 1 
while count <= 100000: 
    count += 1 
print (count)

L'instruction break permet de sortir de la boucle while même si sa condition est respectée.


In [ ]:
while True: 
    number = int(input("Enter the numeric grade: ")) 
    if number >= 0 and number <= 100:
        break
    else:
        print ("Error: grade must be between 100 and 0" )
    print (number)

1.3 Structures conditionnelles if - else

L'instruction conditionelle if-else est une des plus communes en programmation informatique. En python, elle se présente sous la forme suivante:

if condition:
     instructions 1
else:
     Instructions 2


In [ ]:
number=1
if number==1:
    print (True)
else:
    print (False)

Lorsque plus de deux alternative sont possibles, utiliser l'instruction elif pour énumérer les différentes possibilités.


In [ ]:
number=13
if number<5:
    print("A")
elif number <10:
    print("B")
elif number <20:
    print("C")
else:
    print("D")

One-Line Statement

Comme pour la boucle for, il est possible d'écrire l'instruction if_else en une seule ligne lorsque le code est simple.


In [ ]:
number=10
"A" if number >10 else "B"

One line with for Loop

Différentes combinaisons sont possibles pour associer à la fois l'instruction if_else avec une boucle for en une ligne.


In [ ]:
#Sélectionne uniquement les valeur paire
l1 = [k for k in range(10) if k%2==0]
l1

In [ ]:
#Retourne "even" si l'élement k est pair, "odd" sinon.
l2 = ["even" if k%2==0 else "odd" for k in range(10)]
l2

2 Programmation fonctionnelle

L'utilisation de higher-order functions permet d'éxecuter rapidement des schémas classiques:

  • appliquer la même fonction aux éléments d'une liste,
  • séléctionner, ou non, les différents éléments d'une liste selon une certaine condition,
  • ...

Important: il s'agit ici d'introduitre les éléments de programmation fonctionnelle, présents dans Python, et utilisés systématiquement, car parallélisable, dans des architectures distribuées (e. g. Hadoop, Spark).

2.1 map

La première de cette fonction est la fonction map. Elle permet d'appliquer une fonction sur toutes les valeurs d'une liste et retourne une nouvelle liste avec les résultats correspondants.


In [ ]:
import random
numbers = [random.randrange(-10,10) for k in range(10)]
abs_numbers = map(abs,numbers) # Applique la fonction "valeur absolue" à tout les élements de la liste
print(numbers,list(abs_numbers))

In [ ]:
def first_capital_letters(txt):
    if txt[0].islower():
        txt = txt[0].upper()+txt[1:]
    return txt

name=["Jason","bryan","hercule","Karim"]
list(map(first_capital_letters,name))

2.1 filter

La fonction filter permet d'appliquer une fonction test à chaque valeur d'une liste. Si la fonction test est vérifiée, la valeur est ajoutée dans une nouvelle liste. Sinon, la valeur n'est pas prise en compte. La nouvelle liste, constitué de toutes les valeures "positive" selon la fonction test, est retournée.


In [ ]:
def is_odd(n):
    return n % 2 == 1
list(filter(is_odd,range(20)))

2.2 reduce

La dernière fonction est la fonction reduce. Cette approche est loin d'être intuitive. Le meilleur moyen de comprendre le mode d'emploi de cette fonction est d'utiliser un exemple.

L'objectif est de calculer la somme de tous les entier de 0 à 9.

  • Générer dans un premier temps la liste contenant tout ces éléments r10 = [0,1,2,3,4,5,6,7,8,9]
  • La fonction reduce, applique une première fois la fonction sum_and_print sur les deux premiers éléments de la liste.
  • Exécution récursive: la fonction sum_and_print est appliquée sur le résultat de la première opération et sur le 3ème éléments de la liste
  • Itération récursive jusqu'à ce que tous les éléments de la liste soient parcourus

La fonction reduce a donc deux arguments: une fonction et une liste.


In [ ]:
import functools
def sum_and_print(x,y):
    print("Input: ", x,y)
    print("Output: ", y)
    return x+y

r10 = range(10)
res =functools.reduce(sum_and_print, r10)
print(res)

Par défaut, la fonction passée en paramètre de la fonction reduce effectue sa première opérations sur les deux premiers éléments de la listes passés en paramètre. Mais il est possible de spécifier une valeur initiale en troisième paramètre. La première opération sera alors effectuée sur cette valeur initiale et le premier élément de la liste.


In [ ]:
def somme(x,y):
    return x+y

r10 = range(10)
res =functools.reduce(somme, r10,1000)
print(res)

2.4 lambda

L'utilisation de fonctions génériques permet de simplifier le code mais il est coûteux de définir une nouvelle fonction qui peut ne pas être réutilisée, comme par exemple celle de l'exemple précédent. L'appel lambda permet de créer une fonction de façon temporaire. La définition de ces fonctions est assez restrictive, puisqu'elle implique une définition sur une seule ligne, et ne permet pas d'assignation.

Autre point important, l'exécution de cette fonction sur des données distribuées est implicitement parallélisée.

Ainsi les précédents exemples peuvent-être ré-écrits de la manière suivante:


In [ ]:
name=["Jason","bryan","hercule","Karim"]
list(map(lambda x : x[0].upper()+x[1:] if x[0].islower() else x,name))

In [ ]:
list(filter(lambda x : x % 2 == 1 ,range(10)))

In [ ]:
r10 = range(10)
res =functools.reduce(lambda x,y:x+y, r10,1000)
res

3 Classes et objets

3.1 Définitions et exemples

Les classes sont des objets communs à tous les langages orientés objets. Ce sont des objets constitués de

  • attributs: des paramètres fixes, de différentes natures, attribués à l'objet,
  • méthodes: des fonctions qui permettent d'appliquer des transformations sur ces attributs.

Ci dessous on définit une classe "Elève" dans laquelle un élève est décrit par son nom, son prénom et ses notes. On notera la convention de nommage des méthodes qui commence par une minuscule, mais qui possède une majuscule à chaque début de nouveau mot.


In [ ]:
class Eleve:
    """Classe définissant un élève caractérisé par:
        - son nom
        - son prénom
        - ses notes
     """  
    
    def __init__(self, nom, prenom): #constructeur de la classe
        """ Construit un élève avec les nom et prenom passé en paramètre et une liste de notes vide."""
        self._nom = nom
        self._prenom=prenom
        self._notes = []
        
    def getNom(self):
        """ retourne le nom de l'élève """
        return self._nom
    
    def getNotes(self):
        """ retourne les notes de l'élève"""
        return self._notes
    
    def getNoteMax(self):
        """ retourne la note max de l'élève"""
        return max(self._notes)
    
    def getMean(self):
        """ retourne la moyenne de l'élève"""
        return np.mean(self._notes)
    
    def getNbNote(self):
        """ retourne le nombre de note de l'élève"""
        return len(self._notes)
    
    def addNote(self, note):
        """ ajoute la note 'note' à la liste de note de l'élève"""
        self._notes.append(note)

Toutes les classes sont composées d'un constructeur qui a pour nom __init__. Il s'agit d'une méthode spéciale d'instance que Python reconnaît et sait utiliser dans certains contextes. La fonction __init__ est automatiquement appelée à la création d'une nouvelle classe et prend en paramètre self, qui représente l'objet instantié, et les différents attributs nécessaires à sa création.


In [ ]:
eleve1 = Eleve("Jean","Bon")
eleve1._nom

Les attributs de la classe sont directement accessibles de la manière suivante:

objet.nomDeLAtribbut

Cependant, par convention, il est conseillé de définir une méthode pour avoir accès à cet objet. Les méthodes qui permettent d'accéder à des attributs de l'objets sont appelés des accessors. Dans la classe élève, les méthodes commençant par get sont des accessors.


In [ ]:
eleve1.getNom()

Les méthodes permettant de modifier les attributs d'un objet sont appelées des mutators. La fonction addNote, qui permet d'ajouter une note à la liste de notes de l'élève est un mutator.


In [ ]:
print(eleve1.getNotes())
eleve1.addNote(15)
print(eleve1.getNotes())

In [ ]:
for k in range(10):
    eleve1.addNote(np.random.randint(20))

In [ ]:
print (eleve1.getNbNote())
print (eleve1.getNoteMax())
print (eleve1.getMean())

3.2 Héritage

L'héritage est une fonctionnalité qui permet de définir une classe "fille" à partir d'une autre classe "mère". La classe fille hérite alors automatiquement de tous les attributs et de toutes les méthodes de la classe mère.


In [ ]:
class EleveSpecial(Eleve):
    
    def __init__(self, nom, prenom, optionName):
        Eleve.__init__(self, nom, prenom)
        self._optionName = optionName
        self._optionNotes = []

    def getNotesOption(self):
        """ retourne les notes de l'élève"""
        return self._optionNotes
    
    def addNoteOption(self, note):
        """ ajoute la note 'note' à la liste de note de l'élève"""
        self._optionNotes.append(note)

In [ ]:
eleve2 = EleveSpecial("Sam","Stress","latin")

In [ ]:
eleve2.addNote(14)
print (eleve2.getNotes())
eleve2.addNoteOption(12)
print (eleve2.getNotesOption())

3.3 Classes de Scikit-learn

Les méthodes d'apprentissage statistique de la librairie Scikit-learn sont un parfait exemple d'utilisation de classes. Sans entrer en détail dans l'implémentation de cette méthode, voici l'exemple de la Régression linéaire par moindres carrés.

Toutes les fonctions de Scikit-learn sont définies sur de modèle. Voir dans le tutoriel d'apprentissage statistique avec Scikit-learn comment ces propriétés sont utilisées pour enchaîner (pipeline) des exécutions.

L'objet LinearRegression est une classe qui est initée avec des attributs suivants:

  • fit_intercept : si le terme constant doit être estimé ou considéré à 0
  • normalize : si le jeux d'apprentissage doit être normalisé avant la regression
  • copy_X : si le jeux d'apprentissage doit être copié pour éviter les effets de bords
  • n_jobs : le nombre de processeur à utiliser

In [ ]:
from sklearn.linear_model import LinearRegression
lr = LinearRegression(fit_intercept=True, normalize=False, copy_X=True, n_jobs=1)
print (lr.fit_intercept, lr.normalize, lr.copy_X, lr.n_jobs)

La classe LinearRegression possède également des attributs qui sont mis à jour à l'aide des méthodes:

  • coef_ : coefficients estimés
  • residues_ : somme des résidus
  • intercept_ : terme constant

La méthode fit de cette classe est un mutator. Elle prend en paramètre le jeux d'apprentissage, X_train et la variable réponse correspondante Y_train pour estimer les paramètres de la regression linéaire et ainsi mettre à jour les attributs correspondants.


In [ ]:
X_train=[[0, 0], [1, 1], [2, 2]]
Y_train =  [0, 1, 2]
lr.fit (X_train, Y_train)
lr.coef_

La classe LinearRegression possède aussi d'autres méthodes qui utilisent les attributs de la classe. Par exemple:

  • predict: estime la prévision de la variable réponse d'un jeu test X_test
  • score: retourne le coefficient R2 de qualité de la prévision.

In [ ]:
X_test = [[1.5,1.5],[2,4],[7.3,7.1]]
Y_test = [1.5,2.4,7]
pred = lr.predict(X_test)
s = lr.score(X_test,Y_test)
print(pred,s)

4 Packing et Unpacking

Section plus technique qui peut être sautée en première lecture.

L'opérateur * permet, selon la situation, de "paquéter" ou "dépaquéter" les éléments d'une liste.

L'opérateur ** permet, selon la situation, de "paquéter" ou "dépaquéter" les éléments d'un dictionnaire.

4.1 Unpacking

Dans l'exemple ci-dessous. Les opérateurs * et ** dépaquettent les listes et dictionnaires pour les passer en arguments de fonctions.


In [ ]:
def unpacking_list_and_print(a, b):
    print (a)
    print (b)
listarg = [3,4]
unpacking_list_and_print(*listarg)
    
def unpacking_dict_and_print(k1=0, k2=0):
    print (k1)
    print (k2)
dictarg = {'k1':4, 'k2':8}
unpacking_dict_and_print(**dictarg)

4.2 Packing

Ces opérateurs sont surtout utiles dans le sens du "packing". Les fonctions sont alors définies de sorte à recevoir un nombre inconnu d'argument qui seront ensuite "paquétés" et traités dans la fonction.

L'argument *args permet à la fonction de recevoir un nombre supplémentaire inconnu d'arguments sans mot-clef associé.


In [ ]:
def packing_and_print_args(required_arg, *args):
    print ("arg Nécessaire:", required_arg)
    for i, arg in enumerate(args):
        print ("args %d:" %i, arg)

packing_and_print_args(1, "two", 3)
packing_and_print_args(1, "two", [1,2,3],{"a":1,"b":2,"c":3})

L'argument **kwargs permet à la fonction de recevoir un nombre supplémentaire inconnu d'arguments avec mot-clef.


In [ ]:
def packing_and_print_kwargs(def_kwarg=2, **kwargs):
    print ("kwarg défini:", def_kwarg)
    for i,(k,v) in enumerate(kwargs.items()):
        print ("kwarg %d:" %i ,k , v) 

packing_and_print_kwargs(def_kwarg=1, sup_arg1="two", sup_arg2=3)
packing_and_print_kwargs(sup_arg1="two", sup_arg2=3, sup_arg3=[1,2,3])

Les arguments *args et **kwargs peuvent être combinés dans une autre fonctions.


In [ ]:
def packing_and_print_args_and_kwargs(required_arg ,def_kwarg=2, *args, **kwargs):
    print ("arg Nécessaire:", required_arg)
    for i, arg in enumerate(args):
        print ("args %d:" %i, arg)
    print ("kwarg défini:", def_kwarg)
    for i,(k,v) in enumerate(kwargs.items()):
        print ("kwarg %d:" %i ,k , v )

packing_and_print_args_and_kwargs(1, "two", [1,2,3] ,sup_arg1="two", sup_arg2=3 )

Ces deux opérateurs sont très utiles pour gérer des classes liées par des héritages. Les arguments *args **kwargs permettent alors de gérer la tranmission de cet héritage sans avoir à redéfinir les arguments à chaque étape.


In [ ]:
class Objet(object):
    def __init__(self, attribut=None, *args, **kwargs):
        print (attribut)

class Objet2Point0(Objet):
    def __init__(self, *args, **kwargs):
        super(Objet, self).__init__(*args, **kwargs)

class Objet3Point0(Objet2Point0):
    def __init__(self,attribut2=None, *args, **kwargs):
        super(Objet2Point0, self).__init__(*args, **kwargs)
        print (attribut2)

my_data = {'attribut': 'Argument1', 'attribut2': 'Argument2'}
Objet3Point0(**my_data)

Référence

Lambert K. et Osborne M. (2010). Fundamentals of Python: From First Programs Through Data Structures, Course Technology.