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
StatevectorSampleretStatevectorEstimator(moduleqiskit.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ère | Sampler | Estimator |
|---|---|---|
| Entrée | Circuit + mesures | Circuit (sans mesures) + observable |
| Sortie | Comptages / quasi-probabilités | Valeur moyenne |
| Cas d’usage typique | Classification (label depuis bitstrings) | Fonction de coût continue (VQE, régression) |
| Variance (matériel) | 1/√shots | 1/√shots |
| Classe Qiskit 1.x | StatevectorSampler | StatevectorEstimator |
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 à
θ + π/2etθ − π/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
| Optimiseur | Type | Évaluations / itération | Robustesse au bruit | Cas d’usage |
|---|---|---|---|---|
| Parameter-shift + GD | Gradient exact | 2p | Faible | Simulateur, petit p |
| SPSA | Gradient stochastique | 2 | Élevée | NISQ, grand p |
| COBYLA | Sans gradient | O(p) | Modérée | Petit p, pas de gradient |
| L-BFGS-B + param-shift | Quasi-Newton | 2p | Faible | Simulateur, 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).
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_valPourquoi : 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 θ = π où ⟨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.