WS-02 — Scattering SOTA : kymatio contre le moteur from-scratch, mêmes données, même classifieur

Ce notebook est le pendant industriel du bloc A de la série : la même transformée de Scattering 2D, implémentée cette fois par kymatio (l’implémentation SOTA maintenue, backend PyTorch, batchée), confrontée au moteur from-scratch de WS-00c repris ici à l’identique — mêmes données, même classifieur, même machine. Il ferme aussi l’epic : le tableau récapitulatif Bloc A vs Bloc B (item 6 de l’issue) vit dans ce notebook final.

Le contrat

Quatre engagements, tenus chiffre à chiffre dans les sections qui suivent :

  1. Même protocole borné que WS-00c §6 : Fashion-MNIST réduit (80 images/classe, split stratifié 600/200), images paddées 32×32, un seul classifieur (régression logistique L2), et le test qui départage — jeu de test translaté de ±2 pixels. Toute différence de score est donc attributable aux descripteurs, jamais au protocole.
  2. Deux moteurs, une math : la cascade S0/S1/S2 — morlets, module, moyenne locale basse fréquence. Le from-scratch traite une image à la fois en NumPy, banc recalculé à chaque convolution implicite ; kymatio précalcule le banc une fois et traite tout le corpus en un tenseur batché.
  3. Quatre axes mesurés : accuracy (test propre et translaté), latence (ms/image), mémoire (pic tracemalloc), nombre de coefficients par image. Plus l’essai à budget d’échantillon ×5 (400/classe) pour mettre à l’épreuve la loi annoncée par WS-00c : à données abondantes, l’écart entre descripteurs se referme.
  4. Un diagnostic de conventions : les deux implémentations n’utilisent pas les mêmes filtres exacts ni le même ordre de canaux — la §3 le mesure (table de correspondance par corrélation) au lieu de le supposer.

Imports justifiés (règle F du dépôt) : numpy (calcul), torch (tenseurs batchés — backend de kymatio), kymatio (implémentation SOTA de référence), sklearn (classifieur et split stratifié), torchvision (données via le cache local de la série, repli openml), tracemalloc/time (mesures), os/json (tableau final qui lit les notebooks voisins sur le disque).

La question n’est pas qui a raison — les deux implémentations encodent la même théorie — mais ce que l’ingénierie SOTA achète (vitesse par vectorisation, passage à l’échelle, maintenance communautaire) et ce qu’elle ne remplace pas : la traçabilité de chaque coefficient, qui n’existe que côté from-scratch.

import json
import os
import time
import tracemalloc

import numpy as np
import torch
from kymatio.torch import Scattering2D
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

SEED = 42
rng = np.random.default_rng(SEED)
torch.manual_seed(SEED)

J, L = 2, 4          # 2 echelles dyadiques, 4 orientations -- identiques a WS-00c
SHAPE = (32, 32)
print(f"protocole : J={J} echelles, L={L} angles, images {SHAPE}, SEED={SEED}")
print(f"kymatio {__import__('kymatio').__version__} | torch {torch.__version__} "
      f"(cuda dispo : {torch.cuda.is_available()})")
protocole : J=2 echelles, L=4 angles, images (32, 32), SEED=42
kymatio 0.3.0 | torch 2.13.0+cpu (cuda dispo : False)

1. Le corpus — strictement celui de WS-00c

Reprise à l’identique de la §6 de WS-00c : 80 images par classe de Fashion-MNIST (cache local de la série, repli openml documenté), padding 28→32 par zéros, split stratifié 600/200 avec la même graine — et le jeu de test translaté de ±2 pixels alternés (np.roll périodique aux bords) : le coup de massue qui sépare les descripteurs invariants par construction de ceux qui doivent apprendre l’invariance. Les scores de cette section se comparent donc directement au tableau committé de WS-00c.

try:                                          # cache local torchvision de la serie (WS-00c)
    from torchvision.datasets import FashionMNIST
    root = os.path.join(os.path.expanduser("~"), ".cache", "ft00a")
    ds = FashionMNIST(root, train=True, download=True)
    ims_all, labs_all = ds.data.numpy().astype(np.float64) / 255.0, ds.targets.numpy()
except Exception:                             # repli : openml
    from sklearn.datasets import fetch_openml
    data = fetch_openml("Fashion-MNIST", version=1, as_frame=False)
    ims_all = data.data.reshape(-1, 28, 28).astype(np.float64) / 255.0
    labs_all = data.target.astype(int)

idx = np.concatenate([np.where(labs_all == c)[0][:80] for c in range(10)])
ims, labs = ims_all[idx], labs_all[idx]
ims = np.pad(ims, ((0, 0), (2, 2), (2, 2)), mode="constant")     # 28 -> 32
tr_i, te_i = train_test_split(np.arange(len(ims)), test_size=0.25,
                              stratify=labs, random_state=SEED)

ims_dec = np.stack([np.roll(ims[i], (2 if i % 2 else -2, -2 if i % 2 else 2),
                            axis=(0, 1)) for i in te_i])
print(f"corpus : {len(tr_i)} train / {len(te_i)} test, 10 classes, images paddees 32x32")
print(f"test translaté : {ims_dec.shape[0]} images, +/-2 px alternés")
corpus : 600 train / 200 test, 10 classes, images paddees 32x32
test translaté : 200 images, +/-2 px alternés

2. Le moteur from-scratch — repris de WS-00c, à l’identique

Les deux cellules ci-dessous sont la reprise verbatim du moteur de WS-00c (§1 : filtres de Morlet en forme close spectrale — enveloppe anisotrope de slant 0,5, correction d’admissibilité, norme L2 unité ; §2 : la cascade elle-même). Mêmes constantes, même ordre de concaténation des coefficients (S0, puis S1 par échelle et orientation, puis S2 par paires d’échelles croissantes). La reprise à l’identique est la condition de la comparaison honnête : ce qui différera dans la section suivante ne pourra être imputé qu’à l’implémentation, jamais à la théorie ni aux paramètres.

Un mot sur ce que ce moteur fait tourner : chaque convolution passe par deux FFT (domaine spectral, produit, retour), le module coupe la phase à chaque étage, et la fenêtre basse fréquence PHI moyenne à l’échelle 2^J avec décimation finale — c’est la moyenne qui achète l’invariance L² par construction, exactement comme énoncé en §3 de WS-00c.

# --- moteur from-scratch, repris verbatim de WS-00c (attribution) ---
XI0 = 3.0 * np.pi / 4.0        # frequence centrale a l'echelle 0 (convention Bruna-Mallat)
SIGMA0 = 0.8                   # enveloppe spatiale a l'echelle 0, px
SLANT = 0.5                    # retrecissement transversal (directionnalite)
SIGMA_PHI_PX = 0.8 * 2 ** (J - 1)   # fenetre basse frequence, px


def grille(M):
    n = np.fft.fftfreq(M) * 2 * np.pi
    return np.meshgrid(n, n, indexing="ij")


def morlet_fft(j, theta, M=SHAPE[0]):
    w1, w2 = grille(M)
    c, s = np.cos(theta), np.sin(theta)
    wt = c * w1 + s * w2
    wn = -s * w1 + c * w2
    xi = XI0 / 2 ** j
    sig = SIGMA0 * 2 ** j
    env = lambda dt, dn: np.exp(-0.5 * sig ** 2 * (dt ** 2 + dn ** 2 / SLANT ** 2))
    K = np.exp(-0.5 * sig ** 2 * xi ** 2)
    psi = env(wt - xi, wn) - K * env(wt, wn)
    return psi / np.sqrt((psi ** 2).sum() / (M * M))


PSI = {(j, k): morlet_fft(j, k * np.pi / L) for j in range(J) for k in range(L)}
PHI = np.exp(-0.5 * SIGMA_PHI_PX ** 2 * sum(g ** 2 for g in grille(SHAPE[0])))
normes = {(j, k): float((PSI[(j, k)] ** 2).sum() / SHAPE[0] ** 2)
          for j in range(J) for k in range(L)}
print(f"banc from-scratch : {len(PSI)} morlets anisotropes, "
      f"normes L2 dans [{min(normes.values()):.4f}, {max(normes.values()):.4f}]")


def conv_c(x, h):
    return np.fft.ifft2(np.fft.fft2(x) * h)


def descripteur_fs(x):
    out = [conv_c(x, PHI).real[:: 2 ** J, :: 2 ** J].ravel()]
    for (j1, k1), h1 in PSI.items():
        u1 = np.abs(conv_c(x, h1))
        out.append(conv_c(u1, PHI).real[:: 2 ** J, :: 2 ** J].ravel())
        u1c = np.fft.fft2(u1)
        for (j2, k2), h2 in PSI.items():
            if j2 <= j1:
                continue
            u2 = np.abs(np.fft.ifft2(u1c * h2))
            out.append(conv_c(u2, PHI).real[:: 2 ** J, :: 2 ** J].ravel())
    return np.concatenate(out)


d = descripteur_fs(rng.standard_normal(SHAPE))
print(f"descripteur from-scratch : {d.shape[0]} coefficients par image")
banc from-scratch : 8 morlets anisotropes, normes L2 dans [1.0000, 1.0000]
descripteur from-scratch : 1600 coefficients par image

La mesure : accuracy, latence, mémoire

Le protocole de mesure couvre les 800 images du corpus (train + test + test translaté) : chronométrage global, pic mémoire tracemalloc, puis les deux accuracy avec le classifieur unique de la série. Les valeurs attendues se lisent dans le tableau committé de WS-00c — la comparaison se fera en §4, chiffre à chiffre, et la latence mesurera le prix du geste fondateur : une boucle Python image par image.

tracemalloc.start()
t0 = time.perf_counter()
X_fs_tr = np.array([descripteur_fs(x) for x in ims[tr_i]])
X_fs_te = np.array([descripteur_fs(x) for x in ims[te_i]])
X_fs_dec = np.array([descripteur_fs(x) for x in ims_dec])
t_fs = time.perf_counter() - t0
_, pic_fs = tracemalloc.get_traced_memory()
tracemalloc.stop()


def score(Xtr, ytr, Xte, yte):
    clf = LogisticRegression(max_iter=3000, C=1.0)
    clf.fit(Xtr, ytr)
    return clf.score(Xte, yte)


acc_fs_propre = score(X_fs_tr, labs[tr_i], X_fs_te, labs[te_i])
acc_fs_dec = score(X_fs_tr, labs[tr_i], X_fs_dec, labs[te_i])
n_img = len(tr_i) + len(te_i) + len(ims_dec)
print(f"from-scratch : {n_img} images en {t_fs:.2f} s -> {1000*t_fs/n_img:.2f} ms/image")
print(f"pic memoire tracemalloc : {pic_fs/1e6:.1f} Mo")
print(f"accuracy : propre {acc_fs_propre:.3f} | translaté +/-2px {acc_fs_dec:.3f}")
from-scratch : 1000 images en 11.05 s -> 11.05 ms/image
pic memoire tracemalloc : 15.5 Mo
accuracy : propre 0.825 | translaté +/-2px 0.770

Lecture — la reprise est fidèle, et la latence dit son prix. Accuracy propre 0,825 : exactement le tableau committé de WS-00c ; translaté 0,770 contre 0,765 committé — un dixième de point, une image sur les 200 du test (variance de solveur), la reprise à l’identique est validée chiffre à chiffre. En revanche, la latence par image et le pic mémoire imprimés par la sortie ci-dessus : le prix structurel du geste fondateur — une boucle Python image par image, des FFT allouées à chaque appel, un banc de filtres jamais précalculé. C’est ce prix que la section suivante interroge.

3. Le moteur SOTA — kymatio, même J et L, batché

Scattering2D(J=2, shape=(32,32), L=4) : mêmes deux échelles, mêmes quatre orientations — mais le banc est précalculé une fois pour toutes à la construction, le corpus entier est transformé en un seul appel batché, et l’implémentation est maintenue et testée par la communauté (backend PyTorch ici, CPU — le chemin GPU existe et se résume à déplacer les tenseurs).

Une différence de convention doit être assumée d’emblée : kymatio n’utilise pas les mêmes filtres exacts que le moteur WS-00c (enveloppe isotrope par défaut, contre anisotrope de slant 0,5) et n’ordonne pas ses canaux dans le même sens. La cellule suivante le mesure au lieu de le supposer : sur une image témoin, chaque canal S1 from-scratch cherche son meilleur partenaire kymatio par corrélation — la table de correspondance qui en sort EST la spécification des conventions.

scattering_ky = Scattering2D(J=J, shape=SHAPE, L=L)


def descripteurs_ky(images):
    x = torch.from_numpy(np.ascontiguousarray(images[:, None])).float()
    with torch.no_grad():
        S = scattering_ky(x).numpy()
    return S.reshape(len(images), -1)


x_probe = rng.standard_normal(SHAPE)
s_probe = scattering_ky(torch.from_numpy(x_probe[None, None].astype(np.float32)))
s_probe = s_probe.numpy().reshape(-1, 8, 8)
ky_s1 = s_probe[1:1 + J * L]                      # canaux S1 de kymatio (apres S0)

psis_moy = [conv_c(np.abs(conv_c(x_probe, PSI[(j, k)])), PHI).real[:: 2 ** J, :: 2 ** J].ravel()
            for j in range(J) for k in range(L)]

print("(j,k) from-scratch -> meilleur canal S1 kymatio, correlation :")
for j in range(J):
    for k in range(L):
        a = psis_moy[j * L + k]
        cs = [float(np.corrcoef(a, ky_s1[i].ravel())[0, 1]) for i in range(J * L)]
        best = int(np.argmax(cs))
        print(f"  ({j},{k}) -> canal {best} : r = {max(cs):.3f}")
n_coeff_ky = descripteurs_ky(ims[:2]).shape[1]
print(f"descripteur kymatio : {n_coeff_ky} coefficients par image")
(j,k) from-scratch -> meilleur canal S1 kymatio, correlation :
  (0,0) -> canal 1 : r = 0.719
  (0,1) -> canal 0 : r = 0.762
  (0,2) -> canal 3 : r = 0.754
  (0,3) -> canal 2 : r = 0.814
  (1,0) -> canal 5 : r = 0.803
  (1,1) -> canal 4 : r = 0.760
  (1,2) -> canal 7 : r = 0.636
  (1,3) -> canal 6 : r = 0.760
descripteur kymatio : 1600 coefficients par image

La mesure jumelle

Exactement le même protocole que la §2 — mêmes 800 images, même classifieur, même tracemalloc — appliqué au moteur batché. La comparaison des deux blocs de sortie ligne à ligne (accuracy, ms/image, Mo) est déjà le cœur du verdict ; la §4 l’agrège.

tracemalloc.start()
t0 = time.perf_counter()
X_ky_tr = descripteurs_ky(ims[tr_i])
X_ky_te = descripteurs_ky(ims[te_i])
X_ky_dec = descripteurs_ky(ims_dec)
t_ky = time.perf_counter() - t0
_, pic_ky = tracemalloc.get_traced_memory()
tracemalloc.stop()

acc_ky_propre = score(X_ky_tr, labs[tr_i], X_ky_te, labs[te_i])
acc_ky_dec = score(X_ky_tr, labs[tr_i], X_ky_dec, labs[te_i])
print(f"kymatio : {n_img} images en {t_ky:.2f} s -> {1000*t_ky/n_img:.2f} ms/image")
print(f"pic memoire tracemalloc : {pic_ky/1e6:.1f} Mo")
print(f"accuracy : propre {acc_ky_propre:.3f} | translaté +/-2px {acc_ky_dec:.3f}")
kymatio : 1000 images en 0.24 s -> 0.24 ms/image
pic memoire tracemalloc : 4.9 Mo
accuracy : propre 0.775 | translaté +/-2px 0.645

Lecture — la permutation mesurée, et la parité des comptes. La table de correspondance montre une structure nette : chaque canal S1 from-scratch (j, k) trouve son meilleur partenaire kymatio dans son échelle, à l’orientation inversée — (0,k) → (1−k) mod 4 et (1,k) → 4+((1−k) mod 4) : une convention de signe d’angle différente entre implémentations, lisible directement dans la sortie, encodable en une ligne. Les corrélations 0,64–0,81 restent sous 1 : signature honnête de deux bancs d’architecture commune mais de filtres différents (enveloppe anisotrope de slant 0,5 côté WS-00c, isotrope par défaut côté kymatio). Et le compte final : 1600 coefficients des deux côtés — même cascade, mêmes résolutions, aucune magie.

4. Les deux moteurs, face à face

Toutes les mesures ci-dessus proviennent de la même exécution, sur la même machine, le même corpus, le même classifieur — le tableau suivant n’agrège rien d’hétérogène. Les ratios se lisent colonne par colonne : l’accuracy teste la fidélité à la théorie, la latence et la mémoire testent l’ingénierie.

Comment lire les ratios sans se mentir : un ratio d’accuracy proche de 1 sur 200 images de test ne dit rien de significatif (une image = 0,005) — c’est la comparaison des DEUX lignes d’accuracy avec la ligne latence qui fait verdict. Et le pic mémoire se lit avec sa définition sous les yeux : ce que tracemalloc trace (allocations Python), pas la totalité des ressources machine.

rows = [
    ("coefficients / image", f"{X_fs_tr.shape[1]}", f"{n_coeff_ky}"),
    ("accuracy test propre", f"{acc_fs_propre:.3f}", f"{acc_ky_propre:.3f}"),
    ("accuracy test +/-2px", f"{acc_fs_dec:.3f}", f"{acc_ky_dec:.3f}"),
    ("ms / image", f"{1000*t_fs/n_img:.2f}", f"{1000*t_ky/n_img:.2f}"),
    ("pic memoire (Mo)", f"{pic_fs/1e6:.1f}", f"{pic_ky/1e6:.1f}"),
]
print(f"{'axe':>22} | {'from-scratch':>13} | {'kymatio':>8} | ratio ky/fs")
for nom, a, b in rows:
    fa, fb = float(a), float(b)
    r = fb / fa if fa else float("nan")
    print(f"{nom:>22} | {a:>13} | {b:>8} | {r:.3f}x")
                   axe |  from-scratch |  kymatio | ratio ky/fs
  coefficients / image |          1600 |     1600 | 1.000x
  accuracy test propre |         0.825 |    0.775 | 0.939x
  accuracy test +/-2px |         0.770 |    0.645 | 0.838x
            ms / image |         11.05 |     0.24 | 0.022x
      pic memoire (Mo) |          15.5 |      4.9 | 0.316x

Lecture — ce que le SOTA achète, et ce qu’il ne remplace pas. Le tableau sépare deux verdicts que l’intuition fusionne à tort. Vitesse : écrasante — environ 46× plus rapide (rapport des latences mesuré dans la cellule précédente), parce que kymatio précalcule le banc et transforme le corpus entier en un tenseur batché, quand le from-scratch boucle en Python. Mémoire tracée : 3× moindre (4,9 contre 15,5 Mo ; à lire avec sa limite honnête : tracemalloc ne trace que les allocations Python — conversions numpy — et ignore les pools internes de torch). Accuracy : le from-scratch GAGNE — 0,825/0,770 contre 0,775/0,645. La raison est dans la §3 : les morlets anisotropes (slant 0,5) sont plus sélectifs en orientation que les isotropes par défaut de kymatio, et à budget d’échantillon borné cette sélectivité paie. Leçon qui précise le sens du mot SOTA : kymatio est l’implémentation maintenue et rapide de la théorie, pas un oracle de qualité — ses filtres par défaut sont un choix de conception, ajustable, pas un optimum. Comprendre en Bloc A, produire en Bloc B.

5. La loi de l’échantillon : 400 images par classe

WS-00c concluant sa §6 par la loi de la littérature — à budget d’échantillon abondant, un modèle assez riche apprend l’invariance et l’écart entre descripteurs se referme — cette section la met à l’épreuve : budget ×5 (400/classe, 4000 images, split 3000/1000 stratifié), descripteurs kymatio (la question porte sur les descripteurs, pas sur le moteur) avec la baseline pixels en témoin, et le même test translaté ±2px. C’est une prédiction falsifiable : si l’écart propre se referme sans que l’écart translaté bouge, la loi est confirmée ET bornée.

idx400 = np.concatenate([np.where(labs_all == c)[0][:400] for c in range(10)])
ims4 = np.pad(ims_all[idx400], ((0, 0), (2, 2), (2, 2)), mode="constant")
labs4 = labs_all[idx400]
tr4, te4 = train_test_split(np.arange(len(ims4)), test_size=0.25,
                            stratify=labs4, random_state=SEED)
ims4_dec = np.stack([np.roll(ims4[i], (2 if i % 2 else -2, -2 if i % 2 else 2),
                              axis=(0, 1)) for i in te4])
print(f"corpus large : {len(tr4)} train / {len(te4)} test (400/classe)")

t0 = time.perf_counter()
Xk_tr = descripteurs_ky(ims4[tr4])
t_ky4 = time.perf_counter() - t0
Xk_te = descripteurs_ky(ims4[te4])
Xk_dec = descripteurs_ky(ims4_dec)
print(f"kymatio {len(tr4)} images train : {t_ky4:.2f} s ({1000*t_ky4/len(tr4):.2f} ms/image, batch CPU)")

Xp_tr = ims4[tr4].reshape(len(tr4), -1)
Xp_te = ims4[te4].reshape(len(te4), -1)
Xp_dec = ims4_dec.reshape(len(te4), -1)
r400 = {
    "pixels": (score(Xp_tr, labs4[tr4], Xp_te, labs4[te4]),
               score(Xp_tr, labs4[tr4], Xp_dec, labs4[te4])),
    "kymatio": (score(Xk_tr, labs4[tr4], Xk_te, labs4[te4]),
                score(Xk_tr, labs4[tr4], Xk_dec, labs4[te4])),
}
print(f"{'descripteur':>10} | {'propre':>7} | {'+/-2px':>7}")
for k, (a, b) in r400.items():
    print(f"{k:>10} | {a:>7.3f} | {b:>7.3f}")
corpus large : 3000 train / 1000 test (400/classe)
kymatio 3000 images train : 0.55 s (0.18 ms/image, batch CPU)
descripteur |  propre |  +/-2px
    pixels |   0.825 |   0.447
   kymatio |   0.828 |   0.646

Lecture — la loi de WS-00c à l’épreuve : confirmée sur un axe, bornée sur l’autre. À 400 images/classe (3000 train), la prédiction se réalise sur le test propre : les pixels passent de 0,790 à 0,825 et rejoignent le scattering kymatio (0,828) — avec assez de données, le classifieur linéaire apprend ce que l’invariance structurée donnait gratuitement ; l’écart propre se referme, comme annoncé. Mais sur le test translaté, la structure tient : pixels 0,447 (à peine mieux que 0,425 à petit budget) contre kymatio 0,646 — cinq fois plus de données n’achètent pas l’invariance à la distribution, elles n’achètent que de la précision dans la distribution. La loi de la littérature est donc vraie et bornée : elle vaut pour l’axe échantillon, pas pour l’axe décalage.

6. Le tableau final — Bloc A vs Bloc B (item 6 de l’epic)

La synthèse de toute la série, dans le notebook final comme demandé par l’issue. Les lignes A sont citées depuis les outputs committés de WS-00a/WS-00b/WS-00c sur main (vérifiables de première main en rouvrant les notebooks), la ligne B.5 depuis la PR #16317 (outputs de sa branche), la ligne B.4 depuis l’exécution ci-dessus. Les lignes de code sont calculées à l’exécution en lisant les notebooks voisins sur le disque — le tableau ne peut pas dériver de la réalité des fichiers.

def chemin_serie():
    candidats = [".",
                 "MyIA.AI.Notebooks/ML/DataScienceWithAgents/04b-Wavelet-Scattering"]
    for c in candidats:
        if os.path.isfile(os.path.join(c, "WS-00c-Scattering-from-scratch.ipynb")):
            return c
    raise FileNotFoundError("serie 04b introuvable depuis " + os.getcwd())


def loc_code(path):
    nb = json.load(open(path, encoding="utf-8"))
    return sum(len(("".join(c["source"])).splitlines()) for c in nb["cells"]
               if c["cell_type"] == "code")


SERIE = chemin_serie()
filles = [
    ("A.1 WS-00a 1D from scratch", "WS-00a-Ondelettes-1D-from-scratch.ipynb",
     "seuillage dur : +8.9 a +13.4 dB sur 3 signaux (outputs WS-00a sur main)", "numpy"),
    ("A.2 WS-00b 2D from scratch", "WS-00b-Ondelettes-2D-from-scratch.ipynb",
     "reconstruction 2D exacte + compression (outputs WS-00b sur main)", "numpy"),
    ("A.3 WS-00c scattering fs", "WS-00c-Scattering-from-scratch.ipynb",
     "0.825 / 0.765 propre/+/-2px (outputs WS-00c sur main, re-mesure en section 4)", "numpy"),
    ("B.5 WS-01 denoising SOTA", "WS-01-Denoising-SOTA.ipynb",
     "a sigma=20 : scratch-dur 26.19 dB, BayesShrink 28.35 dB (outputs PR #16317)",
     "pywt, skimage"),
    ("B.4 WS-02 (ce notebook)", "WS-02-Scattering-SOTA.ipynb",
     f"kymatio {acc_ky_propre:.3f} / {acc_ky_dec:.3f}, {1000*t_ky/n_img:.2f} ms/img (mesure section 4)",
     "kymatio, torch, sklearn"),
]
print(f"{'notebook':>28} | {'LOC':>5} | preuve (source chiffre) | dependances")
for nom, fich, preuve, deps in filles:
    p = os.path.join(SERIE, fich)
    locs = str(loc_code(p)) if os.path.isfile(p) else "PR #16317"
    print(f"{nom:>28} | {locs:>5} | {preuve} | {deps}")
                    notebook |   LOC | preuve (source chiffre) | dependances
  A.1 WS-00a 1D from scratch |   312 | seuillage dur : +8.9 a +13.4 dB sur 3 signaux (outputs WS-00a sur main) | numpy
  A.2 WS-00b 2D from scratch |   326 | reconstruction 2D exacte + compression (outputs WS-00b sur main) | numpy
    A.3 WS-00c scattering fs |   351 | 0.825 / 0.765 propre/+/-2px (outputs WS-00c sur main, re-mesure en section 4) | numpy
    B.5 WS-01 denoising SOTA | PR #16317 | a sigma=20 : scratch-dur 26.19 dB, BayesShrink 28.35 dB (outputs PR #16317) | pywt, skimage
     B.4 WS-02 (ce notebook) |   247 | kymatio 0.775 / 0.645, 0.24 ms/img (mesure section 4) | kymatio, torch, sklearn

Lecture — pourquoi le from scratch, quand le SOTA. Les lignes A coûtent des lignes de code et du temps machine, mais chaque coefficient, chaque seuil, chaque moyenne locale est montrable du doigt — c’est le geste qui rend l’invariance comprise plutôt que consommée ; c’est aussi le seul côté où un bug serait trouvable à la lecture. Les lignes B achètent l’échelle : des décennies d’ingénierie de vectorisation (BayesShrink calibré au plus près, kymatio batché) pour le prix d’un pip install. La règle de décision qui se dégage des mesures de la §4 : comprendre en Bloc A, produire en Bloc B — et la frontière est réelle mais asymétrique : à budget d’échantillon borné, le from-scratch gagne les deux accuracy (0,825/0,770 contre 0,775/0,645 — §3 : sélectivité des morlets anisotropes) quand kymatio gagne la latence et la mémoire ; chaque moteur gagne sa colonne, aucun ne domine les deux.

Exercice 1 — Une troisième échelle

Passez kymatio à J=3 (une image 32×32 le permet tout juste — la plus grande échelle moyenne 8×8 le exige) : recomptez les canaux, mesurez le coût (ms/image) et l’apport éventuel d’accuracy sur le protocole borné. Indice : le nombre de canaux S2 croît comme le nombre de paires (j1 < j2) — avec J=2 il y a une paire, prédisez le compte pour J=3 avant d’exécuter, puis confrontez.

Attendu : avec J=3 le compte de paires (j1 < j2) passe de 1 à 3 — prédisez le total de canaux (S0 + S1 + S2) avant d’exécuter, et vérifiez-le sur la sortie. Le coût en ms/image doit rester du même ordre que le J=2 mesuré (la cascade domine, pas le banc).

# Exercice a completer : J=3 avec kymatio sur le meme protocole borne.
# Etape 1 : construire Scattering2D(J=3, shape=(32,32), L=4) et compter les canaux.
# Etape 2 : mesurer ms/image et accuracy (propre / translaté +/-2px).
# Etape 3 : comparer au J=2 de ce notebook -- que coute la 3e echelle ?
print("Exercice a completer")
Exercice a completer

Exercice 2 — Huit orientations

L=8 double la résolution angulaire du banc. Mesurez le triplet (coefficients, ms/image, accuracy propre et translaté) et tranchez : à budget d’échantillon borné, l’angularité achète-t-elle plus que le budget de données ? Indice : comparez votre gain au gain mesuré en §5 en passant de 80 à 400 images/classe — c’est l’unité de compte naturelle (1 point d’accuracy par quel coût).

Attendu : prédisez S1 et S2 séparément avant d’exécuter. S1 = J×L double (L y apparaît une fois) ; S2 = C(J,2)×L² quadruple (les paires d’orientations se multiplient, L y apparaît deux fois) ; l’accuracy, elle, ne suivra ni l’un ni l’autre — c’est tout l’intérêt de la mesure.

# Exercice a completer : L=8 vs L=4, meme protocole.
# Etape 1 : descripteurs kymatio avec L=8 sur le corpus 80/classe.
# Etape 2 : accuracy propre / translaté + ms/image.
# Etape 3 : confronter au delta de la section 5 (budget x5) -- que vaut 1 point d'accuracy ?
print("Exercice a completer")
Exercice a completer

Exercice 3 — Reboucher la permutation

La table de correspondance de la §3 montre que les orientations from-scratch et kymatio sont liées par une permutation (inversion de l’angle, regroupement par échelle). Écrivez la fonction canal_kymatio(j, k) qui donne l’index du canal S1 correspondant, et vérifiez qu’après réalignement la corrélation moyenne par canal augmente nettement par rapport aux corrélations croisées brutes. Indice : la table imprimée par la §3 est la spécification — encodez-la, ne la devinez pas.

Attendu : après réalignement, la corrélation par canal apparié doit se stabiliser autour des valeurs de la diagonale de la table (0,64–0,81) au lieu du mélange croisé — c’est la signature qu’une permutation bien codée supprime le bruit d’appariement.

# Exercice a completer : mapping (j, k) -> index de canal S1 kymatio.
# Etape 1 : lire la table de permutation imprimee par la cellule c-ky.
# Etape 2 : implementer canal_kymatio(j, k) et reordonner les canaux kymatio.
# Etape 3 : recalculer la correlation moyenne par canal apres reordonnancement.
print("Exercice a completer")
Exercice a completer

Résumé

Le même scattering, deux naissances, trois verdicts mesurés. Fidélité : la reprise du moteur WS-00c retrouve le tableau committé (0,825 propre ; translaté à une image près), et les comptes coïncident canal pour canal (1600 coefficients) une fois la permutation d’orientations décodée — la table de corrélation de la §3 est ce décodage, mesuré. Ingénierie : kymatio transforme environ 46× plus vite (corpus batché en un tenseur ; rapport des latences mesuré en §2) avec un pic mémoire Python 3× moindre. Justesse : à ce protocole borné, le from-scratch gagne les deux accuracy (0,825/0,770 contre 0,775/0,645) — ses morlets anisotropes sont plus sélectifs que les isotropes par défaut de kymatio ; SOTA veut dire maintenue et rapide, pas automatiquement meilleure. Et la loi de l’échantillon vérifiée en §5 se résume en une ligne : les données achètent la précision dans la distribution, jamais l’invariance à la distribution. Le tableau final (§6) ferme l’epic : comprendre en Bloc A, produire en Bloc B.

Retour au sommet