#!/usr/bin/env python # # file: qmllab/models.py # # revision history: # 20260810 (am): initial version for workshop lab 02 # # The two paths from session 3: # # path a a quantum kernel feeding a classical svm. the quantum # computer is a ruler; the learning stays classical. # path b a variational circuit. the circuit itself is the model and # there is no separate classical learner. # # Both are built here on the same data so they can be compared fairly. #------------------------------------------------------------------------------ # import system modules # import time # import third-party modules # import numpy as np from qiskit import QuantumCircuit from qiskit.circuit import ParameterVector from qiskit.circuit.library import RealAmplitudes from sklearn.model_selection import GridSearchCV from sklearn.svm import SVC # import qmllab modules # from .kernels import exact_kernel, make_feature_map #------------------------------------------------------------------------------ # # global variables are listed here # #------------------------------------------------------------------------------ # the soft-margin values searched by every svm in the lab # DEFAULT_C_GRID = [0.1, 1, 10, 50] #------------------------------------------------------------------------------ # # functions are listed here # #------------------------------------------------------------------------------ def classical_svm(X_train, y_train, X_test, y_test, kernel="rbf", C_grid=None): """ function: classical_svm arguments: X_train: the training features y_train: the training labels X_test: the test features y_test: the test labels kernel: any kernel name understood by sklearn C_grid: the soft-margin values to search return: a dict holding the fitted model and its scores description: Fits an ordinary sklearn svm. This is the number the quantum methods have to beat, so it is searched over C just as they are. """ # search the soft-margin parameter # search = GridSearchCV(SVC(kernel=kernel), {"C": C_grid or DEFAULT_C_GRID}, cv=3, n_jobs=-1).fit(X_train, y_train) model = search.best_estimator_ # exit gracefully # return {"model": model, "C": search.best_params_["C"], "train_acc": model.score(X_train, y_train), "test_acc": model.score(X_test, y_test), "n_support": int(model.n_support_.sum())} # # end of function def qsvm(X_train, y_train, X_test, y_test, feature_map=None, kernel_fn=exact_kernel, C_grid=None, **kernel_kwargs): """ function: qsvm arguments: X_train: the training features y_train: the training labels X_test: the test features y_test: the test labels feature_map: the encoding circuit kernel_fn: which kernel routine to use C_grid: the soft-margin values to search kernel_kwargs: forwarded to kernel_fn, for example shots return: a dict holding the model, both kernel matrices and the scores description: Path a. The svm is handed a precomputed kernel, so it never sees the coordinates at all -- only the table of similarities. That is the kernel trick from the lecture, with a quantum computer supplying the similarities. """ # fall back to the lab's default encoding # if feature_map is None: feature_map = make_feature_map() # build the training and test kernels, timing the quantum part # start = time.time() K_train = kernel_fn(X_train, feature_map=feature_map, **kernel_kwargs) K_test = kernel_fn(X_test, X_train, feature_map=feature_map, **kernel_kwargs) kernel_time = time.time() - start # fit a classical svm on the precomputed similarities # search = GridSearchCV(SVC(kernel="precomputed"), {"C": C_grid or DEFAULT_C_GRID}, cv=3, n_jobs=-1).fit(K_train, y_train) model = search.best_estimator_ # exit gracefully # return {"model": model, "C": search.best_params_["C"], "K_train": K_train, "K_test": K_test, "kernel_time": kernel_time, "train_acc": model.score(K_train, y_train), "test_acc": model.score(K_test, y_test), "n_support": int(model.n_support_.sum())} # # end of function def qsvm_from_kernels(K_train, y_train, K_test, y_test, C=10): """ function: qsvm_from_kernels arguments: K_train: the training kernel matrix y_train: the training labels K_test: the test kernel matrix y_test: the test labels C: the soft-margin value return: a dict holding the fitted model and its scores description: Fits on kernel matrices that already exist, which is how segment 4 reuses similarities measured on real hardware without recomputing anything. """ # fit directly on the supplied matrices # model = SVC(kernel="precomputed", C=C).fit(K_train, y_train) # exit gracefully # return {"model": model, "C": C, "train_acc": model.score(K_train, y_train), "test_acc": model.score(K_test, y_test), "n_support": int(model.n_support_.sum())} # # end of function def reuploading_circuit(n_features=2, n_qubits=2, layers=2): """ function: reuploading_circuit arguments: n_features: how many features each layer re-reads n_qubits: the width of the circuit layers: how many encode-then-rotate blocks to stack return: a tuple (circuit, input_params, weight_params) description: A data re-uploading classifier. The trick is to feed the data in again in every layer instead of only once at the start. A shallow circuit that sees the data once is a weak model; the same circuit re-reading the data each layer becomes a strong one. """ # one parameter vector for the data, one for the trainable angles # x = ParameterVector("x", n_features) theta = ParameterVector("t", layers * n_qubits * 3) circuit = QuantumCircuit(n_qubits) # stack the layers # index = 0 for _ in range(layers): # re-upload the data on every qubit # for q in range(n_qubits): circuit.ry(x[0], q) circuit.rz(x[1 % n_features], q) # apply the trainable rotations # for q in range(n_qubits): circuit.rx(theta[index], q) circuit.ry(theta[index + 1], q) circuit.rz(theta[index + 2], q) index += 3 # entangle so the qubits stop acting independently # for q in range(n_qubits - 1): circuit.cx(q, q + 1) # exit gracefully # return circuit, list(x), list(theta) # # end of function def vqc_reuploading(X_train, y_train, X_test, y_test, layers=2, n_qubits=2, maxiter=300, optimizer="COBYLA", seed=42): """ function: vqc_reuploading arguments: X_train: the training features y_train: the training labels X_test: the test features y_test: the test labels layers: how many times the circuit re-reads the data n_qubits: the width of the circuit maxiter: the optimizer's iteration budget optimizer: COBYLA, SPSA or ADAM seed: seeds the starting angles return: a dict holding the model, the loss history and the scores description: Path b, done well. The classifier wraps a data re-uploading circuit in an estimator network and trains its angles directly. """ # import here so that a plain import of this module stays light # from qiskit_machine_learning.algorithms import NeuralNetworkClassifier from qiskit_machine_learning.neural_networks import EstimatorQNN # build the circuit and wrap it as a neural network # circuit, inputs, weights = reuploading_circuit( n_features=X_train.shape[1], n_qubits=n_qubits, layers=layers) qnn = EstimatorQNN(circuit=circuit, input_params=inputs, weight_params=weights) # record the loss at every optimizer step so we can plot it # history = [] start_point = np.random.RandomState(seed).uniform( 0, 2 * np.pi, len(weights)) classifier = NeuralNetworkClassifier( qnn, optimizer=_optimizer(optimizer, maxiter), callback=lambda w, f: history.append(float(f)), initial_point=start_point) # train, remembering that this classifier wants labels of +/-1 # start = time.time() classifier.fit(X_train, 2 * y_train - 1) train_time = time.time() - start # exit gracefully # return {"model": classifier, "history": history, "train_time": train_time, "circuit": circuit, "n_params": len(weights), "train_acc": classifier.score(X_train, 2 * y_train - 1), "test_acc": classifier.score(X_test, 2 * y_test - 1)} # # end of function def vqc_naive(X_train, y_train, X_test, y_test, reps=2, ansatz_reps=3, maxiter=200, optimizer="COBYLA"): """ function: vqc_naive arguments: X_train: the training features y_train: the training labels X_test: the test features y_test: the test labels reps: repetitions of the feature map ansatz_reps: repetitions of the trainable ansatz maxiter: the optimizer's iteration budget optimizer: COBYLA, SPSA or ADAM return: a dict holding the model, the loss history and the scores description: Path b, done the obvious way: encode once, then bolt on a generic ansatz. It is kept in the lab precisely because it underperforms -- it is the control that gives the re-uploading result its meaning. """ # import here so that a plain import of this module stays light # from qiskit_machine_learning.algorithms import VQC # build the classifier, recording the loss as it trains # history = [] classifier = VQC( feature_map=make_feature_map("zz", X_train.shape[1], reps=reps), ansatz=RealAmplitudes(X_train.shape[1], reps=ansatz_reps), optimizer=_optimizer(optimizer, maxiter), callback=lambda w, f: history.append(float(f))) # train on the labels as given # start = time.time() classifier.fit(X_train, y_train) train_time = time.time() - start # exit gracefully # return {"model": classifier, "history": history, "train_time": train_time, "train_acc": classifier.score(X_train, y_train), "test_acc": classifier.score(X_test, y_test)} # # end of function def _optimizer(name, maxiter): """ function: _optimizer arguments: name: COBYLA, SPSA or ADAM maxiter: the iteration budget return: a qiskit-machine-learning optimizer instance description: Resolves an optimizer by name so callers can pass a plain string. """ # import here so that a plain import of this module stays light # from qiskit_machine_learning.optimizers import ADAM, COBYLA, SPSA # look the optimizer up, rejecting anything unknown # table = {"COBYLA": COBYLA, "SPSA": SPSA, "ADAM": ADAM} if name.upper() not in table: raise ValueError( f"unknown optimizer {name!r}; choose from {list(table)}") # exit gracefully # return table[name.upper()](maxiter=maxiter) # # end of function # # end of file