← Maman, les p'tits Lévy ont-ils des jambes ? Télécharger le notebook · exécuté le 2026-11-03
Simuler de vrais processus de Lévy¶
Ce notebook accompagne la page « Maman, les p'tits Lévy ont-ils des jambes ? » : il en fabrique toutes les figures. Les trajectoires y sont de vrais processus de Lévy, simulés ; le dé, les sphères et les deux sommets de la section « Le relief » sont des dessins.
Seul numpy est nécessaire (plus IPython, pour afficher les figures dans le notebook). Exécuté, le notebook écrit les fichiers SVG de la page à côté de lui et les affiche au passage.
- Trois façons de simuler un processus de Lévy.
- La boîte à outils du dessin.
- Les figures de la page, une par une.
import base64
import math
import os
import numpy as np
from IPython.display import HTML
DOSSIER = os.getcwd() # le dossier du notebook : les figures s'écrivent à côté de lui
BLEU, GRIS, ROUGE, VERT = "#3b82f6", "#94a3b8", "#ef4444", "#10b981"
1. Trois façons de simuler un processus de Lévy¶
Un processus de Lévy, c'est une trajectoire fabriquée en relançant sans cesse le même dé-boule : ses accroissements sur des intervalles de temps disjoints sont indépendants, et ont la même loi sur des intervalles de même longueur. Selon la façon dont il saute, on ne le simule pas de la même manière.
1.1 Un nombre fini de sauts : le Poisson composé¶
C'est le cas le plus simple : une dérive $d$, plus des sauts qui arrivent aux instants d'un processus de Poisson $N$, $$X_t = d\,t + J_1 + \dots + J_{N_t},$$ les tailles $J_k$ étant indépendantes et de même loi. Entre deux sauts, la trajectoire est une droite de pente $d$ : c'est la « trajectoire simple » de la page.
def poisson_compose(rng, T, d, intensite, taille_min, taille_max):
"""Dérive d, plus les sauts d'un processus de Poisson ; tailles uniformes entre
taille_min et taille_max, de signe aléatoire."""
n = rng.poisson(intensite * T)
t = np.sort(rng.random(n) * T)
s = rng.uniform(taille_min, taille_max, n) * rng.choice([-1.0, 1.0], n)
apres = d * t + np.cumsum(s)
return t, s, apres - s, apres
1.2 Une infinité de petits sauts, mais une variation finie¶
Un Lévy à variation finie s'écrit encore $$X_t = d\,t + \sum_{s \le t} \Delta X_s ,$$ mais il saute une infinité de fois dans tout intervalle de temps. Ses sauts forment un processus de Poisson ponctuel sur $[0, T] \times \mathbb{R}^*$, d'intensité $dt \otimes \Pi(dx)$, où $\Pi$ est la mesure de Lévy. On en prend une de type stable, tronquée : $$\Pi(dx) = c_+\, x^{-1-\alpha}\, dx \ \text{ sur } ]0, M_+], \qquad c_-\, |x|^{-1-\alpha}\, dx \ \text{ sur } [-M_-, 0[,$$ avec $0 < \alpha < 1$ : $\Pi(\mathbb{R}^*) = +\infty$ (une infinité de petits sauts), mais $\int |x|\, \Pi(dx) < +\infty$ (la somme des sauts converge).
On ne tire que les sauts de taille supérieure à $\varepsilon$. Leur nombre suit une loi de Poisson de paramètre $T\,\Pi(]\varepsilon, M])$, leurs instants sont uniformes sur $[0, T]$, et leurs tailles s'obtiennent en inversant la fonction de répartition de $\Pi$ restreinte à $]\varepsilon, M]$. L'erreur commise est uniforme en temps, majorée par la somme des valeurs absolues des sauts omis, dont l'espérance vaut $T (c_+ + c_-)\, \varepsilon^{1-\alpha} / (1-\alpha)$ : avec $\varepsilon = 10^{-10}$, c'est bien moins qu'un pixel, pour plusieurs centaines de milliers de sauts tirés.
def simule_levy_vf(rng, T, d, alpha, c_plus, M_plus, c_moins, M_moins, eps):
"""Lévy à variation finie sur [0, T]. Renvoie les instants des sauts (triés), leurs
tailles, et les valeurs de X juste avant et juste après chaque saut."""
def un_cote(c, M):
masse = c * (eps ** -alpha - M ** -alpha) / alpha # Π(]ε, M])
n = rng.poisson(T * masse)
u = rng.random(n)
# inverse de la fonction de répartition de Π restreinte à ]ε, M], normalisée
tailles = (eps ** -alpha - u * (eps ** -alpha - M ** -alpha)) ** (-1.0 / alpha)
return rng.random(n) * T, tailles
t1, s1 = un_cote(c_plus, M_plus)
t2, s2 = un_cote(c_moins, M_moins)
t = np.concatenate([t1, t2])
s = np.concatenate([s1, -s2])
ordre = np.argsort(t)
t, s = t[ordre], s[ordre]
apres = d * t + np.cumsum(s) # X(t_i)
avant = apres - s # X(t_i-)
return t, s, avant, apres
Franchir l'altitude $a$. Entre deux sauts tirés, la trajectoire suit une droite de pente $d$. Elle dépasse donc $a$ pour la première fois, soit d'un saut ($X_{t_i-} \le a < X_{t_i}$), soit en rampant : la dérive la fait passer par $a$, ce qui n'est possible que si $d > 0$. C'est le théorème qu'illustre la section « La reptation » : un Lévy à variation finie rampe vers le haut si et seulement si sa dérive est strictement positive.
def premier_passage(t, avant, apres, d, a, T):
"""Premier instant où X dépasse a (X part de 0 < a), et la manière : 'saut'
(X(t_i-) <= a < X(t_i)) ou 'rampe' (la dérive fait passer X par a).
Renvoie (instant, manière, indice du saut concerné) ou (None, None, None)."""
n = len(t)
haut_avant, haut_apres = avant > a, apres > a
i_avant = int(np.argmax(haut_avant)) if haut_avant.any() else n
i_apres = int(np.argmax(haut_apres)) if haut_apres.any() else n
if i_avant < n and i_avant <= i_apres:
# franchi pendant la dérive qui précède le saut i_avant
t0 = t[i_avant - 1] if i_avant > 0 else 0.0
x0 = apres[i_avant - 1] if i_avant > 0 else 0.0
return t0 + (a - x0) / d, "rampe", i_avant
if i_apres < n:
return t[i_apres], "saut", i_apres
if d > 0 and n and apres[-1] + d * (T - t[-1]) > a:
return t[-1] + (a - apres[-1]) / d, "rampe", n
return None, None, None
1.3 Les processus stables¶
Pour $1 \le \alpha < 2$, le processus $\alpha$-stable symétrique est à variation infinie : la somme de ses sauts ne converge qu'une fois compensée, et la troncature précédente demanderait des précautions. On procède autrement, en calculant ses valeurs exactes sur une grille fine. Par autosimilarité, ses accroissements sur les intervalles $[kT/n, (k+1)T/n]$ sont indépendants et de loi $(T/n)^{1/\alpha} S_\alpha$, où $S_\alpha$ suit la loi stable symétrique standard, de fonction caractéristique $e^{-|u|^\alpha}$. La méthode de Chambers–Mallows–Stuck tire $S_\alpha$ à partir d'une variable $V$ uniforme sur $]-\pi/2, \pi/2[$ et d'une variable $W$ exponentielle de paramètre 1 : $$S_\alpha = \frac{\sin(\alpha V)}{(\cos V)^{1/\alpha}} \left( \frac{\cos\big((1-\alpha) V\big)}{W} \right)^{(1-\alpha)/\alpha}, \qquad S_1 = \tan V .$$ $\alpha = 1$ donne le processus de Cauchy, $\alpha = 1.5$ le stable qui sert d'Abrupt dans la page. Le mouvement brownien, lui, s'obtient avec des accroissements gaussiens.
def stable_symetrique(rng, alpha, n):
"""n tirages de la loi α-stable symétrique standard (fonction caractéristique
exp(-|u|^α)), par la méthode de Chambers–Mallows–Stuck."""
V = rng.uniform(-np.pi / 2, np.pi / 2, n)
if alpha == 1:
return np.tan(V)
W = rng.exponential(1.0, n)
return (np.sin(alpha * V) / np.cos(V) ** (1 / alpha)
* (np.cos((1 - alpha) * V) / W) ** ((1 - alpha) / alpha))
def levy_stable(rng, alpha, n, T=1.0):
"""Valeurs exactes du processus α-stable symétrique aux instants kT/n, k = 0..n."""
return np.concatenate([[0.0], np.cumsum((T / n) ** (1 / alpha) * stable_symetrique(rng, alpha, n))])
def brownien(rng, n, T=1.0):
"""Valeurs exactes du mouvement brownien aux instants kT/n, k = 0..n."""
return np.concatenate([[0.0], np.cumsum(np.sqrt(T / n) * rng.standard_normal(n))])
1.4 Choisir une trajectoire lisible¶
Chaque figure demande une trajectoire qui montre bien ce qu'elle illustre : un saut franc, un sommet loin des bords… Plutôt que de choisir à la main, on prend la première graine du générateur aléatoire, dans l'ordre, dont la trajectoire vérifie des critères écrits en toutes lettres (les fonctions lisible de chaque figure) : le résultat est reproductible.
Pour les processus stables, ces critères sont calés sur les valeurs typiques, mesurées sur des dizaines de tirages : dessiné sur 200 pixels, un stable d'indice 1.5 montre une vingtaine de sauts visibles, et son plus gros saut fait environ 40 % de l'étendue de la trajectoire (63 % pour le processus de Cauchy). Environ un tirage sur deux vérifie les critères : on montre des trajectoires typiques, pas des raretés.
def premiere_graine(simule, lisible, graine, essais=3000):
for g in range(graine, graine + essais):
r = simule(np.random.default_rng(g))
if lisible(r):
print(f" graine retenue : {g}")
return r
raise RuntimeError("aucune trajectoire lisible")
def grille(n, T=1.0):
return np.linspace(0.0, T, n + 1)
def nb_sauts_visibles(X, hauteur_px):
lo, hi = etendue([X])
return int(np.sum(np.abs(np.diff(X)) > 6.0 / hauteur_px * (hi - lo)))
def plus_gros_saut(X):
lo, hi = etendue([X])
return float(np.abs(np.diff(X)).max()) / (hi - lo)
def debut_net(X, hauteur_px, fraction=0.02):
"""Pas de saut visible collé à l'axe vertical (dans les premiers 2 % du temps)."""
lo, hi = etendue([X])
debut = X[:int(fraction * (len(X) - 1)) + 1]
return not np.any(np.abs(np.diff(debut)) > 6.0 / hauteur_px * (hi - lo))
def sommet_net(X, n, rayon, seuil):
"""Le maximum est atteint loin des bords, et c'est un point de la trajectoire,
pas le haut d'un saut isolé : aucun saut de plus de `seuil` à moins de `rayon`
(en temps) du maximum."""
k = int(np.argmax(X))
if not 0.25 * n <= k <= 0.75 * n:
return False
r = int(rayon * n)
return float(np.abs(np.diff(X[k - r:k + r + 1])).max()) <= seuil
2. La boîte à outils du dessin¶
Les figures sont écrites directement en SVG, sans matplotlib, pour régler chaque trait et reprendre les couleurs du site, qui doivent rester lisibles en thème clair comme en thème sombre. Un Cadre fait correspondre les instants et les valeurs aux pixels.
class Cadre:
"""Zone de tracé [x0, x1] x [yh, yb] (pixels) pour les instants [t0, t1] et les
valeurs [lo, hi]."""
def __init__(self, x0, x1, yb, yh, t0, t1, lo, hi):
self.x0, self.x1, self.yb, self.yh = x0, x1, yb, yh
self.t0, self.t1, self.lo, self.hi = t0, t1, lo, hi
def px(self, u):
return self.x0 + (u - self.t0) / (self.t1 - self.t0) * (self.x1 - self.x0)
def py(self, x):
return self.yb - (x - self.lo) / (self.hi - self.lo) * (self.yb - self.yh)
def valeur_par_pixel(self):
return (self.hi - self.lo) / (self.yb - self.yh)
def etendue(valeurs, marge=0.04):
lo = min(float(np.min(v)) for v in valeurs)
hi = max(float(np.max(v)) for v in valeurs)
return lo - marge * (hi - lo), hi + marge * (hi - lo)
Trois choix comptent pour dessiner une trajectoire :
- un saut de plus de 6 pixels est dessiné en pointillés verticaux, comme dans le document d'origine ;
- entre deux sauts, une trajectoire très oscillante (stable, brownien) est dessinée observée à environ deux instants par pixel (
echantillonne) : tracer chacune de ses oscillations remplirait des colonnes entières de pixels et ferait des pâtés ; - une trajectoire peu oscillante (variation finie) est réduite, colonne par colonne, à son minimum et son maximum dans l'ordre où ils sont atteints (
decime), ce qui est fidèle à la demi-colonne près.
def decime(tt, xx, nb):
"""Polyligne exacte (tt croissant au sens large, les sauts y sont des segments
verticaux) réduite à nb colonnes : dans chacune, le min et le max, dans l'ordre
où ils sont atteints."""
nb = max(1, nb)
if len(tt) <= 2 * nb + 2:
return list(zip(tt, xx))
bords = np.linspace(tt[0], tt[-1], nb + 1)
j = np.clip(np.searchsorted(tt, bords, side="right") - 1, 0, len(tt) - 2)
xb = xx[j] + (xx[j + 1] - xx[j]) * (bords - tt[j]) / np.maximum(tt[j + 1] - tt[j], 1e-300)
xb[0], xb[-1] = xx[0], xx[-1]
deb = np.searchsorted(tt, bords[:-1], side="left")
fin = np.searchsorted(tt, bords[1:], side="left")
pts = [(tt[0], xx[0])]
for k in range(nb):
cand = np.concatenate([[xb[k]], xx[deb[k]:fin[k]], [xb[k + 1]]])
imin, imax = int(np.argmin(cand)), int(np.argmax(cand))
tc = 0.5 * (bords[k] + bords[k + 1])
paire = (imin, imax) if imin <= imax else (imax, imin)
pts += [(tc, cand[paire[0]]), (tc, cand[paire[1]])]
pts.append((tt[-1], xx[-1]))
return pts
def echantillonne(tt, xx, nb):
"""La même polyligne, observée à nb + 1 instants régulièrement espacés."""
nb = max(1, nb)
if len(tt) <= nb + 1:
return list(zip(tt, xx))
ts = np.linspace(tt[0], tt[-1], nb + 1)
j = np.clip(np.searchsorted(tt, ts, side="right") - 1, 0, len(tt) - 2)
xs = xx[j] + (xx[j + 1] - xx[j]) * (ts - tt[j]) / np.maximum(tt[j + 1] - tt[j], 1e-300)
xs[0], xs[-1] = xx[0], xx[-1]
return list(zip(ts, xs))
def trace(cadre, t, avant, apres, debut, fin, style, coupes=(), saut_exclu=None, seuil_px=6.0,
densite=None):
"""Trajectoire allant de debut = (t0, x0) à fin = (t1, x1) ; aux instants t_i elle
passe de avant_i à apres_i, et elle est affine entre deux instants. Les sauts de plus
de seuil_px pixels sont dessinés en pointillés. style(u) -> (couleur, opacité,
épaisseur) du morceau commençant en u ; `coupes` : instants où changer de style.
densite = None : min et max par demi-colonne ; sinon, la trajectoire est observée
à `densite` instants par pixel (pour les trajectoires très oscillantes)."""
t0, x0 = debut
t1, x1 = fin
dedans = (t > t0) & (t < t1)
gros = np.flatnonzero(dedans & (np.abs(apres - avant) > seuil_px * cadre.valeur_par_pixel()))
if saut_exclu is not None:
gros = np.union1d(gros, [saut_exclu]).astype(int)
def valeur_en(c):
i = int(np.searchsorted(t, c, side="right")) - 1
ta, xa = (t[i], apres[i]) if i >= 0 and t[i] > t0 else (t0, x0)
tb, xb = (t[i + 1], avant[i + 1]) if i + 1 < len(t) and t[i + 1] < t1 else (t1, x1)
return xa + (xb - xa) * (c - ta) / (tb - ta) if tb > ta else xa
bornes = sorted([(t[i], apres[i], avant[i]) for i in gros]
+ [(c, valeur_en(c), valeur_en(c)) for c in coupes])
bornes = [(t0, x0, x0)] + bornes + [(t1, x1, x1)]
colonnes = 2 * (cadre.x1 - cadre.x0)
svg = []
for i in gros:
if i == saut_exclu:
continue
couleur, opacite, _ = style(t[i])
x = cadre.px(t[i])
svg.append(f'<path d="M{x:.1f},{cadre.py(avant[i]):.1f} L{x:.1f},{cadre.py(apres[i]):.1f}" '
f'stroke="{couleur}" stroke-opacity="{opacite}" stroke-width="0.9" '
f'stroke-dasharray="2 3" fill="none"/>')
for (u, xu, _), (v, _, xv) in zip(bornes[:-1], bornes[1:]):
j0, j1 = np.searchsorted(t, u, side="right"), np.searchsorted(t, v, side="left")
tt = np.concatenate([[u], np.repeat(t[j0:j1], 2), [v]])
xx = np.concatenate([[xu], np.column_stack([avant[j0:j1], apres[j0:j1]]).ravel(), [xv]])
part = (v - u) / (t1 - t0)
if densite is None:
pts = decime(tt, xx, max(1, round(part * colonnes)))
else:
pts = echantillonne(tt, xx, round(part * (cadre.x1 - cadre.x0) * densite))
couleur, opacite, epaisseur = style(u)
d = "M" + " L".join(f"{cadre.px(a):.1f},{cadre.py(b):.1f}" for a, b in pts)
svg.append(f'<path d="{d}" stroke="{couleur}" stroke-opacity="{opacite}" '
f'stroke-width="{epaisseur}" fill="none" stroke-linejoin="round"/>')
return svg
DENSITE = 2.0 # instants d'observation par pixel, pour les trajectoires oscillantes
def trace_grille(cadre, tg, X, style, coupes=(), densite=DENSITE):
"""Trajectoire connue aux instants tg : un accroissement de plus de 6 pixels est
dessiné comme un saut, les autres comme des segments."""
gros = np.abs(np.diff(X)) > 6.0 * cadre.valeur_par_pixel()
avant = np.where(gros, X[:-1], X[1:])
return trace(cadre, tg[1:-1], avant[:-1], X[1:-1], (tg[0], X[0]), (tg[-1], X[-1]), style, coupes,
densite=densite)
Le reste : flèches, axes, textes, cadres, l'écriture des fichiers et leur affichage dans le notebook. Et la sphère du dé-boule, vue d'un peu au-dessus en projection orthogonale : de son équateur et d'un méridien, on ne dessine que les moitiés tournées vers nous, si bien que les arcs cachés derrière la sphère n'apparaissent pas.
def fleche(x0, y0, x1, y1, couleur=GRIS, ep=1.5):
ang = math.atan2(y1 - y0, x1 - x0)
pointe = " ".join(f"M{x1:.1f},{y1:.1f} l{-8 * math.cos(ang + s):.1f},{-8 * math.sin(ang + s):.1f}"
for s in (0.45, -0.45))
return (f'<path d="M{x0:.1f},{y0:.1f} L{x1:.1f},{y1:.1f} {pointe}" stroke="{couleur}" '
f'stroke-width="{ep}" fill="none" stroke-linecap="round"/>')
def axes(cadre, dy=10):
return [fleche(cadre.x0, cadre.yb + dy, cadre.x1 + 22, cadre.yb + dy),
fleche(cadre.x0, cadre.yb + dy, cadre.x0, cadre.yh - 18)]
def texte(x, y, s, couleur=GRIS, taille=13, ancre="start"):
return (f'<text x="{x:.1f}" y="{y:.1f}" font-family="sans-serif" font-size="{taille}" '
f'fill="{couleur}" text-anchor="{ancre}">{s}</text>')
def boule(cx, cy, r, couleur=GRIS, elevation=0.36, meridien=0.36):
"""Le dé-boule : une sphère vue d'un peu au-dessus (angle `elevation`), en
projection orthogonale. On dessine la silhouette, puis l'équateur et un méridien
(incliné de l'angle `meridien` sur le plan de vue), dont seules les moitiés tournées
vers nous sont visibles : les arcs cachés par la sphère ne sont pas dessinés."""
e, b = elevation, meridien
t = np.linspace(0.0, 2 * np.pi, 721)
cercles = [ # (abscisse, ordonnée vers le haut, profondeur vers nous) sur la sphère unité
(np.cos(t), -np.sin(t) * np.sin(e), np.sin(t) * np.cos(e)), # équateur
(np.sin(b) * np.cos(t), np.sin(t) * np.cos(e) - np.cos(b) * np.cos(t) * np.sin(e),
np.sin(t) * np.sin(e) + np.cos(b) * np.cos(t) * np.cos(e)), # méridien
]
svg = [f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{couleur}" fill-opacity="0.18" '
f'stroke="{couleur}" stroke-width="2"/>']
for X, Y, prof in cercles:
vu = prof > 0
k = int(np.argmin(vu)) # on part d'un point caché : l'arc visible
X, Y, vu = np.roll(X, -k), np.roll(Y, -k), np.roll(vu, -k) # est d'un seul tenant
idx = np.flatnonzero(vu)
d = "M" + " L".join(f"{cx + r * X[i]:.1f},{cy - r * Y[i]:.1f}" for i in idx)
svg.append(f'<path d="{d}" fill="none" stroke="{couleur}" stroke-width="1.2" stroke-opacity="0.7"/>')
return "".join(svg)
def cadre_arrondi(largeur, hauteur):
return (f'<rect x="4" y="4" width="{largeur - 8}" height="{hauteur - 8}" rx="10" fill="none" '
f'stroke="{GRIS}" stroke-opacity="0.5" stroke-width="1.5"/>')
def ecrit(nom, largeur, hauteur, morceaux):
svg = (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {largeur} {hauteur}" role="img">'
+ "".join(morceaux) + "</svg>")
with open(os.path.join(DOSSIER, nom), "w") as f:
f.write(svg)
print(f" {nom} : {len(svg) // 1024} Ko")
def montre(*noms):
"""Affiche dans le notebook les figures qui viennent d'être écrites, côte à côte."""
images = []
for nom in noms:
with open(os.path.join(DOSSIER, nom), encoding="utf-8") as f:
svg = f.read()
largeur = svg.split('viewBox="0 0 ')[1].split()[0]
donnees = base64.b64encode(svg.encode()).decode()
images.append(f'<img src="data:image/svg+xml;base64,{donnees}" alt="{nom}" '
f'style="width:{96 // len(noms)}%; max-width:{largeur}px">')
return HTML('<div style="display:flex; gap:2%; align-items:flex-start">' + "".join(images) + "</div>")
3. Les figures de la page¶
3.1 Le dé et la boule de pétanque¶
Deux dessins. Le dé est vu de trois quarts, en projection orthogonale : chaque face est l'image d'un carré par une transformation affine, et ses points deviennent des ellipses. C'est un dé « occidental » : 1, 2 et 3 tournent dans le sens direct autour de leur sommet commun.
def fig_de():
"""Un dé à jouer en perspective (projection orthogonale, vu de trois quarts et d'un
peu au-dessus) : 1 dessus, 5 devant, 4 à droite. C'est un dé occidental : 1, 2 et 3
tournent dans le sens direct autour de leur sommet commun."""
phi, e, k, ox, oy = math.radians(35), math.radians(25), 62.0, 60.0, 62.0
def ecran(x, y, z):
X = x * math.cos(phi) - z * math.sin(phi)
Yh = -x * math.sin(phi) * math.sin(e) + y * math.cos(e) - z * math.cos(phi) * math.sin(e)
return ox + k * X, oy - k * Yh
quatre = [(0.27, 0.27), (0.73, 0.27), (0.27, 0.73), (0.73, 0.73)]
faces = [ # coordonnées (s, t) de la face, s vers la droite et t vers le bas, vue de dehors
(lambda s, t: (s - 0.5, 0.5, t - 0.5), 0.06, [(0.5, 0.5)]), # dessus : 1
(lambda s, t: (s - 0.5, 0.5 - t, 0.5), 0.14, quatre + [(0.5, 0.5)]), # devant : 5
(lambda s, t: (0.5, 0.5 - t, 0.5 - s), 0.26, quatre), # droite : 4
]
m = []
for face, opacite, points in faces:
(x0, y0), (xa, ya), (xb, yb) = ecran(*face(0, 0)), ecran(*face(1, 0)), ecran(*face(0, 1))
m.append(f'<g transform="matrix({xa - x0:.3f} {ya - y0:.3f} {xb - x0:.3f} {yb - y0:.3f} '
f'{x0:.2f} {y0:.2f})">')
m.append(f'<rect width="1" height="1" fill="{BLEU}" fill-opacity="{opacite}" stroke="{BLEU}" '
f'stroke-width="2.2" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>')
m += [f'<circle cx="{s}" cy="{t}" r="0.09" fill="{BLEU}"/>' for s, t in points]
m.append("</g>")
ecrit("de.svg", 120, 124, m)
def fig_petanque():
"""La boule de pétanque, un dé à une infinité de faces : la face tirée est le point
de contact avec le sol."""
m = [boule(200, 135, 95), f'<path d="M60,232 L340,232" stroke="{GRIS}" stroke-width="2"/>',
f'<circle cx="200" cy="230" r="6" fill="{ROUGE}"/>', fleche(285, 262, 212, 236, ROUGE, 1.8),
texte(292, 268, "la face tirée", ROUGE, 14)]
ecrit("petanque.svg", 420, 290, m)
fig_de()
fig_petanque()
montre("de.svg", "petanque.svg")
3.2 Trajectoires simples et compliquées¶
« Il existe des trajectoires simples » : un Poisson composé avec dérive, retenu avec deux ou trois sauts bien séparés, dont au moins un vers le haut et un vers le bas. « Et des trajectoires compliquées » : un processus stable d'indice 1.5, qui saute sans cesse.
def fig_traj_simple():
"""« Il existe des trajectoires simples » : un Poisson composé avec dérive."""
T, d = 1.0, 1.0
def lisible(r):
t, s, _, _ = r
ecarts = np.diff(np.concatenate([[0.0], t, [T]]))
return 2 <= len(t) <= 3 and (s > 0).any() and (s < 0).any() and ecarts.min() > 0.12
t, s, avant, apres = premiere_graine(lambda rng: poisson_compose(rng, T, d, 2.5, 0.25, 0.8), lisible, 1000)
xT = apres[-1] + d * (T - t[-1])
cadre = Cadre(40, 370, 230, 40, 0.0, T, *etendue([[0.0, xT], avant, apres], 0.06))
m = axes(cadre) + trace(cadre, t, avant, apres, (0.0, 0.0), (T, xT), lambda u: (BLEU, 1.0, 1.5))
m.append(texte(cadre.x1 - 30, cadre.yb + 34, "temps"))
ecrit("traj_simple.svg", 420, 280, m)
def fig_traj_compliquee():
"""« Et des trajectoires compliquées » : un processus stable d'indice 1.5."""
n = 10 ** 6
X = premiere_graine(lambda rng: levy_stable(rng, 1.5, n),
lambda X: plus_gros_saut(X) <= 0.45 and 8 <= nb_sauts_visibles(X, 200) <= 40
and debut_net(X, 200), 2000)
cadre = Cadre(40, 380, 240, 40, 0.0, 1.0, *etendue([X]))
m = axes(cadre) + trace_grille(cadre, grille(n), X, lambda u: (BLEU, 1.0, 0.9))
m.append(texte(cadre.x1 - 30, cadre.yb + 34, "temps"))
ecrit("traj_compliquee.svg", 420, 280, m)
fig_traj_simple()
fig_traj_compliquee()
montre("traj_simple.svg", "traj_compliquee.svg")
3.3 Le dé-boule et ses trois lancers¶
Un processus, c'est un dé-boule dont chaque face porte une trajectoire. Pour la divisibilité infinie, on lance trois fois le dé-boule $Y$ — trois trajectoires indépendantes d'un stable d'indice 1.5, chacune sur une durée $1/3$ — et on les recolle bout à bout : c'est, en loi, une trajectoire de $X$ sur $[0, 1]$.
def fig_processus_de():
"""Le dé-boule : sur chacune de ses faces est dessinée une trajectoire."""
n = 2 * 10 ** 5
X = premiere_graine(lambda rng: levy_stable(rng, 1.5, n),
lambda X: plus_gros_saut(X) <= 0.45 and 3 <= nb_sauts_visibles(X, 80) <= 14
and debut_net(X, 80), 3000)
m = [boule(110, 160, 82), f'<circle cx="152" cy="118" r="5" fill="{ROUGE}"/>',
f'<path d="M160,110 C210,55 280,55 318,88" fill="none" stroke="{GRIS}" stroke-width="1.6"/>']
ang = math.atan2(88 - 55, 318 - 280)
m.append(f'<path d="' + " ".join(f"M318,88 l{-8 * math.cos(ang + s):.1f},{-8 * math.sin(ang + s):.1f}"
for s in (0.45, -0.45)) + f'" stroke="{GRIS}" stroke-width="1.6" fill="none"/>')
cadre = Cadre(240, 385, 188, 108, 0.0, 1.0, *etendue([X]))
m.append(fleche(235, 196, 398, 196, ep=1.3))
m += trace_grille(cadre, grille(n), X, lambda u: (BLEU, 1.0, 0.9))
ecrit("processus_de.svg", 420, 280, m)
def fig_decoupage():
"""Trois lancers du dé-boule Y (trois trajectoires indépendantes d'un processus
stable d'indice 1.5, sur une durée 1/3), recollés bout à bout : une trajectoire de X."""
m_ = 333_334
n = 3 * m_
def simule(rng):
X = levy_stable(rng, 1.5, m_, T=1 / 3)
for _ in range(2):
X = np.concatenate([X, X[-1] + levy_stable(rng, 1.5, m_, T=1 / 3)[1:]])
return X
def lisible(X):
lo, hi = etendue([X])
amplitudes = [np.ptp(X[k * m_:(k + 1) * m_ + 1]) for k in range(3)]
return (plus_gros_saut(X) <= 0.45 and 6 <= nb_sauts_visibles(X, 150) <= 39
and min(amplitudes) >= 0.2 * (hi - lo) and debut_net(X, 150))
X = premiere_graine(simule, lisible, 4000)
cadre = Cadre(30, 390, 190, 40, 0.0, 1.0, *etendue([X]))
couleurs = [BLEU, ROUGE, VERT]
m = [f'<path d="M{cadre.px(k / 3):.1f},{cadre.yb + 8} L{cadre.px(k / 3):.1f},{cadre.yh}" '
f'stroke="{GRIS}" stroke-opacity="0.5" stroke-dasharray="3 5" fill="none"/>' for k in (1, 2)]
m += trace_grille(cadre, grille(n), X, lambda u: (couleurs[min(2, int(3 * u + 1e-9))], 1.0, 1.0),
coupes=(1 / 3, 2 / 3))
for k in range(3):
cx = cadre.px((k + 0.5) / 3)
m += [boule(cx, 250, 26), fleche(cx, 220, cx, 200, couleurs[k], 1.4),
texte(cx - 5, 293, "Y", taille=15)]
ecrit("decoupage.svg", 420, 300, m)
fig_processus_de()
fig_decoupage()
montre("processus_de.svg", "decoupage.svg")
3.4 La reptation¶
Deux Lévy à variation finie (méthode 1.2), avec quelques centaines de milliers de sauts chacun.
SAUT: la dérive est négative. Privée de ses sauts, la trajectoire descend ; elle ne peut monter qu'en sautant, et franchit donc $a$ d'un saut.RAMPE: la dérive est positive. La trajectoire franchit $a$ tantôt d'un saut, tantôt en rampant ; on en retient une qui rampe, au bout d'une longue rampe sans saut visible.
La figure estime aussi, sur 400 trajectoires, la fréquence des passages en rampant (une minute de calcul environ) : jamais pour la première, environ deux fois sur trois pour la seconde.
A = 1.0 # l'altitude a
SAUT = dict(T=1.0, d=-1.5, alpha=0.5, c_plus=1.2, M_plus=0.9, c_moins=0.3, M_moins=0.4, eps=1e-10)
RAMPE = dict(T=1.0, d=1.6, alpha=0.6, c_plus=0.3, M_plus=0.5, c_moins=0.3, M_moins=0.5, eps=1e-9)
def bornee(t, s, avant, apres, d, T, a):
"""Cadrage raisonnable, et pas de gros saut collé à l'axe vertical."""
XT = apres[-1] + d * (T - t[-1])
return (max(apres.max(), XT) <= 1.7 * a and min(avant.min(), 0.0) >= -0.5 * a
and not np.any(np.abs(s[t < 0.04 * T]) > 0.03 * a))
def lisible_saut(r, p):
"""Un saut franc, qui enjambe nettement a, après une montée faite de sauts."""
t, s, avant, apres, Ta, mode, i = r
return (mode == "saut" and 0.45 * p["T"] <= Ta <= 0.8 * p["T"]
and s[i] >= 0.3 * A and A - avant[i] >= 0.1 * A and apres[i] - A >= 0.1 * A
and np.sum(s[t < Ta] > 0.08 * A) >= 3
and bornee(t, s, avant, apres, p["d"], p["T"], A))
def lisible_rampe(r, p):
"""Un passage net : une longue rampe sans saut visible mène à a et le traverse ;
la trajectoire saute par ailleurs, dans les deux sens."""
t, s, avant, apres, Ta, mode, i = r
T = p["T"]
if mode != "rampe" or not 0.45 * T <= Ta <= 0.8 * T:
return False
rampe = (t > Ta - 0.12 * T) & (t < Ta + 0.04 * T)
avant_Ta = s[t < Ta]
return (not np.any(np.abs(s[rampe]) > 0.01 * A)
and np.any(avant_Ta > 0.08 * A) and np.any(avant_Ta < -0.08 * A)
and bornee(t, s, avant, apres, p["d"], T, A))
def avec_passage(rng, p):
t, s, avant, apres = simule_levy_vf(rng, **p)
Ta, mode, i = premier_passage(t, avant, apres, p["d"], A, p["T"])
return t, s, avant, apres, Ta, mode, i
def frequence_reptation(p, n_traj, horizon):
"""Proportion des trajectoires qui franchissent a en rampant."""
q = dict(p, T=horizon)
rampe = passe = 0
for k in range(n_traj):
t, s, avant, apres = simule_levy_vf(np.random.default_rng(100_000 + k), **q)
Ta, mode, _ = premier_passage(t, avant, apres, p["d"], A, horizon)
if Ta is not None:
passe += 1
rampe += mode == "rampe"
return rampe / passe, passe
def fig_reptation(rapide=False):
for nom, p, lisible in [("reptation_saut.svg", SAUT, lisible_saut),
("reptation_rampe.svg", RAMPE, lisible_rampe)]:
t, s, avant, apres, Ta, mode, i = premiere_graine(
lambda rng: avec_passage(rng, p), lambda r: r[4] is not None and lisible(r, p), 0)
d, T = p["d"], p["T"]
xT = apres[-1] + d * (T - t[-1])
cadre = Cadre(36, 376, 214, 34, 0.0, T, *etendue([[0.0, A, xT], avant, apres]))
ya = cadre.py(A)
m = axes(cadre)
m.append(f'<path d="M{cadre.x0 - 4},{ya:.1f} L{cadre.x1 + 12},{ya:.1f}" stroke="{GRIS}" '
f'stroke-width="1.4" stroke-dasharray="6 5" fill="none"/>')
m.append(texte(cadre.x0 + 4, ya - 7, 'altitude <tspan font-style="italic">a</tspan>'))
style = lambda u: (BLEU, 0.3, 1.0) if u >= Ta else (BLEU, 1.0, 1.2)
if mode == "saut":
m += trace(cadre, t, avant, apres, (0.0, 0.0), (T, xT), style, saut_exclu=i)
x = cadre.px(Ta)
m.append(f'<path d="M{x:.1f},{cadre.py(avant[i]):.1f} L{x:.1f},{cadre.py(apres[i]):.1f}" '
f'stroke="{ROUGE}" stroke-width="1.6" stroke-dasharray="3 3" fill="none"/>')
m.append(f'<circle cx="{x:.1f}" cy="{cadre.py(avant[i]):.1f}" r="3" fill="none" '
f'stroke="{ROUGE}" stroke-width="1.5"/>')
m.append(f'<circle cx="{x:.1f}" cy="{cadre.py(apres[i]):.1f}" r="3.5" fill="{ROUGE}"/>')
else:
m += trace(cadre, t, avant, apres, (0.0, 0.0), (T, xT), style, coupes=(Ta,))
m.append(f'<circle cx="{cadre.px(Ta):.1f}" cy="{ya:.1f}" r="4" fill="{ROUGE}"/>')
m.append(texte(cadre.x1 - 30, cadre.yb + 28, "temps"))
ecrit(nom, 410, 250, m)
print(f" {len(t)} sauts")
if not rapide:
f, n = frequence_reptation(p, 400, 3.0)
print(f" franchit a en rampant dans {100 * f:.0f} % des cas ({n} trajectoires)")
fig_reptation()
montre("reptation_saut.svg", "reptation_rampe.svg")
3.5 Le relief : deux sommets schématiques¶
Ce sont des dessins, pas des simulations de Lévy, mais ils gardent les fluctuations des trajectoires à variation infinie. Les deux trajectoires valent $\text{enveloppe} - c\,|B(x)|$, où $B$ est un mouvement brownien bilatère nul au sommet $x = 0$ — le même pour les deux dessins : seule l'enveloppe change.
- Abrupt : enveloppe $1 - b\sqrt{|x|}$. Les quatre dérivées de Dini au sommet sont infinies : la trajectoire arrive et repart à la verticale.
- Érodé : enveloppe $1 - a x^2$. Comme $B$ s'annule une infinité de fois près de 0, la trajectoire revient sans cesse toucher sa tangente horizontale (deux dérivées de Dini nulles) ; mais elle plonge comme $\sqrt{|x|}$ entre deux contacts (les deux autres valent $+\infty$ et $-\infty$).
Une loupe, la même pour les deux dessins, agrandit le sommet 9 fois en largeur : le dôme de l'Érodé s'y efface, il ne reste que ses fluctuations, tandis que l'Abrupt reste une pointe.
def fig_relief():
"""Dessins schématiques des sommets (pas des simulations de Lévy), qui gardent les
fluctuations des trajectoires à variation infinie. Les deux trajectoires valent
« enveloppe - c |B(x)| », où B est un mouvement brownien bilatère nul au sommet x = 0
(le même pour les deux dessins : seule l'enveloppe change).
- Abrupt : enveloppe 1 - b √|x|. Les quatre dérivées de Dini au sommet sont
infinies : la trajectoire arrive et repart à la verticale.
- Érodé : enveloppe 1 - a x². Comme B s'annule une infinité de fois près de 0, la
trajectoire revient sans cesse effleurer sa tangente horizontale (deux dérivées de
Dini nulles), mais elle plonge comme √|x| entre deux contacts (les deux autres
valent +∞ et -∞)."""
n, a, b, c = 200_000, 0.7, 0.75, 0.28
x = np.linspace(-1.0, 1.0, 2 * n + 1)
def lisible(B): # B repasse visiblement par 0 de chaque côté, près du sommet
pas = max(1, n // 120)
cotes = (B[n - n // 4:n:pas], B[n + pas:n + n // 4 + 1:pas])
return all(np.sum(np.diff(np.sign(cote)) != 0) >= 3 for cote in cotes)
B = premiere_graine(lambda rng: np.concatenate([brownien(rng, n)[::-1], brownien(rng, n)[1:]]),
lisible, 9000)
courbes = [("relief_abrupt.svg", 1 - b * np.sqrt(np.abs(x)) - c * np.abs(B), "verticale"),
("relief_erode.svg", 1 - a * x ** 2 - c * np.abs(B), "horizontale")]
lo = min(float(y.min()) for _, y, _ in courbes)
cadre = Cadre(40, 260, 184, 54, -1.0, 1.0, lo - 0.03 * (1 - lo), 1.0)
xs, ys = cadre.px(0.0), cadre.py(1.0)
# La loupe, sous le sommet, agrandit 9 fois en largeur ; en hauteur, elle est réglée
# pour que les plongeons de l'Érodé la remplissent aux deux tiers — même loupe pour
# les deux dessins. Le dôme s'y efface, il ne reste que les fluctuations ; la
# pointe, elle, reste une pointe.
cx, cy, r, kx = 150.0, 160.0, 40.0, 9.0
delta = r / (kx * (cadre.x1 - cadre.x0) / (cadre.t1 - cadre.t0))
i0, i1 = n - int(delta * n), n + int(delta * n)
V = float(1 - courbes[1][1][i0:i1 + 1].min()) / 0.66
loupe = Cadre(cx - r, cx + r, cy + r, cy - 0.62 * r, -delta, delta, 1 - V, 1.0)
xl, yl = loupe.px(0.0), loupe.py(1.0)
for nom, y, tangente in courbes:
m = [cadre_arrondi(300, 220)] + trace_grille(cadre, x, y, lambda u: (BLEU, 1.0, 1.0), densite=2.5)
m.append(f'<defs><clipPath id="loupe"><circle cx="{cx}" cy="{cy}" r="{r}"/></clipPath></defs>')
m.append(f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{BLEU}" fill-opacity="0.05" '
f'stroke="{GRIS}" stroke-width="1.5"/>')
dedans = ['<g clip-path="url(#loupe)">']
dedans += trace_grille(loupe, x[i0:i1 + 1], y[i0:i1 + 1], lambda u: (BLEU, 1.0, 1.0), densite=2.5)
if tangente == "verticale":
m.append(f'<path d="M{xs:.1f},{ys - 30:.1f} L{xs:.1f},{ys + 48:.1f}" stroke="{ROUGE}" '
f'stroke-width="1.3" stroke-dasharray="5 4" fill="none"/>')
m.append(texte(xs + 8, ys - 18, "tangente verticale", ROUGE, 12))
dedans.append(f'<path d="M{xl:.1f},{yl - 12:.1f} L{xl:.1f},{cy + r:.1f}" stroke="{ROUGE}" '
f'stroke-width="1.1" stroke-dasharray="4 3" fill="none"/>')
else:
m.append(f'<path d="M{xs - 85:.1f},{ys:.1f} L{xs + 85:.1f},{ys:.1f}" stroke="{ROUGE}" '
f'stroke-width="1.3" stroke-dasharray="5 4" fill="none"/>')
m.append(texte(xs, ys - 14, "tangente horizontale", ROUGE, 12, ancre="middle"))
dedans.append(f'<path d="M{cx - r:.1f},{yl:.1f} L{cx + r:.1f},{yl:.1f}" stroke="{ROUGE}" '
f'stroke-width="1.1" stroke-dasharray="4 3" fill="none"/>')
dedans += [f'<circle cx="{xl:.1f}" cy="{yl:.1f}" r="2.5" fill="{ROUGE}"/>', "</g>"]
m += dedans
m.append(f'<circle cx="{xs:.1f}" cy="{ys:.1f}" r="3" fill="{ROUGE}"/>')
m.append(texte(cx + r + 6, cy + r - 6, "à la loupe", GRIS, 11))
ecrit(nom, 300, 220, m)
fig_relief()
montre("relief_abrupt.svg", "relief_erode.svg")
3.6 Le jeu¶
Deux vraies trajectoires sur $[0, 1]$ : A est un processus de Cauchy (stable d'indice 1, un Érodé), B un stable d'indice 1.5 (un Abrupt). Le point le plus haut doit être loin des bords et atteint sans grand saut tout contre lui, et le plus gros saut doit rester dans les valeurs typiques.
def fig_jeu():
"""Le jeu : A est un processus de Cauchy (un Érodé), B un stable d'indice 1.5 (un
Abrupt) ; trajectoires entières, sur [0, 1]."""
n = 10 ** 6
for nom, alpha, graine, saut_max in [("jeu_A.svg", 1.0, 6000, 0.65), ("jeu_B.svg", 1.5, 6500, 0.45)]:
def lisible(X):
lo, hi = etendue([X])
return sommet_net(X, n, 0.02, 6.0 / 160 * (hi - lo)) and plus_gros_saut(X) <= saut_max
X = premiere_graine(lambda rng: levy_stable(rng, alpha, n), lisible, graine)
cadre = Cadre(20, 280, 190, 30, 0.0, 1.0, *etendue([X]))
m = [cadre_arrondi(300, 220)] + trace_grille(cadre, grille(n), X, lambda u: (BLEU, 1.0, 0.9))
ecrit(nom, 300, 220, m)
fig_jeu()
montre("jeu_A.svg", "jeu_B.svg")
3.7 L'autosimilarité¶
Un processus stable d'indice $\alpha$ est autosimilaire : $(X_{kt})_{t \ge 0}$ a même loi que $(k^{1/\alpha} X_t)_{t \ge 0}$. On découpe donc une fenêtre 8 fois plus étroite que la trajectoire entière et $8^{1/\alpha} = 4$ fois moins haute (pour $\alpha = 1.5$) : agrandie à la taille du cadre, elle a, en loi, la même allure que la trajectoire entière. Parmi les fenêtres qui remplissent bien le cadre agrandi et qui ressemblent à la trajectoire (pas de saut qui l'écrase, quelques sauts visibles), on retient celle dont l'étendue est la plus proche des trois quarts de la hauteur.
def fig_stable_zoom():
"""Un processus stable d'indice 1.5 : une fenêtre k fois plus étroite et k^(1/α)
fois moins haute que le cadre, agrandie à la taille du cadre, a la même allure
que la trajectoire entière."""
alpha, n, k = 1.5, 2 ** 22, 8
fac = k ** (1 / alpha) # 8^(2/3) = 4
m_ = n // k
tg = grille(n)
def fenetre(X):
"""Parmi les fenêtres dont l'étendue remplit bien le cadre agrandi et qui
ressemblent à la trajectoire entière (pas de saut qui l'écrase, des sauts
visibles), celle dont l'étendue est la plus proche de 3/4 de la hauteur."""
lo, hi = etendue([X], 0.05)
R = hi - lo
if plus_gros_saut(X) > 0.45 or nb_sauts_visibles(X, 120) < 6:
return None
meilleure, ecart = None, np.inf
for k0 in range(int(0.05 * n), int(0.85 * n), n // 256):
Xw = X[k0:k0 + m_ + 1]
r = float(np.ptp(Xw))
inc = np.abs(np.diff(Xw))
if (0.6 * R / fac <= r <= 0.9 * R / fac and inc.max() <= 0.45 * r
and np.sum(inc > 6.0 / 120 * R / fac) >= 3 and abs(r - 0.75 * R / fac) < ecart):
meilleure, ecart = k0, abs(r - 0.75 * R / fac)
return meilleure
X = premiere_graine(lambda rng: levy_stable(rng, alpha, n), lambda X: fenetre(X) is not None, 7000)
k0 = fenetre(X)
lo, hi = etendue([X], 0.05)
R = hi - lo
Xw = X[k0:k0 + m_ + 1]
c = 0.5 * (Xw.max() + Xw.min())
haut = Cadre(30, 390, 140, 20, 0.0, 1.0, lo, hi)
bas = Cadre(30, 390, 330, 210, tg[k0], tg[k0 + m_], c - R / fac / 2, c + R / fac / 2)
m = [fleche(24, 150, 410, 150, ep=1.2), fleche(24, 340, 410, 340, ep=1.2)]
m += trace_grille(haut, tg, X, lambda u: (BLEU, 1.0, 0.8))
xa, xb = haut.px(tg[k0]), haut.px(tg[k0 + m_])
ya, yb = haut.py(c + R / fac / 2), haut.py(c - R / fac / 2)
m.append(f'<rect x="{xa:.1f}" y="{ya:.1f}" width="{xb - xa:.1f}" height="{yb - ya:.1f}" '
f'fill="{ROUGE}" fill-opacity="0.08" stroke="{ROUGE}" stroke-width="1.5"/>')
m.append(f'<rect x="26" y="204" width="368" height="132" rx="8" fill="none" stroke="{ROUGE}" '
f'stroke-opacity="0.5" stroke-width="1.5"/>')
m.append(fleche((xa + xb) / 2, yb + 4, 210, 200, ROUGE, 1.5))
m.append(texte((xa + xb) / 2 + 14, (yb + 200) / 2 + 4, "zoom", ROUGE))
m += trace_grille(bas, tg[k0:k0 + m_ + 1], Xw, lambda u: (BLEU, 1.0, 0.8))
ecrit("stable_zoom.svg", 420, 345, m)
fig_stable_zoom()
montre("stable_zoom.svg")
3.8 Le mouvement brownien¶
Il part de 0, comme le veut la tradition : l'axe des temps passe à la hauteur 0, et l'on retient une trajectoire qui oscille de part et d'autre.
def fig_brownien():
"""Le mouvement brownien part de 0, comme le veut la tradition : l'axe des temps
passe à la hauteur 0, et la trajectoire oscille de part et d'autre."""
n = 10 ** 6
def lisible(X):
R = float(X.max() - X.min())
return X.min() < -0.25 * R and X.max() > 0.25 * R
X = premiere_graine(lambda rng: brownien(rng, n), lisible, 8)
cadre = Cadre(42, 382, 250, 30, 0.0, 1.0, *etendue([X]))
y0 = cadre.py(0.0)
m = [fleche(cadre.x0, y0, cadre.x1 + 22, y0), fleche(cadre.x0, cadre.yb + 8, cadre.x0, cadre.yh - 14)]
for j in range(1, 6):
x = cadre.px(j / 6)
m.append(f'<path d="M{x:.1f},{y0:.1f} L{x:.1f},{y0 + 5:.1f}" stroke="{GRIS}" stroke-width="1.2"/>')
m += trace_grille(cadre, grille(n), X, lambda u: (BLEU, 1.0, 0.8))
m.append(texte(cadre.x0 - 8, y0 + 4, "0", ancre="end"))
m.append(texte(cadre.x1 + 22, y0 + 20, "temps", ancre="end"))
ecrit("brownien.svg", 420, 280, m)
fig_brownien()
montre("brownien.svg")