Skip to content
Kartikey Purohit
← Course overview

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)  # 1

Binding 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 untouched

Common exam trap — bind_parameters is GONE. bind_parameters() was deprecated in Qiskit 0.45 and removed in 1.0. In v2.x the only API is assign_parameters(). Verified: hasattr(qc, "bind_parameters")False. Any answer choice using bind_parameters in 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.parameters in 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 t10 and t2 sort as [t10, t2] (character '1' < '2'). Verified output: [Parameter(t10), Parameter(t2)]. BUT ParameterVector elements sort numerically by index: t[2] comes before t[10]. This asymmetry is a favorite gotcha — prefer ParameterVector for 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 mutated

Common exam trap: with inplace=True the return value is None. Code like qc = qc.assign_parameters(vals, inplace=True) silently sets qc = 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 order

1.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 a TypeError (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)) or qc.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 reads 11).

Common exam trap — c_if is REMOVED. InstructionSet.c_if() / the condition attribute were deprecated in Qiskit 1.3 and removed in 2.0. Verified: hasattr(qc.x(0), "c_if")False. if_test is 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 Statevector or .to_instruction()QiskitError: Circuits with control flow operations cannot be converted to an instruction. Simulate them with Aer (AerSimulator / qiskit_aer.primitives.SamplerV2); StatevectorSampler raises '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 line

backend.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), never h. A single h on 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_manager and transpile (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 rzisa.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.