Vincent Vigon

← 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.

  1. Trois façons de simuler un processus de Lévy.
  2. La boîte à outils du dessin.
  3. 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")
  de.svg : 1 Ko
  petanque.svg : 9 Ko
de.svgpetanque.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")
  graine retenue : 1002
  traj_simple.svg : 1 Ko
  graine retenue : 2003
  traj_compliquee.svg : 11 Ko
traj_simple.svgtraj_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")
  graine retenue : 3000
  processus_de.svg : 16 Ko
  graine retenue : 4000
  decoupage.svg : 39 Ko
processus_de.svgdecoupage.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")
  graine retenue : 12
  reptation_saut.svg : 19 Ko
  299498 sauts
  franchit a en rampant dans 0 % des cas (273 trajectoires)
  graine retenue : 45
  reptation_rampe.svg : 18 Ko
  250870 sauts
  franchit a en rampant dans 67 % des cas (400 trajectoires)
reptation_saut.svgreptation_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")
  graine retenue : 9000
  relief_abrupt.svg : 10 Ko
  relief_erode.svg : 10 Ko
relief_abrupt.svgrelief_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")
  graine retenue : 6001
  jeu_A.svg : 11 Ko
  graine retenue : 6504
  jeu_B.svg : 15 Ko
jeu_A.svgjeu_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")
  graine retenue : 7000
  stable_zoom.svg : 22 Ko
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")
  graine retenue : 11
  brownien.svg : 9 Ko
brownien.svg