Expert Chapitre 23-24 / 12

Couches convolutives et pooling quantiques & Plateaus de Barren

Concevoir les couches convolutives (motif brique) et de pooling d'un QCNN, puis comprendre les plateaus de Barren et comment les éviter — avec du code Qiskit exécutable sur simulateur.

Couches convolutives et pooling quantiques

L’idée en une phrase

Les couches convolutives quantiques appliquent un même unitaire paramétré U_conv(θ) à chaque paire de qubits voisins en motif brique (paires paires puis paires impaires), créant un champ réceptif qui croît avec le nombre de sous-couches ; les couches de pooling réduisent le registre en transférant l’information d’un qubit source vers un qubit cible via une opération contrôlée, avant de tracer le source. Dans la boucle variationnelle hybride, la conception de ces couches détermine directement le compromis expressivité ↔ entraînabilité : un bloc convolutif trop riche (15 paramètres pour une SU(4) générale) expose aux plateaus de Barren, tandis qu’un bloc minimal réduit le risque au prix du pouvoir de représentation.

Analogie : Un tournoi à élimination directe. Au premier tour, chaque paire de joueurs s’affronte (convolution locale) et le vainqueur passe au tour suivant (pooling). À chaque tour, le nombre de participants est divisé par deux et l’information pertinente se concentre. Le motif brique revient à faire jouer d’abord les paires 1–2, 3–4, puis les paires 2–3, 4–5 — chaque joueur rencontre ainsi ses deux voisins avant l’élimination.

Points clés

  • Le motif brique (brick-wall pattern) consiste à alterner deux sous-couches : la première applique U_conv(θ) aux paires (0,1), (2,3), … (paires), la seconde aux paires (1,2), (3,4), … (impaires). Après les deux sous-couches, le champ réceptif de chaque qubit s’étend à ses seconds voisins. Sans cette alternance, les qubits pairs et impairs restent dans des blocs déconnectés.
  • Un bloc convolutif à 2 qubits général vit dans SU(4) et nécessite 15 paramètres réels. En pratique, on restreint l’ansatz à un sous-ensemble structuré — typiquement Rz ⊗ Rz → CNOT → Ry ⊗ Ry → CNOT (4 à 6 paramètres) — pour limiter le risque de plateaus de Barren tout en capturant les corrélations locales essentielles.
  • Le pooling par rotation contrôlée (chapitre 21-22) applique une CRz(φ) du qubit source vers le cible, puis trace le source. Alternative : le pooling par mesure mesure directement le qubit source dans la base Z et applique une rotation conditionnelle sur le cible — plus proche du pooling classique, mais introduit de la stochasticité dans le circuit.
  • Le partage de poids (même θ pour tous les blocs convolutifs d’un même étage) réduit les paramètres indépendants de O(n) à O(1) par étage. Sur O(log n) étages, le total reste O(log n) — une propriété décisive face aux plateaus de Barren.
  • La profondeur totale du QCNN est O(log n) — contrairement aux ansatz denses de profondeur O(poly(n)) — ce qui le rend compatible avec les contraintes de cohérence des processeurs NISQ actuels.

Exemple concret

Un QCNN à 8 qubits avec motif brique. Étage 1 — convolution : sous-couche A applique U(θ₁) aux paires (0,1), (2,3), (4,5), (6,7) ; sous-couche B applique le même U(θ₁) aux paires (1,2), (3,4), (5,6). Après ces deux sous-couches, le qubit 3 a interagi avec les qubits 2 et 4 — son champ réceptif couvre 3 qubits. Puis pooling : les qubits pairs (0, 2, 4, 6) sont tracés, 4 qubits actifs restent. Étage 2 : convolution brique sur (1,3), (5,7), (3,5), puis pooling → 2 qubits actifs. Étage 3 : convolution et pooling → 1 qubit. Total : 3 jeux de paramètres convolutifs + 3 jeux de paramètres de pooling — un nombre constant, indépendant de n.

Convolution simple vs motif brique

AspectPaires simplesMotif brique
Sous-couches par étage12 (paires + impaires)
Champ réceptif (1 étage)2 qubits (voisin direct)3 qubits (2ᵉ voisin)
Portes à 2 qubits par étagen/2n − 1
Profondeur supplémentaire1 couche2 couches
Corrélations captéesLocales strictesLocales étendues

Code Python — QCNN avec motif brique et pooling

# QCNN avec motif brique (brick-wall) sur 8 qubits.
# Motif brique : paires paires, puis paires impaires → champ réceptif élargi.
import numpy as np
from qiskit import QuantumCircuit
from qiskit.quantum_info import Statevector

def conv_brick(qc, n_qubits, params):
    """Convolution en motif brique.
    Sous-couche A : paires (0,1), (2,3), …
    Sous-couche B : paires (1,2), (3,4), …
    Même paramètres partout = partage de poids."""
    for q in range(0, n_qubits - 1, 2):  # Sous-couche A
        qc.rz(params[0], q)
        qc.rz(params[1], q + 1)
        qc.cx(q, q + 1)
        qc.ry(params[2], q)
        qc.ry(params[3], q + 1)
        qc.cx(q + 1, q)
    for q in range(1, n_qubits - 1, 2):  # Sous-couche B
        qc.rz(params[0], q)
        qc.rz(params[1], q + 1)
        qc.cx(q, q + 1)
        qc.ry(params[2], q)
        qc.ry(params[3], q + 1)
        qc.cx(q + 1, q)

def pool_layer(qc, sources, targets, param):
    """Pooling par rotation contrôlée : CRz du source vers le target.
    Le qubit source est ensuite considéré comme tracé."""
    for s, t in zip(sources, targets):
        qc.crz(param, s, t)

# Construction du QCNN complet
qc = QuantumCircuit(8)

# Encodage (angle encoding)
data = [0.5, 1.0, 1.5, 2.0, 0.8, 1.3, 1.7, 0.3]
for i, x in enumerate(data):
    qc.ry(x, i)

# Étage 1 — convolution brique + pooling (8 → 4 qubits actifs)
conv_brick(qc, 8, [0.3, 0.5, 0.7, 0.1])
pool_layer(qc, [0, 2, 4, 6], [1, 3, 5, 7], 0.4)

# Étage 2 — convolution sur qubits actifs (1, 3, 5, 7) → 2 actifs
for q0, q1 in [(1, 3), (5, 7), (3, 5)]:
    qc.rz(0.2, q0); qc.rz(0.8, q1)
    qc.cx(q0, q1)
    qc.ry(0.6, q0); qc.ry(0.4, q1)
pool_layer(qc, [1, 5], [3, 7], 0.5)

# Étage 3 — convolution (3, 7) → 1 qubit actif
qc.rz(0.1, 3); qc.rz(0.9, 7)
qc.cx(3, 7)
qc.ry(0.3, 3); qc.ry(0.7, 7)
pool_layer(qc, [3], [7], 0.6)

# Classification : P(qubit 7 = |1⟩)
sv = Statevector(qc)
probs = sv.probabilities()
# Qubit 7 = bit de poids fort (little-endian Qiskit : indices >= 128)
p_class1 = sum(probs[128:])
print(f"P(classe 1) = {p_class1:.4f}")
print(f"Profondeur du circuit : {qc.depth()}")
print(f"Paramètres : 4 + 4 + 4 + 1 + 1 + 1 = 15 (vs ansatz dense)")

Piège courant : « Le motif brique double la profondeur sans apport » est une idée reçue. Sans l’alternance paires/impaires, les qubits 0 et 2 n’interagissent jamais au même étage — le circuit produit des blocs déconnectés. Le motif brique crée un chemin de corrélation entre tous les qubits d’un étage, condition nécessaire pour capturer des motifs non strictement locaux. Le surcoût en profondeur est compensé par un champ réceptif qui passe de 2 à 3 qubits par étage.


Plateaus de Barren

L’idée en une phrase

Un plateau de Barren est un phénomène où la variance du gradient de la fonction de coût décroît exponentiellement avec le nombre de qubits nVar[∂C/∂θ_k] ∈ O(1/2ⁿ) — rendant l’optimisation classique impossible en pratique : le gradient est noyé dans le bruit statistique des mesures. Dans la boucle variationnelle hybride, c’est l’optimiseur classique qui en souffre : le QPU évalue le circuit correctement, mais l’information renvoyée (gradient ≈ 0 ± bruit) ne permet plus de déterminer la direction de descente.

Analogie : Un randonneur cherche le point le plus bas d’un désert de sel. Localement, le sol est parfaitement plat dans toutes les directions — la pente est si faible qu’elle est indiscernable des irrégularités du terrain. Plus le désert est vaste (plus de qubits), plus la pente s’aplatit et plus la marche aléatoire remplace la descente de gradient. Le randonneur a besoin d’un guide local — une structure dans le circuit, un coût local — pour retrouver une pente perceptible.

Points clés

  • Résultat de McClean et al. (2018) : pour un ansatz paramétré dont la distribution des paramètres forme un 2-design (distribution approximativement uniforme sur le groupe unitaire), la variance du gradient satisfait Var[∂C/∂θ_k] ∈ O(1/2ⁿ). Avec n = 20 qubits, la variance est de l’ordre de 10⁻⁶ — le gradient est indétectable avec un nombre raisonnable de mesures.
  • Deux facteurs principaux déclenchent les plateaus de Barren de manière indépendante : la profondeur du circuit (un circuit suffisamment profond forme un 2-design approximatif, quel que soit l’ansatz) et la globalité de la fonction de coût (opérateur agissant sur tous les qubits simultanément, comme Z⊗Z⊗…⊗Z).
  • Coût local vs global (Cerezo et al., 2021) : si la fonction de coût ne dépend que d’un sous-ensemble constant de qubits (coût local), la variance du gradient décroît polynomialementO(1/poly(n)) — pour des circuits peu profonds. Cela suffit pour maintenir l’optimisation classique. Passer d’un coût global à un coût local est une stratégie d’atténuation directe.
  • Plateaus induits par le bruit (Wang et al., 2021) : même avec un ansatz structuré, le bruit matériel (dépolarisation, amortissement) aplatit le paysage de coût en poussant l’état vers l’état maximalement mixte. La variance du gradient décroît comme O(exp(-α·n)) avec α proportionnel au taux d’erreur par porte — un obstacle supplémentaire en régime NISQ.
  • Stratégies d’atténuation : ansatz structuré avec partage de poids (QCNN, chapitre 21-22), entraînement couche par couche (optimiser de la sortie vers l’entrée), coûts locaux, initialisation informée par les données, et circuits peu profonds à expressivité contrôlée. Le QCNN combine plusieurs de ces stratégies.

Exemple concret

Un ansatz dense de profondeur 2 sur n qubits, avec un coût global Z⊗…⊗Z. Pour n = 4, la variance de ∂C/∂θ₀ est d’environ 0.02. Pour n = 8, elle tombe à environ 4 × 10⁻⁴. Pour n = 12, elle atteint l’ordre de 10⁻⁵. Le gradient typique est de l’ordre de √Var ≈ 3 × 10⁻³ pour 12 qubits — il faudrait environ 10⁵ mesures (shots) pour le distinguer du bruit statistique. En comparaison, un QCNN sur les mêmes 12 qubits, avec partage de poids et coût local (Z sur le qubit final), maintient une variance de l’ordre de 0.01 — le gradient reste exploitable avec ~100 mesures.

Variance du gradient — ansatz dense vs QCNN

Qubits (n)Var dense (coût global)Var QCNN (coût local)Ratio
4~0.02~0.050.4
8~4 × 10⁻⁴~0.020.02
12~10⁻⁵~0.0110⁻³
16~10⁻⁷~0.0052 × 10⁻⁵

Code Python — détection numérique d’un plateau de Barren

# Plateau de Barren : la variance de ∂C/∂θ₀ décroît exponentiellement
# avec n pour un ansatz dense + coût global Z⊗…⊗Z.
import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector
from qiskit.quantum_info import Statevector, SparsePauliOp

def build_dense_ansatz(n_qubits, depth=2):
    """Ansatz dense : couches Ry + échelle de CNOT."""
    params = ParameterVector('θ', n_qubits * depth)
    qc = QuantumCircuit(n_qubits)
    idx = 0
    for _ in range(depth):
        for q in range(n_qubits):
            qc.ry(params[idx], q)
            idx += 1
        for q in range(n_qubits - 1):
            qc.cx(q, q + 1)
    return qc, params

def cost_fn(qc, params, values, obs):
    """Coût : ⟨ψ(θ)|O|ψ(θ)⟩."""
    bound = qc.assign_parameters(dict(zip(params, values)))
    return Statevector(bound).expectation_value(obs).real

def estimate_gradient_variance(n_qubits, n_samples=100):
    """Var[∂C/∂θ₀] par parameter shift, coût global Z⊗…⊗Z."""
    qc, params = build_dense_ansatz(n_qubits)
    obs = SparsePauliOp('Z' * n_qubits)
    grads = []
    for _ in range(n_samples):
        vals = np.random.uniform(0, 2 * np.pi, len(params))
        v_plus = vals.copy(); v_plus[0] += np.pi / 2
        v_minus = vals.copy(); v_minus[0] -= np.pi / 2
        g = (cost_fn(qc, params, v_plus, obs)
             - cost_fn(qc, params, v_minus, obs)) / 2
        grads.append(g)
    return np.var(grads)

# Observer le déclin exponentiel de la variance
np.random.seed(42)
print("n qubits | Var[∂C/∂θ₀]")
print("---------|-------------")
for n in [2, 4, 6, 8]:
    var = estimate_gradient_variance(n)
    print(f"       {n} | {var:.6f}")

Piège courant : « Les plateaus de Barren n’apparaissent que pour des circuits très profonds » est inexact. Un circuit peut exhiber un plateau même à profondeur constante si le nombre de qubits est grand et que la fonction de coût est globale (Cerezo et al., 2021). La profondeur du circuit et la globalité du coût sont deux déclencheurs indépendants. Un circuit peu profond avec un coût local peut rester entraînable ; le même circuit avec un coût global souffrira de plateaus dès que n est suffisamment grand.


Fil rouge — la frontière quantique/classique

Ce chapitre cristallise le compromis central du QML NISQ. Côté QPU, les couches convolutives en motif brique et le pooling quantique structurent l’ansatz pour capturer les corrélations locales avec un minimum de paramètres — O(log n) grâce au partage de poids. Côté classique, l’optimiseur ne peut fonctionner que si le gradient porte un signal exploitable : les plateaus de Barren montrent qu’un ansatz trop expressif noie le signal dans un paysage plat. Le QCNN est une réponse directe : en limitant les degrés de liberté et en structurant le circuit, il maintient la variance du gradient à un niveau polynomial en n, permettant à l’optimiseur classique de naviguer le paysage de coût.


Quiz — teste tes connaissances
Expert 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.

Kata Qiskit — détection d’un plateau de Barren

Objectif : implémenter un estimateur de variance du gradient par parameter shift sur un ansatz dense, et observer la décroissance exponentielle caractéristique d’un plateau de Barren.

Squelette :

# kata_day_23_24.py
import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector
from qiskit.quantum_info import Statevector, SparsePauliOp

def build_ansatz(n_qubits, depth=2):
    """Construire un ansatz dense paramétré.
    
    Architecture : pour chaque couche (depth fois),
    appliquer Ry(θ_i) sur chaque qubit, puis une échelle
    de CNOT (qubit q vers qubit q+1, pour q de 0 à n-2).
    
    Returns:
        (QuantumCircuit, ParameterVector)
    """
    n_params = n_qubits * depth
    params = ParameterVector('θ', n_params)
    qc = QuantumCircuit(n_qubits)
    # TODO 1 : construire l'ansatz dense (Ry + échelle CNOT)
    # Pour chaque couche : Ry(params[idx]) sur chaque qubit,
    # puis CNOT(q, q+1) pour q de 0 à n-2.
    return qc, params

def compute_gradient(qc, params, values, obs, param_index=0):
    """Calculer ∂C/∂θ_{param_index} par parameter shift.
    
    Formule : (C(θ + π/2·e_k) − C(θ − π/2·e_k)) / 2
    où C(θ) = ⟨ψ(θ)|obs|ψ(θ)⟩.
    
    Args:
        qc: circuit paramétré
        params: ParameterVector
        values: array de valeurs numériques
        obs: SparsePauliOp (observable)
        param_index: indice du paramètre à dériver
    Returns:
        float : gradient estimé
    """
    # TODO 2 : implémenter le parameter shift rule
    # 1. Copier values, ajouter π/2 à l'indice param_index
    # 2. Copier values, soustraire π/2 à l'indice param_index
    # 3. Évaluer le coût pour chaque jeu (assign_parameters + Statevector)
    # 4. Retourner (C_plus - C_minus) / 2
    return 0.0

def gradient_variance(n_qubits, n_samples=100):
    """Estimer Var[∂C/∂θ₀] sur n_samples tirages aléatoires.
    
    Observable : coût global Z⊗Z⊗…⊗Z.
    Pour chaque échantillon, tirer des paramètres dans [0, 2π)
    et calculer le gradient ∂C/∂θ₀.
    Retourner la variance des gradients collectés.
    """
    qc, params = build_ansatz(n_qubits)
    obs = SparsePauliOp('Z' * n_qubits)
    # TODO 3 : collecter n_samples gradients et retourner np.var(grads)
    return 0.0

Auto-correction :

# test_kata_day_23_24.py
import numpy as np
from kata_day_23_24 import build_ansatz, compute_gradient, gradient_variance
from qiskit.quantum_info import Statevector, SparsePauliOp

# Test 1 : l'ansatz a le bon nombre de paramètres et de qubits
qc, params = build_ansatz(4, depth=2)
assert len(params) == 8, f"4 qubits x 2 couches = 8 params, trouvé {len(params)}"
assert qc.num_qubits == 4, "Le circuit doit avoir 4 qubits"

# Test 2 : le circuit contient des portes Ry et CX
ops = [instr.operation.name for instr in qc.data]
assert 'ry' in ops, "L'ansatz doit contenir des portes Ry"
assert 'cx' in ops, "L'ansatz doit contenir des portes CNOT"

# Test 3 : le gradient est un nombre réel
obs4 = SparsePauliOp('ZZZZ')
vals = np.array([1.0, 0.5, 0.3, 0.7, 0.2, 0.8, 0.4, 0.6])
g = compute_gradient(qc, params, vals, obs4, param_index=0)
assert isinstance(g, (float, np.floating)), f"Le gradient doit être un float, trouvé {type(g)}"

# Test 4 : parameter shift concorde avec la dérivée numérique
eps = 1e-5
v_plus_eps = vals.copy(); v_plus_eps[0] += eps
v_minus_eps = vals.copy(); v_minus_eps[0] -= eps
def _cost(vals):
    bound = qc.assign_parameters(dict(zip(params, vals)))
    return Statevector(bound).expectation_value(obs4).real
g_num = (_cost(v_plus_eps) - _cost(v_minus_eps)) / (2 * eps)
assert abs(g - g_num) < 0.01, f"Parameter shift ({g:.6f}) != dérivée numérique ({g_num:.6f})"

# Test 5 : la variance est positive et raisonnable
np.random.seed(123)
var = gradient_variance(4, n_samples=50)
assert var >= 0, "La variance ne peut pas être négative"
assert var < 1, f"Variance anormalement élevée : {var}"
assert var > 1e-10, "La variance ne devrait pas être quasi nulle sur 50 échantillons"

print("Kata validé !")
Solution et explication
import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector
from qiskit.quantum_info import Statevector, SparsePauliOp

def build_ansatz(n_qubits, depth=2):
    n_params = n_qubits * depth
    params = ParameterVector('θ', n_params)
    qc = QuantumCircuit(n_qubits)
    idx = 0
    for _ in range(depth):                          # TODO 1
        for q in range(n_qubits):
            qc.ry(params[idx], q)
            idx += 1
        for q in range(n_qubits - 1):
            qc.cx(q, q + 1)
    return qc, params

def compute_gradient(qc, params, values, obs, param_index=0):
    v_plus = values.copy()                           # TODO 2
    v_plus[param_index] += np.pi / 2
    v_minus = values.copy()
    v_minus[param_index] -= np.pi / 2

    def cost(vals):
        bound = qc.assign_parameters(dict(zip(params, vals)))
        return Statevector(bound).expectation_value(obs).real

    return (cost(v_plus) - cost(v_minus)) / 2

def gradient_variance(n_qubits, n_samples=100):
    qc, params = build_ansatz(n_qubits)              # TODO 3
    obs = SparsePauliOp('Z' * n_qubits)
    grads = []
    for _ in range(n_samples):
        vals = np.random.uniform(0, 2 * np.pi, len(params))
        g = compute_gradient(qc, params, vals, obs)
        grads.append(g)
    return float(np.var(grads))

Pourquoi : l’ansatz dense (Ry + échelle CNOT) crée un circuit fortement intriqué dont la distribution des unitaires approche un 2-design quand la profondeur augmente. Le parameter shift rule exploite la propriété des rotations de Pauli : ∂⟨O⟩/∂θ_k = (⟨O⟩_{θ_k+π/2} − ⟨O⟩_{θ_k−π/2}) / 2. En tirant les paramètres au hasard et en calculant ce gradient pour chaque tirage, on estime sa variance. Le résultat de McClean et al. prédit que cette variance décroît comme O(1/2ⁿ) pour un coût global — c’est le plateau de Barren. Le QCNN, grâce au partage de poids et au coût local, échappe à ce déclin exponentiel.