#!/usr/bin/env python # # file: qmllab/kernels.py # # revision history: # 20260810 (am): initial version for workshop lab 02 # # Four ways to compute the same quantum kernel: # # k(x, y) = ||^2 # # exact pure linear algebra, statevectors on your laptop # fidelity qiskit's FidelityQuantumKernel, sampled from shots # backend any qiskit backend, ideal or carrying a noise model # hardware compute-uncompute circuits submitted to a real qpu # # Every one of them answers the same question. Only the ruler changes. #------------------------------------------------------------------------------ # import third-party modules # import numpy as np from qiskit.circuit.library import PauliFeatureMap, ZFeatureMap, ZZFeatureMap from qiskit.quantum_info import Statevector from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager #------------------------------------------------------------------------------ # # global variables are listed here # #------------------------------------------------------------------------------ # the feature maps the lab exposes by name # FEATURE_MAPS = {"zz": ZZFeatureMap, "z": ZFeatureMap, "pauli": PauliFeatureMap} # measured on ibm_kingston: seconds of qpu time per two-qubit circuit # QPU_SECONDS_PER_CIRCUIT = 0.31 #------------------------------------------------------------------------------ # # functions are listed here # #------------------------------------------------------------------------------ def make_feature_map(kind="zz", n_features=2, reps=2, entanglement="full"): """ function: make_feature_map arguments: kind: one of the keys of FEATURE_MAPS n_features: how many features to encode, one per qubit reps: how many times the encoding block repeats entanglement: the entangling pattern, ignored for a z map return: a parameterized qiskit circuit description: Builds the circuit that turns a data point into a quantum state. The data supplies the rotation angles, so the encoding is where the data enters the quantum computer. """ # reject an unknown feature map # cls = FEATURE_MAPS.get(kind) if cls is None: raise ValueError( f"unknown feature map {kind!r}; choose from {list(FEATURE_MAPS)}") # a z map has no entangling layer, so it takes no such argument # if cls is ZFeatureMap: return cls(feature_dimension=n_features, reps=reps) # exit gracefully # return cls(feature_dimension=n_features, reps=reps, entanglement=entanglement) # # end of function def statevectors(X, feature_map): """ function: statevectors arguments: X: an (n, d) array of data points feature_map: the encoding circuit return: an (n, 2**q) complex array of amplitudes description: Encodes every row of X as the quantum state |phi(x)>. """ # exit gracefully # return np.array( [Statevector(feature_map.assign_parameters(x)).data for x in X]) # # end of function def exact_kernel(X1, X2=None, feature_map=None): """ function: exact_kernel arguments: X1: an (n, d) array of data points X2: a second array, or None to compare X1 against itself feature_map: the encoding circuit return: an (n, m) kernel matrix description: Computes k(x,y) = ||^2 exactly, by linear algebra. No sampling and no hardware, so this is the ideal answer a perfect quantum computer would return. It is fast enough for a few hundred points because the overlap is one matrix multiply. """ # fall back to the lab's default encoding # if feature_map is None: feature_map = make_feature_map() # encode both sides, reusing the first when comparing to itself # P1 = statevectors(X1, feature_map) P2 = P1 if X2 is None else statevectors(X2, feature_map) # exit gracefully -- the squared overlap of every pair # return np.abs(P1.conj() @ P2.T) ** 2 # # end of function def fidelity_kernel(X1, X2=None, feature_map=None, shots=None, backend=None, optimization_level=3, seed=42): """ function: fidelity_kernel arguments: X1: an (n, d) array of data points X2: a second array, or None for a symmetric kernel feature_map: the encoding circuit shots: samples per entry; None means exact sampling backend: a qiskit backend, or None for the reference one optimization_level: transpiler effort when a backend is given seed: fixes the sampling noise; None lets it vary return: an (n, m) kernel matrix description: Evaluates the kernel through qiskit-machine-learning rather than by hand. Passing shots introduces sampling noise; passing a backend runs the circuits on that backend, which may carry a noise model. """ # import here to keep a plain import of this module light # from qiskit_machine_learning.kernels import FidelityQuantumKernel from qiskit_machine_learning.state_fidelities import ComputeUncompute # fall back to the lab's default encoding # if feature_map is None: feature_map = make_feature_map() # choose a sampler to match what the caller asked for # pass_manager = None if backend is not None: # a backend needs isa circuits, so build a pass manager too # from qiskit.primitives import BackendSamplerV2 if seed is not None and hasattr(backend, "set_options"): backend.set_options(seed_simulator=seed) sampler = BackendSamplerV2(backend=backend, options={"default_shots": shots or 1024}) pass_manager = generate_preset_pass_manager( backend=backend, optimization_level=optimization_level) # no shots means exact statevector sampling # elif shots is None: from qiskit.primitives import StatevectorSampler sampler = StatevectorSampler() # otherwise use the reference sampler, which honours a shot count # else: from qiskit.primitives import Sampler options = {"shots": shots} if seed is not None: options["seed"] = seed sampler = Sampler(options=options) # wire the sampler into a fidelity kernel # fidelity = ComputeUncompute(sampler=sampler, pass_manager=pass_manager) kernel = FidelityQuantumKernel(feature_map=feature_map, fidelity=fidelity) # exit gracefully # return kernel.evaluate(X1) if X2 is None else kernel.evaluate(X1, X2) # # end of function def fidelity_circuit(x, y, feature_map): """ function: fidelity_circuit arguments: x: the first data point y: the second data point feature_map: the encoding circuit return: a measured qiskit circuit description: Builds the compute-uncompute circuit: run U(x), then run U(y) backwards. When x equals y the two cancel exactly and the state returns to |00..0>, so the probability of measuring all zeros is the kernel entry itself. """ # compose the encoding with the inverse of the second encoding # circuit = feature_map.assign_parameters(x).compose( feature_map.assign_parameters(y).inverse()) # read out every qubit # circuit.measure_all() # exit gracefully # return circuit # # end of function def hardware_kernel(X1, X2=None, feature_map=None, backend=None, shots=1024, optimization_level=3, verbose=True): """ function: hardware_kernel arguments: X1: an (n, d) array of data points X2: a second array, or None for a symmetric kernel feature_map: the encoding circuit backend: the qpu to run on shots: samples per kernel entry optimization_level: transpiler effort verbose: print the circuit count and job id return: a tuple (kernel matrix, job) description: Evaluates a kernel on a real qpu in a single job. A symmetric kernel costs n(n-1)/2 circuits, so check the cost before calling this -- see backends.qpu_cost. """ # import here so the module imports without a runtime installed # from qiskit_ibm_runtime import SamplerV2 # fall back to the lab's default encoding # if feature_map is None: feature_map = make_feature_map() # a symmetric kernel only needs the upper triangle # symmetric = X2 is None if symmetric: X2 = X1 # build one circuit per pair we actually need # circuits, tags = [], [] if symmetric: for i in range(len(X1)): for j in range(i + 1, len(X1)): circuits.append(fidelity_circuit(X1[i], X1[j], feature_map)) tags.append((i, j)) else: for i in range(len(X1)): for j in range(len(X2)): circuits.append(fidelity_circuit(X1[i], X2[j], feature_map)) tags.append((i, j)) # report the expected cost before spending it # if verbose: cost = len(circuits) * QPU_SECONDS_PER_CIRCUIT print(f"{len(circuits)} circuits (~{cost:.0f}s QPU expected)") # transpile to the backend's native gates # pm = generate_preset_pass_manager(backend=backend, optimization_level=optimization_level) isa = [pm.run(c) for c in circuits] # submit everything as one job # job = SamplerV2(mode=backend).run(isa, shots=shots) if verbose: print("job id:", job.job_id()) result = job.result() # the kernel entry is the probability of the all-zeros outcome # K = np.eye(len(X1)) if symmetric else np.zeros((len(X1), len(X2))) zeros = "0" * feature_map.num_qubits for (i, j), pub in zip(tags, result): value = pub.data.meas.get_counts().get(zeros, 0) / shots K[i, j] = value if symmetric: K[j, i] = value # exit gracefully # return K, job # # end of function def kernel_stats(K): """ function: kernel_stats arguments: K: a kernel matrix return: a dict of summary statistics description: Summarizes a kernel matrix. A concentrated kernel has a mean off-diagonal near zero while its diagonal stays at one, meaning every point looks like a stranger to every other point. An svm handed that table can only memorize, which is the failure mode segment 2 puts on screen. """ # separate the off-diagonal entries from the diagonal # K = np.asarray(K) square = K.shape[0] == K.shape[1] off = ~np.eye(len(K), dtype=bool) if square else np.ones_like(K, bool) # exit gracefully # return {"mean_offdiag": float(K[off].mean()), "std_offdiag": float(K[off].std()), "min": float(K.min()), "max": float(K.max()), "mean_diag": float(np.diag(K).mean()) if square else None} # # end of function # # end of file