13. Numba#

En plus de ce qui est inclus dans Anaconda, ce cours nécessitera les bibliothèques suivantes :

!pip install quantecon

Hide code cell output

Requirement already satisfied: quantecon in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (0.11.4)
Requirement already satisfied: numba>=0.49.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (0.65.1)
Requirement already satisfied: numpy>=1.17.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (2.4.6)
Requirement already satisfied: requests in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (2.34.2)
Requirement already satisfied: scipy>=1.5.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (1.17.1)
Requirement already satisfied: sympy in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from quantecon) (1.14.0)
Requirement already satisfied: llvmlite<0.48,>=0.47.0dev0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from numba>=0.49.0->quantecon) (0.47.0)
Requirement already satisfied: charset_normalizer<4,>=2 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (3.4.4)
Requirement already satisfied: idna<4,>=2.5 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (3.18)
Requirement already satisfied: urllib3<3,>=1.26 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (2.7.0)
Requirement already satisfied: certifi>=2023.5.7 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from requests->quantecon) (2026.5.20)
Requirement already satisfied: mpmath<1.4,>=1.1.0 in /home/runner/miniconda3/envs/quantecon/lib/python3.13/site-packages (from sympy->quantecon) (1.3.0)

Veuillez également vous assurer que vous disposez de la dernière version d’Anaconda, car les anciennes versions sont une source fréquente d’erreurs.

Commençons par quelques importations :

import numpy as np
import quantecon as qe
import matplotlib.pyplot as plt

13.1. Vue d’ensemble#

Dans un cours précédent, nous avons abordé la vectorisation, qui peut améliorer la vitesse d’exécution en envoyant les opérations de traitement de tableaux par lots vers du code de bas niveau efficace.

Cependant, comme discuté dans ce cours, les schémas de vectorisation traditionnels présentent des faiblesses :

  • Très gourmands en mémoire pour les opérations composées sur les tableaux

  • Inefficaces ou impossibles pour certains algorithmes

Une façon de contourner ces problèmes consiste à utiliser Numba, un compilateur à la volée (JIT) pour Python.

Numba compile les fonctions en instructions machine natives lors de l’exécution.

Lorsqu’il y parvient, le résultat est une performance comparable à celle du code C ou Fortran compilé.

De plus, Numba peut effectuer des astuces utiles telles que le multithreading.

Ce cours présente les idées essentielles.

Note

Certains lecteurs pourraient être curieux de connaître la relation entre Numba et Julia, qui contient son propre compilateur JIT. Bien que les deux compilateurs soient similaires à de nombreux égards, Numba est moins ambitieux, ne cherchant à compiler qu’un petit sous-ensemble du langage Python. Bien que cela puisse ressembler à un défaut, c’est aussi une force : la nature plus restrictive de Numba le rend facile à bien utiliser et performant dans ce qu’il fait.

13.2. Compiler des fonctions#

13.2.1. Un exemple#

Considérons un problème difficile à vectoriser (c’est-à-dire à confier à des opérations de traitement de tableaux).

Le problème consiste à générer la trajectoire via l’application quadratique

\[ x_{t+1} = \alpha x_t (1 - x_t) \]

Dans ce qui suit, nous posons \(\alpha = 4\).

13.2.1.1. Version de base#

Voici le tracé d’une trajectoire typique, à partir de \(x_0 = 0.1\), avec \(t\) sur l’axe des abscisses

def qm(x0, n, α=4.0):
    x = np.empty(n+1)
    x[0] = x0
    for t in range(n):
      x[t+1] = α * x[t] * (1 - x[t])
    return x

x = qm(0.1, 250)
fig, ax = plt.subplots()
ax.plot(x, 'b-', lw=2, alpha=0.8)
ax.set_xlabel('$t$', fontsize=12)
ax.set_ylabel('$x_{t}$', fontsize = 12)
plt.show()
_images/f05c18bf5c35e2238d9361f72445d8033b5635493b2bc4bc818c894e29b9cdbc.png

Voyons combien de temps cela prend pour un grand \(n\)

n = 10_000_000

with qe.Timer() as timer1:
    # Chronométrage de la version Python de base
    x = qm(0.1, n)
3.6051 seconds elapsed

13.2.1.2. Accélération via Numba#

Pour accélérer la fonction qm à l’aide de Numba, nous importons d’abord la fonction jit

from numba import jit

Nous l’appliquons maintenant à qm, produisant une nouvelle fonction :

qm_numba = jit(qm)

La fonction qm_numba est une version de qm qui est « ciblée » pour la compilation JIT.

Nous expliquerons ce que cela signifie dans un instant.

Chronométrons cette nouvelle version :

with qe.Timer() as timer2:
    # Chronométrage de la version jittée
    x = qm_numba(0.1, n)
0.1378 seconds elapsed

C’est un gain de vitesse important.

En fait, la fois suivante et toutes les fois suivantes, elle s’exécute encore plus vite car la fonction a été compilée et se trouve en mémoire :

with qe.Timer() as timer3:
    # Deuxième exécution
    x = qm_numba(0.1, n)
0.0246 seconds elapsed

Voici le gain de vitesse

timer1.elapsed /  timer3.elapsed
146.34723821875514

C’est un grand gain pour une petite modification de notre code d’origine.

Voyons comment cela fonctionne.

13.2.2. Comment et quand cela fonctionne#

Numba tente de générer du code machine rapide en utilisant l’infrastructure fournie par le projet LLVM.

Il le fait en inférant les informations de type à la volée.

(Voir notre cours précédent sur le calcul scientifique pour une discussion sur les types.)

L’idée de base est la suivante :

  • Python est très flexible et nous pourrions donc appeler la fonction qm avec de nombreux types.

    • par exemple, x0 pourrait être un tableau NumPy ou une liste, n pourrait être un entier ou un flottant, etc.

  • Cela rend très difficile la génération de code machine efficace à l’avance (c’est-à-dire avant l’exécution).

  • Cependant, lorsque nous appelons effectivement la fonction, par exemple en exécutant qm(0.5, 10), les types de x0, α et n sont déterminés.

  • De plus, les types des autres variables dans qm peuvent être inférés une fois les types d’entrée connus.

  • La stratégie de Numba et des autres compilateurs JIT consiste donc à attendre que la fonction soit appelée, puis à compiler.

C’est ce qu’on appelle la compilation « à la volée » (« just-in-time »).

Notez que, si vous effectuez l’appel qm_numba(0.5, 10) puis le faites suivre de qm_numba(0.9, 20), la compilation n’a lieu qu’au premier appel.

C’est parce que le code compilé est mis en cache et réutilisé au besoin.

C’est pourquoi, dans le code ci-dessus, la deuxième exécution de qm_numba est plus rapide.

Remarque

En pratique, plutôt que d’écrire qm_numba = jit(qm), nous utilisons généralement la syntaxe de décorateur et plaçons @jit avant la définition de la fonction. Cela équivaut à ajouter qm = jit(qm) après la définition.

13.3. Points délicats#

Numba est relativement facile à utiliser mais pas toujours sans accroc.

Passons en revue certains des problèmes que les utilisateurs rencontrent.

13.3.1. Typage#

Une inférence de type réussie est la clé de la compilation JIT.

Dans un contexte idéal, Numba peut inférer toutes les informations de type nécessaires.

Lorsque Numba ne peut pas inférer toutes les informations de type, il lève une erreur.

Par exemple, dans le contexte ci-dessous, Numba est incapable de déterminer le type de la fonction g lors de la compilation de iterate

@jit
def iterate(f, x0, n):
    x = x0
    for t in range(n):
        x = f(x)
    return x

# Non jitté
def g(x):
    return np.cos(x) - 2 * np.sin(x)

# Ce code génère une erreur
try:
    iterate(g, 0.5, 100)
except Exception as e:
    print(e)
Failed in nopython mode pipeline (step: nopython frontend)
non-precise type pyobject
During: typing of argument at /tmp/ipykernel_2960/3393735757.py (1)

File "../../../../../../tmp/ipykernel_2960/3393735757.py", line 1:
<source missing, REPL/exec in use?>

During: Pass nopython_type_inference 

This error may have been caused by the following argument(s):
- argument 0: Cannot determine Numba type of <class 'function'>

Dans le cas présent, nous pouvons facilement corriger cela en compilant g.

@jit
def g(x):
    return np.cos(x) - 2 * np.sin(x)

iterate(g, 0.5, 100)
2.223875299559663

Dans d’autres cas, comme lorsque nous voulons utiliser des fonctions de bibliothèques externes telles que SciPy, il pourrait ne pas y avoir de solution de contournement simple.

13.3.2. Variables globales#

Une autre chose à laquelle il faut faire attention lors de l’utilisation de Numba est la gestion des variables globales.

Par exemple, considérez le code suivant

a = 1

@jit
def add_a(x):
    return a + x

print(add_a(10))
11
a = 2

print(add_a(10))
11

Remarquez que changer la variable globale n’a eu aucun effet sur la valeur renvoyée par la fonction 😱.

Lorsque Numba compile du code machine pour les fonctions, il traite les variables globales comme des constantes afin de garantir la stabilité des types.

Pour éviter cela, passez les valeurs comme arguments de fonction plutôt que de dépendre des variables globales.

13.4. Boucles multithreadées dans Numba#

En plus de la compilation JIT, Numba offre un support pour le calcul parallèle sur CPU et GPU.

L’outil clé pour la parallélisation sur CPU dans Numba est la fonction prange, qui indique à Numba d’exécuter les itérations de boucle en parallèle sur les cœurs disponibles.

Pour illustrer, examinons d’abord un morceau de code simple, à un seul thread (c’est-à-dire non parallélisé).

Le code simule la mise à jour de la richesse \(w_t\) d’un ménage via la règle

\[ w_{t+1} = R_{t+1} s w_t + y_{t+1} \]

Ici

  • \(R\) est le taux de rendement brut des actifs

  • \(s\) est le taux d’épargne du ménage et

  • \(y\) est le revenu du travail.

Nous modélisons à la fois \(R\) et \(y\) comme des tirages indépendants d’une loi log-normale.

Voici le code :

@jit
def update(w, r=0.1, s=0.3, v1=0.1, v2=1.0):
    " Met à jour la richesse du ménage. "
    # Tirage des chocs
    R = np.exp(v1 * np.random.randn()) * (1 + r)
    y = np.exp(v2 * np.random.randn())
    # Mise à jour de la richesse
    w = R * s * w + y
    return w

Voyons comment la richesse évolue sous cette règle.

fig, ax = plt.subplots()

T = 100
w = np.empty(T)
w[0] = 5
for t in range(T-1):
    w[t+1] = update(w[t])

ax.plot(w)
ax.set_xlabel('$t$', fontsize=12)
ax.set_ylabel('$w_{t}$', fontsize=12)
plt.show()
_images/6040b22c2c47e22a9b0d244c36405cde43cc12b213b53dae16ad34db932e3915.png

Supposons maintenant que nous ayons une grande population de ménages et que nous voulions savoir quelle sera la richesse médiane.

Ce n’est pas facile à résoudre avec un crayon et du papier, nous utiliserons donc la simulation à la place :

  1. Simuler un grand nombre de ménages dans le temps

  2. Calculer la richesse médiane

Voici le code :

@jit
def compute_long_run_median(w0=1, T=1000, num_reps=50_000):
    obs = np.empty(num_reps)
    # Pour chaque ménage
    for i in range(num_reps):
        # Fixer la condition initiale et avancer dans le temps
        w = w0
        for t in range(T):
            w = update(w)
        # Enregistrer la valeur finale
        obs[i] = w
    # Prendre la médiane de toutes les valeurs finales
    return np.median(obs)

Voyons à quelle vitesse cela s’exécute :

with qe.Timer():
    # Préchauffage
    compute_long_run_median()
5.5473 seconds elapsed
with qe.Timer():
    # Deuxième exécution
    compute_long_run_median()
4.6893 seconds elapsed

Pour accélérer cela, nous allons le paralléliser via le multithreading.

Pour ce faire, nous ajoutons l’option parallel=True et remplaçons range par prange :

from numba import prange

@jit(parallel=True)
def compute_long_run_median_parallel(
        w0=1, T=1000, num_reps=50_000
    ):
    obs = np.empty(num_reps)
    for i in prange(num_reps):  # Parallélisation sur les ménages
        w = w0
        for t in range(T):
            w = update(w)
        obs[i] = w
    return np.median(obs)

Regardons le chronométrage :

with qe.Timer():
    # Préchauffage
    compute_long_run_median_parallel()
1.3791 seconds elapsed
with qe.Timer():
    # Deuxième exécution
    compute_long_run_median_parallel()
0.9615 seconds elapsed

L’accélération est significative.

Remarquez que nous parallélisons entre les ménages plutôt que sur le temps – les mises à jour d’un ménage individuel à travers les périodes de temps sont intrinsèquement séquentielles.

Pour la parallélisation basée sur GPU, voir nos cours sur JAX.

13.5. Exercices#

Exercice 13.1 et Exercice 13.3 estiment tous deux \(\pi\) par Monte-Carlo à partir d’échantillons aléatoires dans le carré unité.

Nous les générons ici et les stockons dans u_draws et v_draws afin de pouvoir les utiliser dans les deux exercices et comparer les résultats

n = 1_000_000
rng = np.random.default_rng()
u_draws = rng.uniform(size=n)
v_draws = rng.uniform(size=n)

Exercice 13.1

Précédemment, nous avons examiné comment approcher \(\pi\) par Monte-Carlo.

Utilisez la même idée ici, mais rendez le code efficace en utilisant Numba.

Comparez la vitesse avec et sans Numba lorsque la taille de l’échantillon est grande.

Exercice 13.2

Dans la série de cours Introduction to Quantitative Economics with Python, vous pouvez tout apprendre sur les chaînes de Markov à états finis.

Pour l’instant, concentrons-nous simplement sur la simulation d’un exemple très simple d’une telle chaîne.

Supposons que la volatilité des rendements d’un actif puisse se trouver dans l’un des deux régimes — élevé ou faible.

Les probabilités de transition entre les états sont les suivantes

_images/nfs_ex1.png

Par exemple, prenons une durée de période d’un jour, et supposons que l’état actuel est élevé.

Nous voyons sur le graphique que l’état de demain sera

  • élevé avec une probabilité de 0,8

  • faible avec une probabilité de 0,2

Votre tâche est de simuler une séquence d’états quotidiens de volatilité selon cette règle.

Fixez la longueur de la séquence à n = 1_000_000 et commencez dans l’état élevé.

Implémentez une version en Python pur et une version Numba, et comparez les vitesses.

Pour tester votre code, évaluez la fraction du temps que la chaîne passe dans l’état faible.

Si votre code est correct, elle devrait être d’environ 2/3.

Exercice 13.3

Dans un exercice précédent, nous avons utilisé Numba pour accélérer un effort de calcul de la constante \(\pi\) par Monte-Carlo.

Essayez maintenant d’ajouter la parallélisation et voyez si vous obtenez des gains de vitesse supplémentaires.

Vous ne devez pas vous attendre à d’énormes gains ici car, bien qu’il y ait de nombreuses tâches indépendantes (tirer un point et tester s’il est dans le cercle), chacune a un temps d’exécution faible.

De manière générale, la parallélisation est moins efficace lorsque les tâches individuelles à paralléliser sont très petites par rapport au temps d’exécution total.

Cela est dû aux surcoûts associés à la répartition de toutes ces petites tâches sur plusieurs CPU.

Néanmoins, avec un matériel adapté, il est possible d’obtenir des gains de vitesse non triviaux dans cet exercice.

Pour la taille de la simulation Monte-Carlo, utilisez quelque chose de substantiel, comme n = 100_000_000.

Exercice 13.4

Dans Exercice 13.3, nous avons tiré tous les points aléatoires avant la boucle parallèle.

Il est tentant de plutôt tirer chaque point à l’intérieur de la boucle prange, en passant un générateur rng en argument et en appelant rng.uniform() dans le corps de la boucle.

Essayez-le : le code devrait s’exécuter et renvoyer un nombre proche de \(\pi\), pourtant il y a un bug subtil dans cette approche.

Enquêtez comme suit :

  1. Appelez votre fonction quelques fois avec la même graine et vérifiez si le résultat est reproductible.

  2. Répétez l’estimation de nombreuses fois sur une gamme de tailles d’échantillon et comparez sa dispersion à celle d’une version parallèle correcte.

Expliquez ensuite ce qui ne va pas et donnez une manière correcte de tirer à l’intérieur d’une boucle parallèle.

Astuce : essayez d’utiliser une fonction aléatoire ancienne telle que np.random.uniform() au lieu d’un Generator et voyez ce qui se passe.

Exercice 13.5

Nous avons maintenant deux façons correctes d’estimer \(\pi\) en parallèle.

L’une tire tous les points avant la boucle, comme dans Exercice 13.3.

L’autre les tire à l’intérieur de la boucle avec des fonctions anciennes, comme dans Exercice 13.4.

Comparez leur vitesse à n = 100_000_000, en incluant le temps passé à générer les points aléatoires.

Exercice 13.6

Dans notre cours sur SciPy, nous avons abordé l’évaluation d’une option d’achat dans un contexte où le prix de l’action sous-jacente suivait une distribution simple et bien connue.

Ici, nous abordons un contexte plus réaliste.

Rappelons que le prix de l’option obéit à

\[ P = \beta^n \mathbb E \max\{ S_n - K, 0 \} \]

  1. \(\beta\) est un facteur d’actualisation,

  2. \(n\) est la date d’échéance,

  3. \(K\) est le prix d’exercice et

  4. \(\{S_t\}\) est le prix de l’actif sous-jacent à chaque instant \(t\).

Supposons que n, β, K = 20, 0.99, 100.

Supposons que le prix de l’action obéit à

\[ \ln \frac{S_{t+1}}{S_t} = \mu + \sigma_t \xi_{t+1} \]

\[ \sigma_t = \exp(h_t), \quad h_{t+1} = \rho h_t + \nu \eta_{t+1} \]

Ici \(\{\xi_t\}\) et \(\{\eta_t\}\) sont IID et normales centrées réduites.

(Il s’agit d’un modèle de volatilité stochastique, où la volatilité \(\sigma_t\) varie dans le temps.)

Utilisez les valeurs par défaut μ, ρ, ν, S0, h0 = 0.0001, 0.1, 0.001, 10, 0.

(Ici S0 est \(S_0\) et h0 est \(h_0\).)

En générant \(M\) trajectoires \(s_0, \ldots, s_n\), calculez l’estimation Monte-Carlo

\[ \hat P_M := \beta^n \mathbb E \max\{ S_n - K, 0 \} \approx \frac{1}{M} \sum_{m=1}^M \max \{S_n^m - K, 0 \} \]

du prix, en appliquant Numba et la parallélisation.