D2 – Monte-Carlo & Mandelbrot¶

Python numérique – devoir n° 2 – à rendre pour le dimanche 27 septembre 2026, 23 h 59 (la séance 3 est le mardi 29)

Bienvenue dans votre deuxième devoir ! Nous allons y mettre en pratique les notions vues en séance 2 : fonctions vectorisées (ufunc), agrégations avec axis, broadcasting, masques booléens et générateur de nombres aléatoires. Le sujet comporte trois parties indépendantes, avec une règle commune : on ne boucle jamais sur les points, les tirages ou les pixels. La seule boucle Python autorisée est celle sur les itérations de la partie C (et, le cas échéant, une petite boucle sur quelques graines aléatoires en A.3).

Vous avez deux semaines devant vous ; durée indicative : 2 h 30.

{admonition}
:class: important

* Ce notebook reste dans le dossier `D2/` de votre dépôt `pe-homework` (celui que le zip du sujet a créé en se dézippant), avec ses figures à côté, puis il doit être **commité et poussé** (`git add`, `git commit`, `git push`) avant la deadline. Attention, c'est la **date du commit** qui fait foi, pas celle du push ; le nom du fichier, lui, n'a pas d'importance (`sujet.ipynb` convient très bien).
* Juste avant de rendre, **pensez à ré-exécuter toutes les cellules** via *Kernel → Restart Kernel and Run All Cells* : toutes doivent s'exécuter dans l'ordre et sans erreur. En effet, un notebook qui plante à mi-chemin sera corrigé jusqu'à l'erreur…
* Les trois figures demandées (`pi.png`, `marche.png` et `mandelbrot.png`) sont à enregistrer dans le dossier du notebook (`plt.savefig`), et doivent aussi apparaître dans le notebook. Pensez à les habiller : une figure sans titre, sans légende ou sans nom d'axes perdra des points.
* Les cellules `# votre code` sont à compléter ; vous pouvez ajouter autant de cellules que vous le souhaitez. Les cellules de test (`assert`) sont là pour vous aider : si elles passent sans erreur, c'est bon signe. Ne les modifiez pas !
* Répondez aux questions "en français" dans une cellule *Markdown*, en deux ou trois phrases.
{admonition}
:class: note

* La documentation de `numpy` / `matplotlib`, les notebooks du cours, vos notes.
* Les assistants IA sont tolérés, avec la même règle qu'au D1 : **vous devez être capable d'expliquer chaque ligne que vous rendez**, et je pourrai vous demander de le faire à l'oral en séance. Le devoir est individuel.
* En revanche, les boucles `for` / `while` sur les éléments d'un tableau (points, pas, pixels) restent interdites : une solution correcte mais qui boucle sur les éléments vaudra au plus la moitié des points de la question.

Barème¶

Partie Contenu Points
A Monte-Carlo : estimation de π, convergence, pi.png 6
B Marches aléatoires 2D vectorisées, marche.png 6
C Ensemble de Mandelbrot, mandelbrot.png 6
– Notebook propre : exécuté sans erreur, code vectorisé, figures légendées, réponses rédigées 2
Total 20
Bonus Zoom, palette, itération sans avertissement +2

Inspiré des exercices de https://numerique-exos.info-mines.paris.

In [1]:
import os
import numpy as np
import matplotlib.pyplot as plt

Partie A – Estimer π par la méthode de Monte-Carlo (6 pts)¶

Le principe est le suivant : on tire n points uniformément dans le carré $[-1, 1]^2$, et l'on compte ceux qui tombent dans le disque unité ($x^2 + y^2 \le 1$). Cette proportion approche en effet l'aire du disque divisée par celle du carré, c'est-à-dire $\pi / 4$ : il suffit donc de la multiplier par 4 pour estimer π.

{admonition}
:class: note

C'est la façon moderne de tirer des nombres au hasard avec `numpy` : `rng = np.random.default_rng(0)` fabrique un générateur, puis `rng.uniform(-1, 1, size=(5, 2))` tire un tableau `(5, 2)` de flottants et `rng.integers(0, 4, size=10)` dix entiers pris dans `{0, 1, 2, 3}`. La graine passée à `default_rng` rend la suite reproductible : deux exécutions donnent exactement les mêmes tirages, ce qui est bien pratique pour retrouver un bug… et pour vous corriger !

A.1 Écrivez estimer_pi(n, seed=0) qui :

  • crée un générateur rng = np.random.default_rng(seed) ;
  • tire un tableau points de forme (n, 2) de flottants uniformes dans $[-1, 1]$ (rng.uniform) ;
  • calcule le masque booléen dedans des points du disque (sans boucle) ;
  • renvoie 4 * (proportion de points dedans).

Grâce au seed, deux appels avec les mêmes arguments donneront exactement le même résultat.

In [2]:
# SOLUTION
def estimer_pi(n, seed=0):
    rng = np.random.default_rng(seed)
    points = rng.uniform(-1, 1, size=(n, 2))
    dedans = (points**2).sum(axis=1) <= 1
    return 4 * dedans.mean()

estimer_pi(1_000_000)
Out[2]:
np.float64(3.141576)
In [3]:
assert 3.0 < estimer_pi(1000) < 3.3
assert abs(estimer_pi(1_000_000, seed=0) - np.pi) < 0.01
assert estimer_pi(5000, seed=42) == estimer_pi(5000, seed=42)
assert estimer_pi(5000, seed=1) != estimer_pi(5000, seed=2)
print("ok")
ok
{admonition}
:class: note

Autour d'un `plt.plot(...)` ou d'un `plt.scatter(...)`, on ajoute `plt.title("…")`, `plt.xlabel("…")`, `plt.ylabel("…")` et `plt.legend()`, qui affiche les `label=` passés aux tracés. Enfin, `plt.savefig("fig.png", dpi=120)` enregistre la figure dans un fichier ; faites-le **avant** `plt.show()`, car celui-ci referme la figure et l'on obtiendrait sinon un fichier vide. Nous verrons `matplotlib` en détail en séance 5.

A.2 Passons à l'illustration : tirez 3000 points (avec un générateur de graine 0) et tracez-les avec plt.scatter, en colorant différemment les points du disque et les autres (argument c= de scatter). Utilisez plt.axis("equal") afin que le disque soit bien rond, et donnez à la figure un titre qui mentionne l'estimation de π obtenue avec ces 3000 points.

In [4]:
# SOLUTION
rng = np.random.default_rng(0)
pts = rng.uniform(-1, 1, size=(3000, 2))
masque = (pts**2).sum(axis=1) <= 1
plt.figure(figsize=(5, 5))
plt.scatter(pts[masque, 0], pts[masque, 1], s=4, label="dans le disque")
plt.scatter(pts[~masque, 0], pts[~masque, 1], s=4, label="dehors")
plt.axis("equal")
plt.legend(loc="upper right")
plt.title(f"3000 points : π ≈ {4 * masque.mean():.4f}")
plt.show()
No description has been provided for this image
{admonition}
:class: note

`np.logspace(2, 6, 9)` donne 9 valeurs régulièrement espacées *en échelle logarithmique* entre $10^2$ et $10^6$ : en clair, c'est `linspace` appliqué aux exposants. `plt.loglog(x, y)` trace comme `plt.plot` mais avec les deux axes en échelle logarithmique, où une loi de puissance $y = x^a$ apparaît comme une droite de pente $a$. On reverra les figures en séance 5.

A.3 Étudions maintenant la convergence. Pour chaque n de ns = np.logspace(2, 6, 9).astype(int) (de 100 à 1 000 000), calculez l'erreur absolue $|\hat\pi_n - \pi|$, moyennée sur 5 graines différentes (seed = 0, 1, 2, 3, 4) afin de lisser le hasard. Rangez les résultats dans un tableau erreurs de forme (9,).

Tracez ensuite erreurs en fonction de ns en échelle log-log (plt.loglog) avec des marqueurs, et superposez la droite de référence $1 / \sqrt{n}$ en pointillés. Nommez les axes, ajoutez une légende et un titre, puis enregistrez la figure dans pi.png (plt.savefig("pi.png"), avant plt.show()).

Enfin, dans une cellule Markdown : que constatez-vous sur la pente ? Et combien de points faut-il, en ordre de grandeur, pour gagner une décimale de précision ?

{admonition}
:class: tip

Ici, une boucle Python sur les 9 valeurs de `n` et sur les 5 graines est tout à fait acceptable (cela ne fait que 45 appels) : ce sont les *points* qu'on ne doit pas parcourir un à un.
In [5]:
# SOLUTION
ns = np.logspace(2, 6, 9).astype(int)
graines = range(5)
erreurs = np.array([[abs(estimer_pi(n, seed=s) - np.pi) for s in graines] for n in ns]).mean(axis=1)

plt.loglog(ns, erreurs, "o-", label="erreur moyenne sur 5 graines")
plt.loglog(ns, 1 / np.sqrt(ns), "k--", label=r"$1/\sqrt{n}$")
plt.xlabel("nombre de points n")
plt.ylabel(r"$|\hat\pi_n - \pi|$")
plt.title("Convergence de l'estimation Monte-Carlo de π")
plt.legend()
plt.grid(True, which="both", alpha=0.3)
plt.savefig("pi.png", dpi=120)
plt.show()
No description has been provided for this image
In [6]:
assert ns.shape == (9,) and ns[0] == 100 and ns[-1] == 1_000_000
assert erreurs.shape == (9,)
assert erreurs[-1] < erreurs[0]
assert erreurs[-1] < 0.01
assert os.path.exists("pi.png")
print("ok")
ok
{admonition}
La pente en log-log est proche de −1/2 : l'erreur décroît comme $1/\sqrt{n}$ (c'est le théorème central limite). Pour gagner une décimale, c'est-à-dire diviser l'erreur par 10, il faut donc 100 fois plus de points : la méthode est simple, mais lente.

Partie B – Marches aléatoires dans le plan (6 pts)¶

Un marcheur part de l'origine et, à chaque pas, se déplace d'une unité dans l'une des quatre directions (est, ouest, nord, sud), choisie au hasard. Nous allons simuler K = 500 marcheurs faisant chacun n = 1000 pas — et tout cela d'un coup, sans la moindre boucle !

{admonition}
:class: note

Au lieu d'un entier ou d'une slice, on peut indexer un tableau par un *tableau d'indices* : `np.array([10, 20, 30])[np.array([0, 2, 2, 1])]` renvoie `[10, 30, 30, 20]`. Le résultat a la forme du tableau d'indices, et si le tableau indexé a plusieurs dimensions, chaque indice est remplacé par la ligne correspondante. C'est une notion optionnelle du cours (notebook `1-14`).
{admonition}
:class: note

`tab.cumsum(axis=...)` ne renvoie pas un total, mais tous les totaux intermédiaires : `np.array([2, 3, 1]).cumsum()` vaut `[2, 5, 6]`. Le résultat a exactement la même forme que le tableau de départ, et `axis=` désigne la dimension le long de laquelle on accumule. C'est précisément ce qui transforme une suite de déplacements en une suite de positions.

B.1 Avec un générateur de graine 0 :

  • tirez tirages = rng.integers(0, 4, size=(K, n)) : le numéro de direction de chaque pas de chaque marcheur ;
  • définissez la table directions, un tableau (4, 2) contenant les quatre déplacements (1, 0), (-1, 0), (0, 1), (0, -1) ;
  • calculez pas = directions[tirages]. Quelle est la forme de pas ?
  • calculez positions, les positions successives de chaque marcheur, par somme cumulée le long de l'axe des pas.
In [7]:
# SOLUTION
K, n = 500, 1000
rng = np.random.default_rng(0)
tirages = rng.integers(0, 4, size=(K, n))
directions = np.array([[1, 0], [-1, 0], [0, 1], [0, -1]])
pas = directions[tirages]
positions = pas.cumsum(axis=1)
print(pas.shape, positions.shape)
(500, 1000, 2) (500, 1000, 2)
In [8]:
assert tirages.shape == (500, 1000)
assert directions.shape == (4, 2)
assert pas.shape == (500, 1000, 2)
assert positions.shape == (500, 1000, 2)
assert np.all(np.abs(pas).sum(axis=2) == 1)          # chaque pas a une longueur 1
assert np.array_equal(positions[:, 0, :], pas[:, 0, :])
assert np.array_equal(positions[:, -1, :], pas.sum(axis=1))
print("ok")
ok

B.2 Intéressons-nous maintenant aux distances. Calculez :

  • finales : les positions finales des K marcheurs, forme (K, 2) ;
  • distances : leur distance à l'origine, forme (K,) ;
  • comparez la racine de la moyenne des distances au carré à $\sqrt{n}$, et la distance moyenne à $\sqrt{\pi n} / 2 \approx 0{,}886\sqrt{n}$.
  • Quelle proportion des marcheurs termine à moins de 20 unités de l'origine ? Et quelle est la distance maximale atteinte par un marcheur au cours de sa marche, et pas seulement à la fin ? Pour cette dernière, calculez dist_t, tableau (K, n) des distances à l'origine à chaque instant.
In [9]:
# SOLUTION
finales = positions[:, -1, :]
distances = np.sqrt((finales**2).sum(axis=1))
print("RMS :", np.sqrt((distances**2).mean()), "  sqrt(n) =", np.sqrt(n))
print("moyenne :", distances.mean(), "  0.886 sqrt(n) =", np.sqrt(np.pi * n) / 2)
print("proportion à moins de 20 :", (distances < 20).mean())
dist_t = np.sqrt((positions**2).sum(axis=2))
print("distance max atteinte (tous marcheurs, tous instants) :", dist_t.max())
print("distance max atteinte, marcheur par marcheur (5 premiers) :", dist_t.max(axis=1)[:5])
RMS : 31.73824191728332   sqrt(n) = 31.622776601683793
moyenne : 28.11196907157812   0.886 sqrt(n) = 28.024956081989643
proportion à moins de 20 : 0.324
distance max atteinte (tous marcheurs, tous instants) : 86.03487664894976
distance max atteinte, marcheur par marcheur (5 premiers) : [21.21320344 45.79301257 44.68780594 28.0713377  19.84943324]
In [10]:
assert finales.shape == (500, 2) and distances.shape == (500,)
rms = np.sqrt((distances**2).mean())
assert 0.85 * np.sqrt(n) < rms < 1.15 * np.sqrt(n)
assert dist_t.shape == (500, 1000)
assert np.all(dist_t.max(axis=1) >= distances)
print("ok")
ok

B.3 Passons à la figure marche.png : tracez les trajectoires des 5 premiers marcheurs (plt.plot(x, y) avec une couleur par marcheur), marquez l'origine d'un point noir, et pensez à plt.axis("equal"), au titre et à la légende. Enregistrez le tout dans marche.png.

Puis, dans une seconde figure (pas besoin de l'enregistrer, celle-là), tracez la distance moyenne à l'origine en fonction du temps, dist_t.mean(axis=0), et superposez la courbe $0{,}886\sqrt{t}$ pour t = np.arange(1, n + 1). Commentez en une phrase ce que vous observez.

In [11]:
# SOLUTION
plt.figure(figsize=(6, 6))
for k in range(5):
    plt.plot(positions[k, :, 0], positions[k, :, 1], lw=0.8, label=f"marcheur {k}")
plt.plot(0, 0, "ko", ms=8, label="origine")
plt.axis("equal")
plt.legend()
plt.title(f"5 marches aléatoires de {n} pas")
plt.savefig("marche.png", dpi=120)
plt.show()

t = np.arange(1, n + 1)
plt.plot(t, dist_t.mean(axis=0), label="distance moyenne (500 marcheurs)")
plt.plot(t, 0.886 * np.sqrt(t), "k--", label=r"$0{,}886\sqrt{t}$")
plt.xlabel("pas t"); plt.ylabel("distance à l'origine")
plt.legend(); plt.title("Éloignement moyen au cours du temps")
plt.show()
No description has been provided for this image
No description has been provided for this image
In [12]:
assert os.path.exists("marche.png")
print("ok")
ok

B.4 Terminons par un peu de masques : sans boucle, calculez la proportion de marcheurs qui repassent par l'origine au moins une fois après le départ, c'est-à-dire dont la position vaut (0, 0) à un instant quelconque.

In [13]:
# SOLUTION
retour = (positions == 0).all(axis=2).any(axis=1)
print("proportion de retours à l'origine :", retour.mean())
proportion de retours à l'origine : 0.7
In [14]:
assert retour.shape == (500,) and retour.dtype == bool
assert 0.5 < retour.mean() < 0.95
print("ok")
ok
{admonition}
RMS ≈ 31,6 = √1000 (chaque pas a une norme 1 et les pas sont indépendants), distance moyenne ≈ 28. La courbe `dist_t.mean(axis=0)` colle à $0{,}886\sqrt{t}$. Environ 70 % des marcheurs repassent par l'origine en 1000 pas (la marche 2D est récurrente : avec une infinité de pas, ce serait 100 %).

Partie C – L'ensemble de Mandelbrot (6 pts)¶

Pour un nombre complexe $c$, on itère $z_0 = 0$, $z_{k+1} = z_k^2 + c$. Si la suite reste bornée, $c$ appartient à l'ensemble de Mandelbrot ; or, dès que $|z_k| > 2$, on est certain qu'elle diverge, ce qui nous donne un critère d'arrêt simple. On colorie alors chaque point $c$ du plan selon le nombre d'itérations effectuées avant que $|z_k|$ ne dépasse 2, avec un plafond max_iter pour les points qui ne divergent pas.

C.1 Écrivez grille(xmin, xmax, ymin, ymax, nx, ny) qui renvoie le tableau complexe c de forme (ny, nx) tel que c[i, j] = x[j] + 1j * y[i], avec x = np.linspace(xmin, xmax, nx) et y = np.linspace(ymin, ymax, ny). Utilisez le broadcasting vu en séance 2 (np.newaxis ou reshape).

In [15]:
# SOLUTION
def grille(xmin, xmax, ymin, ymax, nx, ny):
    x = np.linspace(xmin, xmax, nx)
    y = np.linspace(ymin, ymax, ny)
    return x[np.newaxis, :] + 1j * y[:, np.newaxis]

grille(-1, 1, -1, 1, 3, 2)
Out[15]:
array([[-1.-1.j,  0.-1.j,  1.-1.j],
       [-1.+1.j,  0.+1.j,  1.+1.j]])
In [16]:
g = grille(-2, 1, -1.5, 1.5, 4, 3)
assert g.shape == (3, 4)
assert g.dtype == complex
assert g[0, 0] == -2 - 1.5j and g[2, 3] == 1 + 1.5j and g[1, 0] == -2 + 0j
print("ok")
ok
{admonition}
:class: note

Certains calculs `numpy` (dépassement de capacité, `inf`, `nan`) produisent un `RuntimeWarning` sans interrompre le programme. `np.errstate` fait taire ces avertissements sur une portion de code :

```python
with np.errstate(over="ignore", invalid="ignore"):
    resultat = mon_calcul()
```

Ne l'utilisez que lorsque vous avez compris d'où viennent les avertissements et que vous les jugez inoffensifs !

C.2 Écrivez mandelbrot(c, max_iter=100) qui renvoie un tableau d'entiers n_iter, de même forme que c, contenant pour chaque point le nombre d'itérations pendant lesquelles $|z_k| \le 2$ (donc max_iter pour les points de l'ensemble).

La seule boucle Python autorisée est celle sur les itérations : à chaque tour, tous les points de c sont mis à jour d'un coup.

Un mot d'avertissement : les points qui divergent "explosent" rapidement vers inf ou nan, ce qui déclenche des avertissements (RuntimeWarning: overflow, invalid value). Ils sont sans gravité ici, et vous pourrez les faire taire en entourant l'appel de with np.errstate(over="ignore", invalid="ignore"): — ou vous en débarrasser pour de bon, cf. le bonus.

In [17]:
# SOLUTION
def mandelbrot(c, max_iter=100):
    z = np.zeros_like(c)
    n_iter = np.zeros(c.shape, dtype=int)
    for k in range(max_iter):
        z = z**2 + c
        encore = np.abs(z) <= 2
        n_iter += encore
    return n_iter

with np.errstate(over="ignore", invalid="ignore"):
    m = mandelbrot(grille(-2, 1, -1.5, 1.5, 300, 300), max_iter=50)
m.shape, m.min(), m.max()
Out[17]:
((300, 300), np.int64(0), np.int64(50))
In [18]:
with np.errstate(over="ignore", invalid="ignore"):
    m = mandelbrot(grille(-2, 1, -1.5, 1.5, 300, 300), max_iter=50)
assert m.shape == (300, 300)
assert m.dtype.kind in "iu"
assert m.max() == 50 and m.min() >= 0
assert m[150, 100] == 50          # c ≈ -1 : dans l'ensemble
assert m[0, 0] <= 2               # c = -2 - 1.5i : diverge tout de suite
assert m[150, 250] <= 5           # c ≈ 0.5 : diverge vite
assert 0.14 < (m == 50).mean() < 0.21   # aire de l'ensemble ≈ 1.5 sur un cadre de 9
print("ok")
ok

C.3 Passons à la figure mandelbrot.png : calculez mandelbrot sur la grille [-2, 1] × [-1.5, 1.5] avec nx = 900, ny = 900 et max_iter = 100, puis affichez le résultat avec plt.imshow(..., extent=[xmin, xmax, ymin, ymax], origin="lower") afin que les axes soient gradués en coordonnées complexes, et non en numéros de pixel. Ajoutez plt.colorbar(label="itérations"), les noms d'axes (partie réelle / partie imaginaire), un titre, et enregistrez dans mandelbrot.png.

Enfin, dans une cellule Markdown : que vaut n_iter pour les points de l'ensemble, et comment les reconnaît-on sur l'image ?

In [19]:
# SOLUTION
xmin, xmax, ymin, ymax = -2, 1, -1.5, 1.5
with np.errstate(over="ignore", invalid="ignore"):
    M = mandelbrot(grille(xmin, xmax, ymin, ymax, 900, 900), max_iter=100)

plt.figure(figsize=(7, 7))
plt.imshow(M, extent=[xmin, xmax, ymin, ymax], origin="lower", cmap="magma")
plt.colorbar(label="itérations", shrink=0.8)
plt.xlabel("Re(c)"); plt.ylabel("Im(c)")
plt.title("Ensemble de Mandelbrot (max_iter = 100)")
plt.savefig("mandelbrot.png", dpi=120)
plt.show()
No description has been provided for this image
In [20]:
assert M.shape == (900, 900)
assert os.path.exists("mandelbrot.png")
print("ok")
ok
{admonition}
Les points de l'ensemble ont `n_iter == max_iter` (100) : ce sont les points de la couleur « maximale » de la barre (la grande zone intérieure, cardioïde + bulbes). Le dégradé à l'extérieur indique la vitesse de divergence.

Bonus (+2)¶

Bo.1 Un zoom, pour commencer : calculez et affichez la zone [-0.8, -0.7] × [0.05, 0.15] — la « vallée des hippocampes » — avec max_iter = 300, toujours avec extent. Essayez au passage une autre palette (cmap="twilight", "inferno", "viridis", …).

Bo.2 Puis une version sans aucun avertissement, et sans np.errstate : au lieu de mettre à jour z partout, ne mettez à jour que les points encore actifs, à l'aide d'un masque booléen et de l'indexation par masque (attention à compter exactement comme mandelbrot !). Vérifiez que le résultat est identique (np.array_equal) à celui de mandelbrot sur la grille de C.2, puis comparez les temps d'exécution avec %timeit.

In [21]:
# SOLUTION
def mandelbrot_masque(c, max_iter=100):
    z = np.zeros_like(c)
    n_iter = np.zeros(c.shape, dtype=int)
    actif = np.ones(c.shape, dtype=bool)
    for k in range(max_iter):
        z[actif] = z[actif]**2 + c[actif]
        encore = np.abs(z[actif]) <= 2   # parmi les points actifs, ceux qui restent bornés
        n_iter[actif] += encore
        actif[actif] = encore            # on désactive les autres
    return n_iter

c2 = grille(-2, 1, -1.5, 1.5, 300, 300)
with np.errstate(over="ignore", invalid="ignore"):
    ref = mandelbrot(c2, 50)
print(np.array_equal(mandelbrot_masque(c2, 50), ref))

zx = (-0.8, -0.7, 0.05, 0.15)
Z = mandelbrot_masque(grille(*zx, 600, 600), max_iter=300)
plt.figure(figsize=(7, 7))
plt.imshow(Z, extent=zx, origin="lower", cmap="twilight")
plt.colorbar(label="itérations", shrink=0.8)
plt.xlabel("Re(c)"); plt.ylabel("Im(c)")
plt.title("Zoom : vallée des hippocampes (max_iter = 300)")
plt.show()
True
No description has been provided for this image
In [22]:
# CORRIGE-ONLY
with np.errstate(over="ignore", invalid="ignore"):
    %timeit mandelbrot(c2, 50)
%timeit mandelbrot_masque(c2, 50)
8.65 ms ± 163 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
10.5 ms ± 74.8 μs per loop (mean ± std. dev. of 7 runs, 100 loops each)
{admonition}
`encore` est calculé sur les seuls points actifs ; `n_iter[actif] += encore` compte l'itération pour ceux qui restent bornés, et `actif[actif] = encore` désactive les autres : on obtient exactement le même comptage que la version simple (piège classique : compter `+= 1` avant de tester donne une itération de trop). La version masquée évite de calculer sur les points divergés (et donc les `inf`/`nan`) ; elle est souvent plus rapide quand beaucoup de points divergent tôt, mais l'indexation par masque a un coût, donc les deux versions sont proches sur cette grille.

Bon courage à tous ! Pour toute question sur le sujet, vous pouvez me contacter par email : raphael@imagine-app.fr.

Au plaisir de vous lire,

votre responsable de cours, Raphael Prasquier (sujet conçu par Aurélien Noce)