Day 2 — Parameterized & Dynamic Circuits, Transpilation
Lesson
Syllabus: §1.2 (parameterized), §1.3 (dynamic / classical feedforward), §1.4 (transpilation) Verified against: qiskit 2.5.1, qiskit-ibm-runtime 0.48.0, qiskit-aer 0.17.2
Part 1 — Parameterized Circuits (§1.2)
1.1 Parameter basics
A Parameter is a named symbolic placeholder you can use anywhere a gate angle goes.
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
import numpy as np
theta = Parameter("theta")
qc = QuantumCircuit(1)
qc.rx(theta, 0)
print(qc.parameters) # ParameterView([Parameter(theta)])
print(qc.num_parameters) # 1Binding produces a new circuit by default (the original keeps its parameters):
bound = qc.assign_parameters({theta: np.pi})
print(bound.parameters) # ParameterView([]) <- fully bound
print(qc.num_parameters) # 1 <- original untouchedCommon exam trap —
bind_parametersis GONE.bind_parameters()was deprecated in Qiskit 0.45 and removed in 1.0. In v2.x the only API isassign_parameters(). Verified:hasattr(qc, "bind_parameters")→False. Any answer choice usingbind_parametersin a v2.x question is wrong.
1.2 Dict binding vs list binding — the sorted-order trap
assign_parameters accepts:
- a dict
{parameter_or_name: value}— order doesn't matter, partial binding allowed - a list/array of values — must supply every parameter, and values map to
circuit.parametersin its sorted order, NOT insertion order.
circuit.parameters sorts alphabetically by name (string sort!):
b = Parameter("b"); a = Parameter("a"); z = Parameter("z")
qc = QuantumCircuit(1)
qc.rx(z, 0) # inserted first
qc.ry(a, 0)
qc.rz(b, 0) # inserted last
print(list(qc.parameters))
# [Parameter(a), Parameter(b), Parameter(z)] <- alphabetical, not rx/ry/rz order!
bound = qc.assign_parameters([0.1, 0.2, 0.3]) # a=0.1, b=0.2, z=0.3
print(bound.draw())
# ┌─────────┐┌─────────┐┌─────────┐
# q: ┤ Rx(0.3) ├┤ Ry(0.1) ├┤ Rz(0.2) ├ <- rx got z's value 0.3
# └─────────┘└─────────┘└─────────┘Common exam trap — string sort, not numeric sort. Standalone parameters named
t10andt2sort as[t10, t2](character '1' < '2'). Verified output:[Parameter(t10), Parameter(t2)]. BUTParameterVectorelements sort numerically by index:t[2]comes beforet[10]. This asymmetry is a favorite gotcha — preferParameterVectorfor sweeps.
Common exam trap — wrong-length list. Binding a list with the wrong number of values raises
ValueError: Mismatching number of values and parameters.... Partial binding is dict-only.
1.3 inplace=True
c = Parameter("c")
qc = QuantumCircuit(1); qc.rx(c, 0)
ret = qc.assign_parameters({c: 0.5}, inplace=True)
print(ret) # None <- returns None when inplace!
print(list(qc.parameters)) # [] <- qc itself was mutatedCommon exam trap: with
inplace=Truethe return value isNone. Code likeqc = qc.assign_parameters(vals, inplace=True)silently setsqc = None.
1.4 ParameterVector
from qiskit.circuit import ParameterVector
theta = ParameterVector("theta", 3) # theta[0], theta[1], theta[2]
qc = QuantumCircuit(1)
for p in theta:
qc.ry(p, 0)
print(len(theta)) # 3
print(theta[1]) # theta[1]
print(list(qc.parameters))
# [ParameterVectorElement(theta[0]), ParameterVectorElement(theta[1]),
# ParameterVectorElement(theta[2])] <- numeric index order1.5 ParameterExpression arithmetic
Parameters support + - * /, and methods like .sin(), .cos(), .exp().
Arithmetic yields a ParameterExpression; the underlying Parameter is still the
thing tracked by circuit.parameters.
a = Parameter("a")
qc = QuantumCircuit(1)
qc.rx(2 * a, 0) # expression: 2*a
qc.ry(a + np.pi / 2, 0) # expression: a + pi/2
print(list(qc.parameters)) # [Parameter(a)] <- ONE parameter, used twice
bound = qc.assign_parameters({a: np.pi / 2})
print(bound.draw())
# ┌───────┐┌───────┐
# q: ┤ Rx(π) ├┤ Ry(π) ├ <- 2*(pi/2)=pi and pi/2+pi/2=pi
# └───────┘└───────┘You can also assign a parameter to another expression (substitution):
e = Parameter("e")
qc2 = qc.assign_parameters({a: 2 * e}) # circuit now parameterized by e
print(list(qc2.parameters)) # [Parameter(e)]1.6 Bind late: parameter values in PUBs
For V2 primitives you usually don't bind at all — pass values in the PUB:
(circuit, parameter_values). One circuit + an array of value sets = a sweep,
transpiled once. (Full runtime coverage in a later day; the sweep pattern appears in
Lab A below.)
Common exam trap:
Statevector(qc)on a circuit with unbound parameters raises aTypeError(unbound parameter). Bind first, or use primitives with PUB values.
Part 2 — Dynamic Circuits (§1.3)
Dynamic circuits = mid-circuit measurement + classical feedforward (gates conditioned on measurement outcomes).
2.1 if_test — the v2.x way
qc = QuantumCircuit(2, 2)
qc.h(0)
qc.measure(0, 0) # mid-circuit measurement
with qc.if_test((qc.clbits[0], 1)): # condition is a TUPLE (bit/reg, value)
qc.x(1)
qc.measure(1, 1)
print(qc.count_ops())
# OrderedDict({'measure': 2, 'h': 1, 'if_else': 1}) <- op is named 'if_else'Common exam trap — the condition is a TUPLE.
qc.if_test((clbit, 1))orqc.if_test((creg, 3)). Passing the bit/register alone, or bit and value as two positional args, fails. Register conditions compare the whole register to an integer value (e.g.(cr, 3)means the 2-bit register reads11).
Common exam trap —
c_ifis REMOVED.InstructionSet.c_if()/ theconditionattribute were deprecated in Qiskit 1.3 and removed in 2.0. Verified:hasattr(qc.x(0), "c_if")→False.if_testis the only conditional construct in v2.x.
2.2 else branch
if_test used as a context manager returns an else-handle:
qc = QuantumCircuit(2, 2)
qc.h(0)
qc.measure(0, 0)
with qc.if_test((qc.clbits[0], 1)) as else_:
qc.x(1)
with else_:
qc.h(1)
qc.measure(1, 1)2.3 for_loop, while_loop, switch
# for_loop — collection must be a range or list of INTS in v2.x
qc = QuantumCircuit(1)
with qc.for_loop(range(3)):
qc.x(0)
print(qc.count_ops()) # OrderedDict({'for_loop': 1})
# while_loop — same tuple condition as if_test
qc = QuantumCircuit(1, 1)
qc.h(0)
qc.measure(0, 0)
with qc.while_loop((qc.clbits[0], 1)): # repeat until we measure 0
qc.h(0)
qc.measure(0, 0)
print(qc.count_ops()) # {'h': 1, 'measure': 1, 'while_loop': 1}
# switch on a classical register
qc = QuantumCircuit(2, 2)
qc.h([0, 1]); qc.measure([0, 1], [0, 1])
with qc.switch(qc.cregs[0]) as case:
with case(0):
qc.x(0)
with case(1, 2): # one body for multiple values
qc.z(0)
with case(case.DEFAULT): # fallthrough
qc.h(0)
print(qc.count_ops()) # {'h': 2, 'measure': 2, 'switch_case': 1}Common exam trap: control-flow circuits cannot be converted to a
Statevectoror.to_instruction()—QiskitError: Circuits with control flow operations cannot be converted to an instruction.Simulate them with Aer (AerSimulator/qiskit_aer.primitives.SamplerV2);StatevectorSamplerraises'StatevectorSampler cannot handle ControlFlowOp'.
Use cases to recognize on the exam: teleportation (X/Z corrections from Bell measurement), active/conditional reset (measure, flip if 1), conditional gates.
Part 3 — Transpilation (§1.4)
3.1 Why transpile?
Real backends only run their basis gates, only allow 2-qubit gates on coupled qubit pairs (coupling map), and V2 primitives reject non-ISA circuits. ISA (Instruction Set Architecture) circuit = expressed exclusively in the target's supported operations on legal qubit pairs.
from qiskit_ibm_runtime.fake_provider import FakeManilaV2
backend = FakeManilaV2()
print(backend.num_qubits) # 5
print(sorted(backend.target.operation_names))
# ['cx', 'delay', 'for_loop', 'id', 'if_else', 'measure', 'reset', 'rz',
# 'switch_case', 'sx', 'x']
print(backend.coupling_map)
# [[0, 1], [1, 0], [1, 2], [2, 1], [2, 3], [3, 2], [3, 4], [4, 3]] <- a linebackend.target is the single source of truth in v2.x — it holds basis gates,
connectivity, error rates, and timing, and is what the transpiler consumes.
3.2 generate_preset_pass_manager — the v2.x standard
from qiskit import QuantumCircuit
from qiskit.transpiler import generate_preset_pass_manager
ghz = QuantumCircuit(3)
ghz.h(0); ghz.cx(0, 1); ghz.cx(0, 2)
ghz.measure_all()
pm = generate_preset_pass_manager(optimization_level=3, backend=backend)
isa = pm.run(ghz)
print(isa.count_ops())
# OrderedDict({'measure': 3, 'rz': 2, 'cx': 2, 'sx': 1, 'barrier': 1})Notice: h is gone — it became rz, sx, rz. Gate names in a transpiled circuit
are basis-gate names.
Common exam trap: after transpilation
count_ops()shows basis gates (rz,sx,x,cx/ecr/cz), neverh. A singlehon FakeManilaV2 becomes{'rz': 2, 'sx': 1}with depth 3.
transpile(circuit, backend=backend, optimization_level=n) is the one-shot function
equivalent; preset pass managers are preferred in v2.x because they're reusable and
stage-customizable (pm.layout, pm.routing, ... can be replaced).
Common exam trap — default optimization level is 2 for BOTH
generate_preset_pass_managerandtranspile(docstring: "If None, level 2 will be chosen as default"). It was 1 in old Qiskit — v2.x exams test the new default.
3.3 The six stages (know the order)
init → layout → routing → translation → optimization → scheduling
| Stage | What it does |
|---|---|
| init | unroll >2-qubit gates, basic simplification |
| layout | map virtual → physical qubits (TrivialLayout, VF2Layout, SabreLayout) |
| routing | insert SWAPs so 2-qubit gates touch only coupled pairs (SabreSwap) |
| translation | rewrite into basis gates from backend.target |
| optimization | 1-qubit resynthesis, gate cancellation, commutation (heavier at 2–3) |
| scheduling | insert delays / timing (only if requested; DD lives here) |
Verified: pm.stages → ('init', 'layout', 'routing', 'translation', 'optimization', 'scheduling').
3.4 Optimization levels 0–3
| Level | Layout | Routing | Optimization |
|---|---|---|---|
| 0 | trivial (qubit i → physical i) | stochastic SWAPs, no cleanup | none |
| 1 | VF2/Sabre, light | Sabre | light (1q gate merging, cancellation) |
| 2 (default) | better Sabre trials | Sabre | medium — good speed/quality balance |
| 3 | most Sabre trials | Sabre | heavy (unitary resynthesis, 2q block collection) |
Measured on the GHZ above (FakeManilaV2):
level 0: depth 9, ops {'cx': 5, 'measure': 3, 'rz': 2, 'sx': 1, 'barrier': 1}
level 3: depth 6, ops {'measure': 3, 'rz': 2, 'cx': 2, 'sx': 1, 'barrier': 1}
Why level 0 has 5 cx: trivial layout puts the GHZ on qubits 0,1,2 of a line;
cx(0, 2) is not a coupled pair, so routing inserts a SWAP (= 3 cx) → 2 + 3 = 5.
Level 3 picks a layout where no SWAP is needed and keeps 2 cx.
Common exam trap: higher optimization level ⇒ usually lower depth/gate count but longer transpile time. Level 0 is for debugging/layout experiments, not "fastest circuit".
Common exam trap: transpilation can also increase counts vs the abstract circuit (SWAP insertion, basis decomposition) — "transpiling always reduces gate count" is false.
3.5 Layout bookkeeping
The transpiled circuit remembers its qubit mapping in isa.layout
(a TranspileLayout); the original circuit has layout is None. Estimator
observables must later be mapped with observable.apply_layout(isa.layout) —
that's a Day-5 topic, but the reason is set here.
Parameterized circuits survive transpilation unbound: transpiling ry(theta) on
FakeManilaV2 gives {'sx': 2, 'rz': 2} with theta still inside an rz —
isa.parameters → [Parameter(theta)]. Transpile once, sweep many.
CODING LABS — predict, then run
Lab A — ParameterVector sweep through a PUB (no binding!)
Predict the three get_counts() results before running.
import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit import Parameter
from qiskit.primitives import StatevectorSampler
theta = Parameter("theta")
qc = QuantumCircuit(1, 1)
qc.ry(theta, 0)
qc.measure(0, 0)
sampler = StatevectorSampler(seed=42)
sweep = [[0.0], [np.pi / 2], [np.pi]] # 3 parameter sets
job = sampler.run([(qc, sweep)], shots=1000) # ONE pub, swept
res = job.result()[0]
print(res.data.c.shape) # ?
for i in range(3):
print(res.data.c[i].get_counts()) # ? ? ?Question to answer: what is res.data.c.shape, and why does the middle result not
say exactly 500/500?
Lab B — GHZ at level 0 vs level 3 on FakeManilaV2
Predict: which level yields more cx, and exactly how many more? (Hint: one SWAP = 3 cx.)
from qiskit import QuantumCircuit
from qiskit.transpiler import generate_preset_pass_manager
from qiskit_ibm_runtime.fake_provider import FakeManilaV2
backend = FakeManilaV2()
ghz = QuantumCircuit(3)
ghz.h(0); ghz.cx(0, 1); ghz.cx(0, 2)
ghz.measure_all()
for lvl in (0, 3):
pm = generate_preset_pass_manager(optimization_level=lvl, backend=backend)
isa = pm.run(ghz)
print(f"L{lvl}: depth={isa.depth()} ops={dict(isa.count_ops())}")Then inspect isa.layout.initial_index_layout() at both levels and explain the cx difference.
Lab C — active reset with if_test (needs Aer)
Predict the counts. Bit order reminder: with measure(0, 0) then measure(0, 1),
clbit 1 is the left character of each bitstring.
from qiskit import QuantumCircuit
from qiskit_aer.primitives import SamplerV2 as AerSampler
qc = QuantumCircuit(1, 2)
qc.h(0)
qc.measure(0, 0) # 50/50 outcome recorded in c[0]
with qc.if_test((qc.clbits[0], 1)):
qc.x(0) # flip back to |0> if we saw 1
qc.measure(0, 1) # final state recorded in c[1]
sampler = AerSampler(seed=7)
res = sampler.run([qc], shots=1000).result()[0]
print(res.data.c.get_counts()) # which two bitstrings appear? which never?Solutions and expected outputs are in solution-bank.md.