Intermédiaire Chapitre 13-14 / 7

Mesurer et décider (Sampler vs Estimator) & Entraîner un circuit paramétré

Comprendre les deux primitives de mesure de Qiskit et boucler l'entraînement d'un circuit paramétré — avec du code exécutable sur simulateur.

Mesurer et décider : Sampler vs Estimator

L’idée en une phrase

La mesure ferme la partie quantique de la boucle variationnelle hybride : le Sampler exécute le circuit et renvoie des bitstrings échantillonnés, tandis que l’Estimator calcule directement la valeur moyenne d’un observable — deux façons complémentaires de convertir un état quantique en information exploitable par l’optimiseur classique.

Analogie : Un sondage électoral et un thermomètre illustrent la distinction. Le sondeur (Sampler) interroge N personnes et collecte des votes discrets — il en déduit une distribution de résultats. Le thermomètre (Estimator) donne directement une température moyenne sans détailler l’agitation de chaque molécule. Les deux informent sur l’état du système, mais le sondage fournit une distribution complète là où le thermomètre renvoie un scalaire.

Points clés

  • Le Sampler prend un circuit avec mesures et renvoie des comptages de bitstrings (ou des quasi-probabilités). Le circuit doit contenir des instructions measure — sans elles, il n’y a rien à échantillonner.
  • L’Estimator prend un circuit sans mesures et un observable (opérateur hermitien, typiquement un produit de Pauli), et renvoie la valeur moyenne. C’est la primitive naturelle pour évaluer une fonction de coût continue.
  • En Qiskit 1.x, les primitives modernes sont StatevectorSampler et StatevectorEstimator (module qiskit.primitives). Les anciennes classes V1 (Sampler, Estimator) sont dépréciées.
  • Pour la classification binaire, le Sampler permet de décoder un label depuis les bitstrings (bit majoritaire, par exemple). Pour une fonction de coût continue (VQE, régression), l’Estimator est préférable car il fournit directement un scalaire différentiable.
  • Sur matériel réel, le Sampler est soumis au bruit d’échantillonnage (variance en 1/√shots). L’Estimator sur simulateur statevector est exact ; sur matériel réel, il hérite du même bruit.

Exemple concret

On prépare un circuit à 1 qubit avec Ry(θ) pour θ = 1.0. L’état résultant est cos(0.5)|0⟩ + sin(0.5)|1⟩. Avec le Sampler (1 000 shots), on obtient environ 770 mesures "0" et 230 mesures "1" — la distribution brute. Avec l’Estimator et l’observable Z, on obtient directement ⟨Z⟩ = cos²(0.5) − sin²(0.5) = cos(1.0) ≈ 0.5403. Le Sampler donne la même information, mais il faut la recalculer : ⟨Z⟩ ≈ 770/1000 − 230/1000 = 0.54. L’Estimator est plus direct et, sur simulateur exact, exempt de variance d’échantillonnage.

Comparaison Sampler vs Estimator

CritèreSamplerEstimator
EntréeCircuit + mesuresCircuit (sans mesures) + observable
SortieComptages / quasi-probabilitésValeur moyenne
Cas d’usage typiqueClassification (label depuis bitstrings)Fonction de coût continue (VQE, régression)
Variance (matériel)1/√shots1/√shots
Classe Qiskit 1.xStatevectorSamplerStatevectorEstimator

Code Python — Sampler : obtenir des bitstrings

from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler

# Circuit avec mesure — obligatoire pour le Sampler
qc = QuantumCircuit(1)
qc.ry(1.0, 0)
qc.measure_all()

sampler = StatevectorSampler(seed=42)
result = sampler.run([qc], shots=1000).result()
counts = result[0].data.meas.get_counts()
print("Comptages :", counts)

# Approximation de ⟨Z⟩ depuis les comptages
p0 = counts.get("0", 0) / 1000
p1 = counts.get("1", 0) / 1000
print(f"⟨Z⟩ ≈ {p0 - p1:.4f}")

Code Python — Estimator : obtenir une valeur moyenne

from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp

# Circuit SANS mesure — l'Estimator n'en a pas besoin
qc = QuantumCircuit(1)
qc.ry(1.0, 0)

observable = SparsePauliOp.from_list([("Z", 1)])
estimator = StatevectorEstimator()
result = estimator.run([(qc, observable)]).result()
expval = float(result[0].data.evs)
print(f"⟨Z⟩ = {expval:.4f}")   # cos(1.0) ≈ 0.5403

Piège courant : « Le Sampler et l’Estimator font la même chose, l’un est simplement plus récent » est inexact. Ils répondent à des questions différentes : le Sampler renvoie une distribution discrète (utile pour décoder un label de classification), l’Estimator renvoie un scalaire continu (utile comme fonction de coût). Utiliser le Sampler pour évaluer une cost function continue oblige à recalculer la moyenne manuellement et introduit de la variance inutile.


Entraîner un circuit paramétré

L’idée en une phrase

L’entraînement boucle la boucle variationnelle hybride : l’optimiseur classique ajuste les paramètres θ de l’ansatz pour minimiser une fonction de coût mesurée par l’Estimator (ou le Sampler), en répétant le cycle évaluer → calculer le gradient → mettre à jour θ jusqu’à convergence.

Analogie : Un alpiniste descendant un vallon dans le brouillard ne voit pas le fond. Pour estimer la pente, il fait deux pas d’essai — un légèrement en avant, un légèrement en arrière — et compare les altitudes. C’est la règle du parameter-shift : évaluer le circuit à θ + π/2 et θ − π/2. SPSA procède autrement : il choisit une direction aléatoire unique et sonde les deux côtés, ce qui suffit pour estimer un gradient même avec un millier de paramètres — deux évaluations au lieu de deux mille.

Points clés

  • La règle du parameter-shift calcule le gradient exact d’une porte R(θ) = exp(−iθG/2) (avec G² = I) via deux évaluations : ∂⟨O⟩/∂θₖ = (⟨O⟩(θₖ + π/2) − ⟨O⟩(θₖ − π/2)) / 2. C’est un résultat analytique, pas une approximation par différences finies.
  • Pour un circuit à p paramètres, le parameter-shift nécessite 2p évaluations par itération. C’est exact sur simulateur, mais coûteux sur matériel réel quand p est grand.
  • SPSA (Simultaneous Perturbation Stochastic Approximation) n’utilise que 2 évaluations par itération, quel que soit p. Il perturbe tous les paramètres simultanément dans une direction aléatoire. Le gradient estimé est bruité mais converge en moyenne — c’est l’optimiseur de choix pour les circuits NISQ profonds.
  • COBYLA est un optimiseur sans gradient qui construit un simplexe local. Il ne nécessite pas de calculer des dérivées, mais converge lentement pour p > 10.
  • La convergence dépend du paysage de coût : un ansatz trop expressif crée des plateaus de Barren (gradients exponentiellement petits, chapitre 12) où aucun optimiseur ne progresse efficacement.

Exemple concret

On entraîne un circuit à 1 qubit Ry(θ) pour minimiser ⟨Z⟩. L’état initial est θ₀ = 0.5. L’Estimator donne ⟨Z⟩(0.5) = cos(0.5) ≈ 0.8776. Le parameter-shift évalue ⟨Z⟩(0.5 + π/2) = cos(2.071) ≈ −0.4794 et ⟨Z⟩(0.5 − π/2) = cos(−1.071) ≈ 0.4794, d’où un gradient de (−0.4794 − 0.4794)/2 = −0.4794. Avec un taux d’apprentissage η = 0.5, la mise à jour donne θ₁ = 0.5 − 0.5 × (−0.4794) = 0.7397. Après une quinzaine d’itérations, θ converge vers π, où ⟨Z⟩ = cos(π) = −1 — le minimum global.

Comparaison des stratégies d’optimisation

OptimiseurTypeÉvaluations / itérationRobustesse au bruitCas d’usage
Parameter-shift + GDGradient exact2pFaibleSimulateur, petit p
SPSAGradient stochastique2ÉlevéeNISQ, grand p
COBYLASans gradientO(p)ModéréePetit p, pas de gradient
L-BFGS-B + param-shiftQuasi-Newton2pFaibleSimulateur, convergence rapide

Code Python — boucle d’entraînement avec parameter-shift

import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp

# Circuit paramétré à 1 qubit
theta = Parameter('θ')
qc = QuantumCircuit(1)
qc.ry(theta, 0)

observable = SparsePauliOp.from_list([("Z", 1)])
estimator = StatevectorEstimator()

def evaluate(param_val: float) -> float:
    """Évaluer ⟨Z⟩ pour une valeur de θ donnée."""
    result = estimator.run([(qc, observable, [param_val])]).result()
    return float(result[0].data.evs)

def parameter_shift_gradient(param_val: float) -> float:
    """Gradient exact via la règle du parameter-shift."""
    shift = np.pi / 2
    return (evaluate(param_val + shift) - evaluate(param_val - shift)) / 2

# Descente de gradient
theta_val = 0.5
lr = 0.3
for step in range(20):
    cost = evaluate(theta_val)
    grad = parameter_shift_gradient(theta_val)
    theta_val -= lr * grad
    if step % 5 == 0:
        print(f"Étape {step:2d} | θ = {theta_val:.4f} | ⟨Z⟩ = {cost:.4f}")

print(f"Résultat : θ = {theta_val:.4f}, ⟨Z⟩ = {evaluate(theta_val):.4f}")

Code Python — optimisation avec scipy (COBYLA)

import numpy as np
from scipy.optimize import minimize
from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp

# Circuit à 2 qubits : encodage angle + ansatz entraînable
x = ParameterVector('x', 2)
theta_vec = ParameterVector('θ', 4)
qc = QuantumCircuit(2)
# Encodage des features (fixées pendant l'optimisation)
qc.ry(x[0], 0)
qc.ry(x[1], 1)
# Ansatz : rotations + intrication + couche finale
qc.ry(theta_vec[0], 0)
qc.ry(theta_vec[1], 1)
qc.cx(0, 1)
qc.ry(theta_vec[2], 0)
qc.ry(theta_vec[3], 1)

observable = SparsePauliOp.from_list([("ZI", 1)])  # mesurer qubit 0
estimator = StatevectorEstimator()
x_data = [0.8, 1.2]  # features fixées

def cost(params):
    """Fonction de coût : ⟨ZI⟩ avec features fixées."""
    all_params = list(x_data) + list(params)
    result = estimator.run([(qc, observable, all_params)]).result()
    return float(result[0].data.evs)

result = minimize(cost, x0=np.random.rand(4), method='COBYLA',
                  options={"maxiter": 100})
print(f"Coût final : {result.fun:.4f}")
print(f"Paramètres : {np.round(result.x, 3)}")

Piège courant : « La règle du parameter-shift est une approximation numérique du gradient » est inexact. C’est un résultat analytiquement exact pour toute porte de la forme exp(−iθG/2) avec G² = I (rotations Rx, Ry, Rz). La précision ne dépend pas d’un pas ε arbitrairement petit, contrairement aux différences finies. Sur matériel réel, la seule source d’erreur est le bruit d’échantillonnage.


Fil rouge — la frontière quantique/classique

Ce chapitre complète la boucle variationnelle hybride en détaillant ses deux charnières : la mesure (passage du quantique au classique) et l’optimisation (mise à jour classique des paramètres). Le QPU exécute le circuit paramétré et produit un état quantique ; le Sampler ou l’Estimator convertit cet état en données classiques (bitstrings ou valeurs moyennes) ; l’optimiseur classique (COBYLA, SPSA, parameter-shift + descente de gradient) ajuste θ et relance le cycle. Le compromis expressivité ↔ entraînabilité se manifeste ici dans le choix de l’optimiseur : un circuit profond nécessite un optimiseur robuste au bruit (SPSA), mais même SPSA ne peut rien contre des gradients exponentiellement plats (plateaus de Barren, chapitre 12).


Quiz — teste tes connaissances
Intermédiaire 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 — optimiser un circuit par parameter-shift

Objectif : utiliser l’Estimator et la règle du parameter-shift pour trouver le paramètre θ qui minimise ⟨Z⟩ dans un circuit Ry(θ) à 1 qubit.

Squelette :

# kata_day_13_14.py
import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp

def build_circuit():
    """Construire un circuit Ry(θ) à 1 qubit et l'observable Z.
    Retourne (circuit, observable, paramètre).
    """
    theta = Parameter('θ')
    qc = QuantumCircuit(1)
    # TODO 1 : appliquer une rotation Ry(theta) sur le qubit 0
    observable = SparsePauliOp.from_list([("Z", 1)])
    return qc, observable, theta

def evaluate(qc, observable, theta_val: float) -> float:
    """Évaluer ⟨Z⟩ pour une valeur de θ donnée avec l'Estimator."""
    estimator = StatevectorEstimator()
    # TODO 2 : exécuter l'Estimator avec (circuit, observable, [theta_val])
    # Retourner float(result[0].data.evs)
    return 0.0  # placeholder

def parameter_shift_gradient(qc, observable, theta_val: float) -> float:
    """Calculer le gradient de ⟨Z⟩ par la règle du parameter-shift."""
    shift = np.pi / 2
    # TODO 3 : évaluer à theta_val + shift et theta_val - shift
    # Retourner (f_plus - f_minus) / 2
    return 0.0  # placeholder

def optimize(qc, observable, theta_init: float = 0.5,
             lr: float = 0.3, n_steps: int = 20) -> float:
    """Descente de gradient par parameter-shift. Retourne theta final."""
    theta_val = theta_init
    # TODO 4 : boucle de n_steps itérations
    #   gradient = parameter_shift_gradient(...)
    #   theta_val -= lr * gradient
    return theta_val

Auto-correction :

# test_kata_day_13_14.py
import numpy as np
from kata_day_13_14 import build_circuit, evaluate, parameter_shift_gradient, optimize

qc, observable, theta = build_circuit()

# Test 1 : le circuit a 1 qubit et 1 paramètre
assert qc.num_qubits == 1, "Le circuit doit avoir 1 qubit"
assert len(qc.parameters) == 1, f"1 paramètre attendu, trouvé {len(qc.parameters)}"

# Test 2 : évaluation correcte
val_0 = evaluate(qc, observable, 0.0)
assert abs(val_0 - 1.0) < 0.01, f"⟨Z⟩(0) doit valoir 1.0, obtenu {val_0}"
val_pi = evaluate(qc, observable, np.pi)
assert abs(val_pi - (-1.0)) < 0.01, f"⟨Z⟩(π) doit valoir -1.0, obtenu {val_pi}"

# Test 3 : gradient correct via parameter-shift
grad = parameter_shift_gradient(qc, observable, np.pi / 2)
assert abs(grad - (-1.0)) < 0.01, f"Gradient(π/2) doit valoir -1.0, obtenu {grad}"

# Test 4 : convergence de l'optimisation
theta_final = optimize(qc, observable)
cost_final = evaluate(qc, observable, theta_final)
assert cost_final < -0.95, f"Après optimisation ⟨Z⟩ < -0.95 attendu, obtenu {cost_final}"

print("Kata validé !")
Solution et explication
import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp

def build_circuit():
    theta = Parameter('θ')
    qc = QuantumCircuit(1)
    qc.ry(theta, 0)  # TODO 1
    observable = SparsePauliOp.from_list([("Z", 1)])
    return qc, observable, theta

def evaluate(qc, observable, theta_val: float) -> float:
    estimator = StatevectorEstimator()
    # TODO 2 : exécuter et extraire la valeur moyenne
    result = estimator.run([(qc, observable, [theta_val])]).result()
    return float(result[0].data.evs)

def parameter_shift_gradient(qc, observable, theta_val: float) -> float:
    shift = np.pi / 2
    # TODO 3 : deux évaluations décalées
    f_plus = evaluate(qc, observable, theta_val + shift)
    f_minus = evaluate(qc, observable, theta_val - shift)
    return (f_plus - f_minus) / 2

def optimize(qc, observable, theta_init: float = 0.5,
             lr: float = 0.3, n_steps: int = 20) -> float:
    theta_val = theta_init
    # TODO 4 : boucle gradient descent
    for _ in range(n_steps):
        grad = parameter_shift_gradient(qc, observable, theta_val)
        theta_val -= lr * grad
    return theta_val

Pourquoi : le kata met en œuvre la boucle variationnelle complète sur le cas le plus simple possible — 1 qubit, 1 paramètre, observable Z. L’Estimator évalue ⟨Z⟩ = cos(θ) exactement. Le parameter-shift calcule −sin(θ) via deux évaluations décalées de π/2. La descente de gradient converge vers θ = π⟨Z⟩ = −1. Ce motif se généralise directement aux circuits multi-qubits : chaque paramètre est traité indépendamment par le parameter-shift, et l’Estimator peut évaluer n’importe quel observable de Pauli.