Skip to content
Kartikey Purohit
← Course overview

Day 3 — Quantum Operations

Lesson

C1000-179 exam sprint · Qiskit 2.5.1 · every snippet below was executed and outputs are real. Section 2 is ~11 of 68 questions. Most of them hinge on one convention: little-endian.


0. THE Rule of this Section: Little-Endian

Qiskit orders qubits little-endian everywhere:

  • In a Pauli string, the rightmost character acts on qubit 0.
  • In a bitstring / statevector label, the rightmost bit is qubit 0.
  • In a statevector index, the integer value of the bitstring (q_{n-1}...q1 q0) is the index.
Pauli('IZ')      →  I on qubit 1,  Z on QUBIT 0        (rightmost = q0!)
Pauli('XZ')      →  X on qubit 1,  Z on qubit 0;  matrix = np.kron(X, Z)
label '10'       →  q1 = 1, q0 = 0  → statevector index 2

⚠️ COMMON EXAM TRAP #1 (the big one) Pauli('IZ') does NOT act Z on qubit 1. Read Pauli strings right to left: position 0 from the right = qubit 0. IBM writes distractors that assume big-endian. When in doubt on the exam, mentally rewrite: 'IZ' = Z₀, 'ZI' = Z₁.

Verified:

import numpy as np
from qiskit.quantum_info import Pauli
 
np.diag(Pauli('IZ').to_matrix()).real   # [ 1. -1.  1. -1.]  = kron(I, Z) → Z on q0
np.diag(Pauli('ZI').to_matrix()).real   # [ 1.  1. -1. -1.]  = kron(Z, I) → Z on q1

to_matrix() of a multi-qubit Pauli string 'AB' is np.kron(A, B) — the leftmost letter is the highest qubit and comes first in the Kronecker product:

X = np.array([[0,1],[1,0]]); Z = np.diag([1,-1])
np.allclose(Pauli('XZ').to_matrix(), np.kron(X, Z))   # True
np.allclose(Pauli('XZ').to_matrix(), np.kron(Z, X))   # False

1. Matrix Reference Table (memorize cold)

Gate Matrix Notes
I [[1, 0], [0, 1]] identity
X [[0, 1], [1, 0]] bit flip; X|0⟩=|1⟩
Y [[0, -i], [i, 0]] Y = iXZ; Y|0⟩ = i|1⟩
Z [[1, 0], [0, -1]] phase flip; Z|+⟩=|−⟩
H (1/√2)[[1, 1], [1, -1]] H|0⟩=|+⟩, H|1⟩=|−⟩; H = H†
S [[1, 0], [0, i]] S = √Z; S² = Z; S|+⟩ = |i⟩
T [[1, 0], [0, e^{iπ/4}]] = [[1,0],[0,(1+i)/√2]] T = √S; T² = S; T⁴ = Z

Also: sdg = diag(1, −i), tdg = diag(1, e^{−iπ/4}). All verified via Operator(qc).data.

Ladder to remember: T² = S, S² = Z, (so T⁴ = Z), Z² = I, H² = I.


2. The Pauli Class

2.1 Construction from strings, with phase prefixes

Valid prefixes: '', '-', 'i', '-i' (the group phase (−i)^k).

from qiskit.quantum_info import Pauli
 
Pauli('IZ')        # IZ    (2 qubits, Z on qubit 0)
Pauli('-iXY')      # -iXY  (Y on q0, X on q1, overall phase -i)
Pauli('-iXY').phase   # 1
Pauli('-Z').phase     # 2
Pauli('iZ').phase     # 3
Pauli('X').phase      # 0

⚠️ TRAP: .phase stores k where prefix = (−i)^k: ''→0, '-i'→1, '-'→2, 'i'→3. It is not "0,1,2,3 = +1,i,−1,−i".

to_matrix() includes the phase: Pauli('iZ').to_matrix() = [[1j, 0], [0, -1j]]. .to_label() round-trips: Pauli('-iXY').to_label()'-iXY'.

2.2 Pauli products — compose vs dot

  • A.dot(B) = matrix product A·B (B applied first).
  • A.compose(B) = matrix product B·A (A applied first — circuit order!).
X, Y = Pauli('X'), Pauli('Y')
X.dot(Y)       # iZ    (X·Y = iZ)
X.compose(Y)   # -iZ   (Y·X = -iZ)

Cyclic products (verified): X@Y = iZ, Y@Z = iX, Z@X = iY; reversed order flips the sign (X@Z = -iY). Operator shortcuts: @ = dot, & = compose, ^ = tensor.

2.3 tensor vs expand, adjoint

a, b = Pauli('X'), Pauli('Z')
a.tensor(b)   # XZ   — a goes to the HIGHER qubits (left of string)
a.expand(b)   # ZX   — reversed: b goes to the higher qubits
Pauli('-iXY').adjoint()   # iXY  (phase conjugated; Paulis are Hermitian up to phase)

2.4 Commutation

Two Pauli strings commute iff they anticommute on an even number of positions.

Pauli('X').commutes(Pauli('Z'))        # False (single position, anticommute)
Pauli('XX').commutes(Pauli('ZZ'))      # True  (two anticommuting positions → even)
Pauli('XI').commutes(Pauli('ZI'))      # False (one anticommuting position: qubit 1)
Pauli('XI').commutes(Pauli('IZ'))      # True  (act on different qubits)
Pauli('X').anticommutes(Pauli('Z'))    # True

3. SparsePauliOp — Observables / Hamiltonians

3.1 Construction

from qiskit.quantum_info import SparsePauliOp
 
op = SparsePauliOp.from_list([("XZ", 1.0), ("IY", 0.5)])
# SparsePauliOp(['XZ', 'IY'], coeffs=[1. +0.j, 0.5+0.j])
 
op.coeffs        # array([1. +0.j, 0.5+0.j])  ← ALWAYS complex128
op.paulis        # PauliList(['XZ', 'IY'])
op.num_qubits    # 2
SparsePauliOp("XYZ")   # single term, default coeff 1+0j

⚠️ TRAP: coeffs are always complex (complex128), even if you pass floats/ints. An answer choice showing coeffs=[1., 0.5] (real dtype) is wrong; it prints 1.+0.j.

from_sparse_list gives (pauli chars, qubit indices, coeff) — indices map chars to qubits:

op2 = SparsePauliOp.from_sparse_list(
    [("ZX", [1, 4], 2.0),      # Z on qubit 1, X on qubit 4
     ("YY", [0, 3], -1.0)],    # Y on qubits 0 and 3
    num_qubits=5)
# SparsePauliOp(['XIIZI', 'IYIIY'], coeffs=[ 2.+0.j, -1.+0.j])

Check that: 'XIIZI' read right-to-left = q0:I, q1:Z, q2:I, q3:I, q4:X. Little-endian again.

3.2 simplify(), arithmetic, adjoint

op3 = SparsePauliOp.from_list([("XX", 1.0), ("XX", 1.0), ("ZZ", 0.0)])
op3.simplify()
# SparsePauliOp(['XX'], coeffs=[2.+0.j])   — merges duplicates, drops zero coeffs
 
SparsePauliOp.from_list([("Z", 1.0), ("Z", -1.0)]).simplify()
# SparsePauliOp(['I'], coeffs=[0.+0.j])    — full cancellation leaves a zero identity term
 
SparsePauliOp(["XI"]) + SparsePauliOp(["IX"])   # ['XI', 'IX'], coeffs=[1.+0.j, 1.+0.j]
2j * SparsePauliOp(["Z"])                        # ['Z'], coeffs=[0.+2.j]
((1+2j) * SparsePauliOp(["Z"])).adjoint()        # ['Z'], coeffs=[1.-2.j]  (conjugates coeffs)

3.3 compose / tensor / expand (same conventions as Pauli)

A = SparsePauliOp(["X"]); B = SparsePauliOp(["Z"])
A.tensor(B)    # ['XZ']  — A on higher qubit
A.expand(B)    # ['ZX']  — B on higher qubit
A.compose(B)   # ['Y'], coeffs=[0.+1.j]   (= Z·X = iY : compose applies A first)

Typical Estimator Hamiltonian: SparsePauliOp.from_list([("ZZ", 1.0), ("XI", 0.5), ("IX", 0.5)]).


4. Statevector

4.1 Construction

from qiskit.quantum_info import Statevector
from qiskit import QuantumCircuit
 
Statevector.from_label('01').data.real   # [0. 1. 0. 0.]  → index 1  (q1=0, q0=1)
Statevector.from_label('10').data.real   # [0. 0. 1. 0.]  → index 2  (q1=1, q0=0)

Labels beyond 0/1: '+', '-' (X eigenstates), 'r', 'l' (Y eigenstates): 'r' = (|0⟩ + i|1⟩)/√2, 'l' = (|0⟩ − i|1⟩)/√2.

qc = QuantumCircuit(2)
qc.h(0); qc.cx(0, 1)                      # Bell state
bell = Statevector.from_instruction(qc)
bell.data.real                            # [0.7071 0. 0. 0.7071]

⚠️ TRAP — statevector index ordering: the amplitude at index k belongs to basis state |bin(k)⟩ read little-endian. qc.x(0) on a 2-qubit circuit gives [0, 1, 0, 0] (index 1 = '01' = q0 flipped), not [0, 0, 1, 0]. qc.x(1) gives [0, 0, 1, 0] (index 2 = '10').

4.2 probabilities_dict / sample_counts

bell.probabilities_dict()      # {'00': 0.5, '11': 0.5}   (keys are little-endian bitstrings)
bell.seed(42)
bell.sample_counts(1000)       # {'00': 503, '11': 497}   (random unless seeded)

probabilities() returns the array form: for qc.h(1) on 2 qubits → [0.5, 0. , 0.5, 0. ] (indices 0 and 2 = '00' and '10' — superposition on qubit 1).

4.3 evolve

sv = Statevector.from_label('0')
sv.evolve(qc_with_x).data.real     # [0. 1.]  — accepts Operator, Gate, or QuantumCircuit
sv.evolve(Pauli('Z'))              # also accepts Pauli directly

4.4 expectation_value

Statevector.from_label('0').expectation_value(Pauli('Z'))    # 1.0
Statevector.from_label('+').expectation_value(Pauli('X'))    # ~1.0
bell.expectation_value(SparsePauliOp(['ZZ']))                # ~1+0j
bell.expectation_value(SparsePauliOp(['ZI']))                # 0j  (single-qubit ⟨Z⟩ vanishes!)
bell.expectation_value(SparsePauliOp(['XX']))                # ~1+0j

Little-endian in expectations too — on |01⟩ (q0 = 1):

sv01 = Statevector.from_label('01')
sv01.expectation_value(Pauli('IZ'))   # -1.0  (Z on q0, which is |1⟩)
sv01.expectation_value(Pauli('ZI'))   # +1.0  (Z on q1, which is |0⟩)

Also useful: sv.inner(other) (⟨sv|other⟩ = 0.7071… for ⟨0|+⟩), and sv.equiv(other) — True if equal up to global phase.


5. Operator

from qiskit.quantum_info import Operator
 
qc = QuantumCircuit(1); qc.h(0)
Operator(qc).data           # [[0.7071, 0.7071], [0.7071, -0.7071]]
Operator(qc).is_unitary()   # True
Operator(np.array([[1, 1], [0, 1]])).is_unitary()   # False
  • Operator(circuit) builds the full unitary; a circuit drawn left→right multiplies matrices right→left: circuit h(0); z(0) has unitary Z·H.
  • A.compose(B) = B·A ("A then B", circuit order); A.dot(B) = A·B.
  • A == B → exact equality. A.equiv(B) → equal up to global phase.
  • Operator(qc).power(2), .adjoint(), .tensor(), .expand() all exist.
qs = QuantumCircuit(1); qs.s(0)
Operator(qs).power(2) == Operator(Pauli('Z'))     # True  (S² = Z)

6. Fidelity, DensityMatrix, partial_trace

from qiskit.quantum_info import state_fidelity, partial_trace, DensityMatrix
 
state_fidelity(Statevector.from_label('0'), Statevector.from_label('+'))   # 0.5
state_fidelity(bell, bell)                                                 # ~1.0
 
rho0 = partial_trace(bell, [1])   # trace OUT qubit 1, keep qubit 0
rho0.data.real                    # [[0.5, 0. ], [0. , 0.5]]  = I/2, maximally mixed
rho0.purity()                     # ~0.5  (pure state would give 1.0)

A reduced single qubit of a Bell pair is maximally mixed — that is entanglement: no single-qubit state can describe half of an entangled pair.


7. Gate Effects, Phases, Identities

7.1 Action on basis / Bloch states

State Bloch axis X Z H S
|0⟩ +Z |1⟩ |0⟩ |+⟩ |0⟩
|1⟩ −Z |0⟩ −|1⟩ |−⟩ i|1⟩
|+⟩ +X |+⟩ |−⟩ |0⟩ |i⟩ (= 'r')
|−⟩ −X −|−⟩ |+⟩ |1⟩ |l⟩ (= (|0⟩−i|1⟩)/√2)

Verified: S|+⟩ = [0.7071, 0.7071i] which is Statevector.from_label('r'); T|+⟩ = [0.7071, 0.5+0.5i] (45° around Z).

7.2 Global vs relative phase

  • Global phase (e^{iφ} on the whole state) is unobservable: Statevector([1,0]).equiv(Statevector([1j,0])) → True.
  • Relative phase (between amplitudes) is physical: Z turns |+⟩ into |−⟩ — different state, yet Z-basis probabilities unchanged ({'0': 0.5, '1': 0.5} both). You need an X-basis measurement (apply H first) to see it.
  • Z, S, T all leave computational-basis probabilities of any state untouched (diagonal matrices).

7.3 Entanglement recipe

h(0) then cx(0, 1) on |00⟩ → (|00⟩ + |11⟩)/√2. Signatures on the exam: counts only 00/11, ⟨ZZ⟩ = ⟨XX⟩ = +1, ⟨ZI⟩ = ⟨IZ⟩ = 0, reduced states = I/2.

7.4 Identities (all verified with Operator(...).equiv)

  • HZH = X and HXH = Z (H swaps the X and Z axes)
  • HYH = −Y; S² = Z; T² = S; H² = X² = Y² = Z² = I
  • CX(a,b) matrix for control=q0, target=q1 (little-endian!): [[1,0,0,0],[0,0,0,1],[0,0,1,0],[0,1,0,0]] — it maps index 1 (|01⟩) ↔ index 3 (|11⟩).

7.5 Rotations: R_P(θ) = exp(−iθP/2)

theta = 0.7
np.allclose(Operator(qc_rz_07).data,
            expm(-1j*theta/2 * Pauli('Z').to_matrix()))   # True
  • rx(π) = [[0,-i],[-i,0]] = −iXequiv(X) is True, == is False.
  • rz(π) = diag(−i, i) = −iZ ≡ Z up to global phase (equiv True, exact False).
  • ry(π/2)|0⟩ = |+⟩ = [0.7071, 0.7071] (Y rotations keep real amplitudes).
  • Period is (R_P(2π) = −I), another classic distractor.

⚠️ TRAP: Operator(rz(π)) == Operator(z) is False; .equiv() is True. If the exam asks "identical" vs "equivalent up to global phase", rz(π) is the latter.


8. Coding Labs — predict, then run

Do these with /home/kartikey_purohit/development/ibm-qiskit/.venv/bin/python.

Lab A — "The H sandwich": prove HZH = X and HXH = Z numerically

Predict first: which of the two comparisons (== vs equiv) will be True? Then build h(0); z(0); h(0) and h(0); x(0); h(0) circuits, wrap in Operator, and compare with Operator(Pauli('X')) / Operator(Pauli('Z')) using both == and .equiv(). Bonus: show HYH = −Y (hint: compare against Pauli('-Y')).

Lab B — Bell autopsy: half of an entangled pair is pure noise

Build the Bell state with from_instruction. Predict: ⟨ZZ⟩, ⟨XX⟩, ⟨ZI⟩, ⟨IZ⟩, and what partial_trace(bell, [1]) looks like. Then compute all five, plus purity() of the reduced state and state_fidelity between the reduced state and DensityMatrix.from_label('0'). Explain why a Bloch vector of length 0 means "entangled" here.

Lab C — Phase detective: rz(π) vs Z, and the S gate's hidden work

  1. Predict the 4 booleans: Operator(rz_pi) == Operator(Z), .equiv(), and the same pair for rx(π) vs X. Run and check.
  2. Take |+⟩, apply S, print probabilities_dict() (predict: changed or not?), then verify the result equals Statevector.from_label('r') with .equiv().
  3. Make the phase visible with a basis change. Predict the Z-basis probabilities of H·Z|+⟩, H·S|+⟩, and H·T|+⟩ (each: apply the phase gate to |+⟩, then H, then look). One is deterministic, one is still 50/50, one is ~85/15 — which is which, and why? (Hint: H reads out the X axis; S parks the state on the Y axis.)

9. Thirty-Second Cram Card

  • Pauli string: rightmost char = qubit 0. Pauli('IZ') = Z₀.
  • to_matrix('AB') = kron(A, B). Phase prefix k in .phase: (−i)^k.
  • compose = "then" (B·A); dot = matrix product (A·B); tensor puts self on high qubits.
  • SparsePauliOp coeffs: complex128 always; simplify() merges/drops terms.
  • Statevector index k ↔ bitstring bin(k), rightmost bit = q0.
  • ⟨Bell|ZZ|Bell⟩ = ⟨XX⟩ = 1, single-qubit ⟨Z⟩ = 0, reduced state = I/2.
  • HZH = X, HXH = Z, S² = Z, T² = S, rz(π) ≡ Z up to global phase.
  • Paulis commute iff anticommuting positions are even in number: XX vs ZZ → commute.