## Filtrage Spatial Appris

Pluttôt que de regarder chaque pixel d'une image tout seul, un CNN utilise une petite loupe qui glisse sur toute l'image et détecte des motifs locaux (bords, textures) — comme un cartographe qui parcourt le terrain case par case pour dresser une carte des reliefs.

Le passage du traitement de données tabulaires aux grilles de pixels 2D a imposé un saut paradigmatique majeur dans l'histoire du deep learning. Un MLP appliqué à une image traite chaque pixel comme une dimension indépendante, ignorant totalement la structure spatiale intrinsèque : deux pixels voisins ne sont pas distingués de deux pixels aux antipodes. Cette approche est non seulement inefficace computationnellement, mais fondamentalement inadaptée à la nature des données visuelles [@CS231n2026; @WikipediaCNN2026; @BINUS2017].

::: {.card .card-window .mb-4}

:::: {.card-header}
⚖️ MLP vs CNN
::::

:::: {.card-control-row .sim-controls-row}
```{ojs}
//| echo: false
import { createDimensionalityComparison } from "../../assets/js/simulations/convolution.js"
viewof res_idx = aptitek.createSlider({
  label: "Résolution d'Entrée",
  labels: ["28x28 (MNIST)", "250x250 (Standard)", "1920x1080 (Full HD)", "3840x2160 (4K UHD)"],
  value: 1,
  min: 0,
  max: 3,
  step: 1
})
```
::::

```{ojs}
//| echo: false
dim_comparison = createDimensionalityComparison(res_idx)
```

:::

### Le Principe de Responsabilité Unique Spatiale

Chaque neurone ne regarde qu'une petite fenêtre de l'image (son champ récepteur) — et le même filtre est réutilisé partout, comme le même coup de tampon appliqué sur toute la surface d'une feuille.

La solution réside dans l'application du **Principe de Responsabilité Unique** (SRP) à l'extraction de caractéristiques : chaque neurone n'est responsable que d'une région locale de l'image, son **champ récepteur** (*receptive field*). Cette contrainte architecturale repose sur deux piliers fondamentaux [@CS231n2026; @ParadigmShift2026] :

* **Connectivité locale :** Chaque neurone de la couche convolutionnelle n'est connecté qu'à une fenêtre spatiale de taille $F \times F$ dans la couche précédente, et non à l'ensemble de l'entrée. Cette localité exploite le fait que les corrélations entre pixels sont fortes à courte distance et faibles à longue distance.

::: {.card .card-window .mb-4 .local-filter-card}
:::: {.card-header}
🔍 Champ Récepteur
::::

:::: {.tab-actions}

* [Lancer]{#btn-local-filter-play .bi-play-fill .btn .btn-outline-primary .btn-sm}
* [Pause]{#btn-local-filter-pause .bi-pause-fill .btn .btn-outline-secondary .btn-sm}
* [Reset]{#btn-local-filter-reset .bi-arrow-counterclockwise .btn .btn-outline-danger .btn-sm}
::::

:::: {.card-body}
::::: {.row .align-items-center}
:::::: {.col-md-6}
::::::: {#plot-local-filter-network .plot-wrapper}
:::::::
::::::
:::::: {.col-md-6 .d-flex .justify-content-center .flex-column .align-items-center .py-4}
::::::: {.viz-local-filter-container .mb-3}
:::::::: {.viz-local-filter-window .pos-0}
::::::::
:::::::: {.viz-local-filter-cell .is-active}
::::::::
:::::::: {.viz-local-filter-cell .is-active}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell .is-active}
::::::::
:::::::: {.viz-local-filter-cell .is-active}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::: {.viz-local-filter-cell}
::::::::
:::::::
::::::: {.text-muted .fst-italic .text-center .description-box}
Étape 1 : Le filtre analyse la région en haut à gauche (neurone h₀,₀).
:::::::
::::::
:::::
::::
:::

```{ojs}
//| echo: false
renderLocalFilterNetwork = {
  // 1. Configuration of states
  const states = [
    { index: 0, description: "Étape 1 : Le filtre analyse la région en haut à gauche (neurone h₀,₀)." },
    { index: 1, description: "Étape 2 : Le filtre glisse vers le haut à droite (neurone h₀,₁)." },
    { index: 2, description: "Étape 3 : Le filtre glisse vers le bas à droite (neurone h₁,₁)." },
    { index: 3, description: "Étape 4 : Le filtre glisse vers le bas à gauche (neurone h₁,₀)." }
  ];

  if (!window.localFilterSimState) {
    window.localFilterSimState = states[0];
  }

  // 2. Instantiate StateMachine
  const sm = new aptitek.StateMachine({
    states: states,
    interval: 1800,
    loop: true,
    onStateChange: (state, index) => {
      window.localFilterSimState = state;

      // Update grid window position
      const windowEl = document.querySelector(".viz-local-filter-window");
      if (windowEl) {
        windowEl.className = "viz-local-filter-window pos-" + index;
      }

      // Highlight active grid cells
      const cells = document.querySelectorAll(".viz-local-filter-cell");
      const activeIndices = [
        [0, 1, 4, 5],     // Step 0
        [2, 3, 6, 7],     // Step 1
        [10, 11, 14, 15], // Step 2
        [8, 9, 12, 13]    // Step 3
      ][index];

      if (cells.length > 0) {
        cells.forEach((cell, i) => {
          cell.classList.toggle("is-active", activeIndices.includes(i));
        });
      }

      // Refresh graph highlight styles
      if (graph && typeof graph.refresh === "function") {
        graph.refresh();
      }
    }
  });

  const controller = new aptitek.SimulationController(sm, {
    play: "#btn-local-filter-play",
    pause: "#btn-local-filter-pause",
    reset: "#btn-local-filter-reset",
    description: ".local-filter-card .description-box"
  });

  // 3. Define a 4x4 input grid on the left
  const nodes = [];
  for (let r = 0; r < 4; r++) {
    for (let c = 0; c < 4; c++) {
      nodes.push({
        id: `p_${r}_${c}`,
        label: "", // empty to represent pixels
        shape: "square",
        nodeRadius: 10,
        fx: -130 + c * 26,
        fy: -39 + r * 26,
        status: () => {
          const index = window.localFilterSimState?.index ?? 0;
          const activeIndices = [
            [0, 1, 4, 5],     // Step 0
            [2, 3, 6, 7],     // Step 1
            [10, 11, 14, 15], // Step 2
            [8, 9, 12, 13]    // Step 3
          ][index];
          const flatIdx = r * 4 + c;
          return activeIndices.includes(flatIdx) ? "activePixel" : "default";
        }
      });
    }
  }

  // Define 4 output/hidden layer neurons on the right
  const hiddenLabels = [
    { id: "h_0_0", label: "h₀,₀", fx: 75, fy: -25 },
    { id: "h_0_1", label: "h₀,₁", fx: 125, fy: -25 },
    { id: "h_1_1", label: "h₁,₁", fx: 125, fy: 25 },
    { id: "h_1_0", label: "h₁,₀", fx: 75, fy: 25 }
  ];

  hiddenLabels.forEach(n => {
    nodes.push({
      id: n.id,
      label: n.label,
      shape: "circle",
      nodeRadius: 16,
      fx: n.fx,
      fy: n.fy,
      status: () => {
        const index = window.localFilterSimState?.index ?? 0;
        const activeIds = ["h_0_0", "h_0_1", "h_1_1", "h_1_0"];
        return activeIds[index] === n.id ? "activeNeuron" : "default";
      }
    });
  });

  // Connections showing receptive fields
  const links = [];

  // Receptive fields mapping
  const rf = {
    h_0_0: [[0,0], [0,1], [1,0], [1,1]],
    h_0_1: [[0,2], [0,3], [1,2], [1,3]],
    h_1_1: [[2,2], [2,3], [3,2], [3,3]],
    h_1_0: [[2,0], [2,1], [3,0], [3,1]]
  };

  Object.entries(rf).forEach(([hid, coords]) => {
    coords.forEach(([r, c]) => {
      links.push({
        source: `p_${r}_${c}`,
        target: hid,
        status: () => {
          const index = window.localFilterSimState?.index ?? 0;
          const activeIds = ["h_0_0", "h_0_1", "h_1_1", "h_1_0"];
          return (activeIds[index] === hid) ? "activeConnection" : "default";
        }
      });
    });
  });

  const graph = aptitek.createGraph("#plot-local-filter-network", { nodes, links }, {
    nodeRadius: 16, fontSize: 8, height: 260,
    enableZoom: false, enablePan: false, enableDrag: false,
    zoomToFit: true, zoomToFitPadding: 30,
    cooldownTicks: Infinity,
    styles: {
      activePixel:      { nodeBg: "rgba(var(--sol-cyan-rgb), 0.15)",    nodeBorder: "var(--sol-cyan)" },
      activeNeuron:     { nodeBg: "rgba(var(--sol-cyan-rgb), 0.2)",     nodeBorder: "var(--sol-cyan)", nodeText: "var(--sol-cyan)" },
      activeConnection: { linkStroke: "var(--sol-cyan)", particles: 2, particleColor: "var(--sol-cyan)", particleSpeed: 0.03 }
    }
  });

  invalidation.then(() => {
    controller.destroy();
    if (graph && typeof graph.destroy === "function") {
      graph.destroy();
    }
  });

  return graph;
}
```

* **Partage de poids :** Un même filtre (*kernel*) $\mathbf{k} \in \mathbb{R}^{F \times F}$ est appliqué à chaque position spatiale de l'entrée. Ce partage impose une propriété fondamentale : **l'équivariance par translation**. Si un motif (un bord, une texture) se déplace dans l'image, sa réponse dans la carte de caractéristiques se déplace de la même quantité, sans nécessiter de nouveaux paramètres.

En traitement du signal classique, les filtres de Sobel ou Gaussien sont définis à la main pour détecter des bords ou lisser l'image. Le CNN **apprend** ses propres noyaux directement depuis les données, optimisant automatiquement l'équilibre entre lissage et différentiation pour la tâche cible.

### L'opération de convolution et ses dimensions

La convolution transforme un volume 3D (largeur × hauteur × canaux) en un autre volume, dont la taille dépend de trois réglages : la taille de la loupe ($F$), son pas de déplacement ($S$) et le rembourrage des bords ($P$).

L'opération de convolution transforme un volume d'entrée $W_1 \times H_1 \times D_1$ (largeur × hauteur × profondeur/canaux) en un volume de sortie $W_2 \times H_2 \times D_2$ via $K$ filtres de taille $F \times F$ [@CS231n2026] :

$$
W_2 = \frac{W_1 - F + 2P}{S} + 1 \qquad H_2 = \frac{H_1 - F + 2P}{S} + 1 \qquad D_2 = K
$$

* **$W_1, H_1$** : largeur et hauteur du volume d'entrée.
* **$W_2, H_2$** : largeur et hauteur du volume de sortie.
* **$F$** : taille spatiale du filtre (*kernel*).
* **$P$** : padding (largeur de la bordure de zéros ajoutée aux limites de l'image).
* **$S$** : stride (le pas de déplacement du filtre sur l'image).
* **$K$** : le nombre de filtres appliqués (qui donne la profondeur de la sortie $D_2$).

Chaque filtre produit une **carte de caractéristiques** (*feature map*) de taille $W_2 \times H_2$.

### Optimisation GPU

Pour que le GPU puisse traiter des milliers d'images en parallèle, on "déroule" les patches de l'image en une grande matrice, afin de remplacer la convolution par une simple multiplication de matrices — opération que les GPU font extrêmement vite.

Pour maximiser le débit de calcul sur GPU, les frameworks de deep learning reformulent la convolution en **multiplication de matrices généralisée** (GEMM) via l'algorithme `im2col` [@CS231n2026] :

1. Chaque bloc spatial $F \times F$ de l'entrée est déroulé en un vecteur colonne → matrice $\mathbf{X}_{\text{col}} \in \mathbb{R}^{(F^2 D_1) \times (W_2 H_2)}$ (où $\in$ désigne l'appartenance à l'espace des matrices de nombres réels de dimensions correspondantes, [voir le Glossaire](../../glossaire.qmd#symboles-mathématiques-notations)).
2. Les $K$ filtres sont déroulés en vecteurs lignes → matrice $\mathbf{W}_{\text{row}} \in \mathbb{R}^{K \times (F^2 D_1)}$.
3. La convolution entière devient : $\mathbf{Y}_{\text{col}} = \mathbf{W}_{\text{row}} \mathbf{X}_{\text{col}}$

Cette reformulation sacrifie de la mémoire (duplication des pixels dans $\mathbf{X}_{\text{col}}$) pour exploiter les routines d'algèbre linéaire hautement optimisées (cuBLAS, etc.) qui atteignent des dizaines de TFLOPS sur GPU modernes.

### Hiérarchie des représentations

Au fur et à mesure qu'on s'enfonce dans les couches, le réseau voit de moins en moins de détails mais de plus en plus de sens : des bords au début, puis des textures, puis des formes, puis des objets entiers — comme passer d'une carte topographique à une carte routierre à une carte des villes.

L'empilement de couches convolutionnelles crée une hiérarchie progressive d'abstraction : les premières couches extraient des primitives élémentaires (bords, gradients de couleur), les couches intermédiaires combinent ces primitives en textures et formes, les couches profondes assemblent ces formes en concepts sémantiques (visages, roues, etc.). C'est cette hiérarchie automatique qui distingue fondamentalement le CNN du MLP et explique ses performances remarquables en vision par ordinateur.
