diff --git a/benchmarks/benchmark_qsvr_kernel.py b/benchmarks/benchmark_qsvr_kernel.py new file mode 100644 index 00000000..0f8a4175 --- /dev/null +++ b/benchmarks/benchmark_qsvr_kernel.py @@ -0,0 +1,78 @@ +"""Compare the original full kernel loop with symmetry reuse on CPUQVM. + +Run from the repository root with PYTHONPATH=pyqpanda-algorithm. +This measures local simulator runtime, not quantum hardware speedup. +""" + +import argparse +import importlib.metadata +import json +import platform +import statistics +from time import perf_counter + +import numpy as np + +from pyqpanda_alg.QSVR import Quantum_SVR + + +def dense_kernel(model, x): + matrix = np.empty((len(x), len(x))) + for i in range(len(x)): + for j in range(len(x)): + matrix[i, j] = model.dist(x[i], x[j]) + return matrix + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sizes", nargs="+", type=int, default=[16, 32, 64]) + parser.add_argument("--repeats", type=int, default=3) + args = parser.parse_args() + if args.repeats < 1 or any(size < 2 for size in args.sizes): + parser.error("repeats must be positive and sample sizes must be at least 2") + + results = [] + for size in args.sizes: + rng = np.random.default_rng(20260907 + size) + model = Quantum_SVR(rng.normal(size=(size, 2)), rng.normal(size=size)) + x = model.x + model.dist(x[0], x[1]) # Warm the simulator before either timed path. + durations = {"dense": [], "symmetric": []} + max_error = 0.0 + for repeat in range(args.repeats): + order = ["dense", "symmetric"] + if repeat % 2: + order.reverse() + matrices = {} + for method in order: + start = perf_counter() + matrices[method] = (dense_kernel(model, x) if method == "dense" + else model.k_kernel(x, x.copy())) + durations[method].append(perf_counter() - start) + np.testing.assert_allclose(matrices["dense"], matrices["symmetric"], + atol=1e-12, rtol=1e-12) + max_error = max(max_error, float(np.max(np.abs( + matrices["dense"] - matrices["symmetric"])))) + dense = statistics.median(durations["dense"]) + symmetric = statistics.median(durations["symmetric"]) + results.append({ + "samples": size, + "dense_calls": size * size, + "symmetric_calls": size * (size + 1) // 2, + "dense_median_seconds": dense, + "symmetric_median_seconds": symmetric, + "speedup": dense / symmetric, + "max_absolute_error": max_error, + }) + print(json.dumps({ + "python": platform.python_version(), + "pyqpanda3": importlib.metadata.version("pyqpanda3"), + "numpy": np.__version__, + "repeats": args.repeats, + "results": results, + }, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/docs/qsvr-kernel-symmetry.md b/docs/qsvr-kernel-symmetry.md new file mode 100644 index 00000000..2bbbf36d --- /dev/null +++ b/docs/qsvr-kernel-symmetry.md @@ -0,0 +1,73 @@ +# QSVR training-kernel symmetry + +## Release note + +`Quantum_SVR.k_kernel(X, Y)` now evaluates only one triangle when `X` and +`Y` contain the same samples in the same order. The fidelity kernel satisfies +`K(x, y) = ||^2 = K(y, x)`, so the other triangle can reuse +those values. Equal array copies and equal nested lists also benefit. + +For `n` training samples, simulator calls decrease from `n^2` to +`n(n+1)/2`. Diagonal entries are still evaluated through `dist`; they are +not replaced by constants. Distinct datasets use the original full pairwise +evaluation, including square matrices with different sample order. There is +no persistent cache, and the public method signatures are unchanged. + +The optimization applies to the existing symmetric fidelity kernel. +Custom `dist` overrides must preserve that symmetry. Differences from the +old full loop can occur at floating-point roundoff because one mirrored +entry replaces a separately simulated evaluation. + +## Validation + +Base: `OriginQ/pyqpanda-algorithm` develop +`5f973efccb84bc193157d1ccebe32137e307293b`. + +- Focused tests before the change: 7 passed, 4 failed. The four failures + detected 16 simulator calls where 10 suffice for four training samples. +- Focused tests after the change: 11 passed. +- Repository pytest run: 29 passed in 23.78 seconds. +- Kernel entries agree within absolute/relative tolerance `1e-12` with + independent NumPy state vectors constructed from RX, RY, and CZ matrices. +- Cases cover same-object inputs, equal copies, equal lists, duplicate + points, reordered samples, rectangular matrices, singletons, and empty + inputs. An SVR integration case compares against an independently + constructed precomputed kernel, allowing libsvm's stopping tolerance. + +Test filenames follow the repository's `Test_*.py` discovery convention. +The prose contribution guide's dotted `feature.test.py` name cannot be +imported by pytest's current default import mode. + +## Reproduce + +From the repository root, with the package's dependencies plus pytest +installed (including pandas and scikit-learn used by existing imports): + +```bash +PYTHONPATH=pyqpanda-algorithm MPLBACKEND=Agg python -m pytest \ + -c test/pytest.ini -o addopts='' test/QAlgBase/Test_QSVR_kernel.py -q + +PYTHONPATH=pyqpanda-algorithm MPLBACKEND=Agg python -m pytest \ + -c test/pytest.ini -o addopts='' test -q + +PYTHONPATH=pyqpanda-algorithm MPLBACKEND=Agg python \ + benchmarks/benchmark_qsvr_kernel.py +``` + +`-o addopts=''` omits the optional Allure-reporting plugin flags. + +## Local benchmark + +Python 3.12.13, PyQPanda3 0.4.1, NumPy 2.3.5, Linux x86_64. +Three repetitions per size, fixed random seeds, median elapsed time, +alternating evaluation order after a simulator warm-up: + +| Samples | Old calls | New calls | Old median | New median | Speedup | +|---:|---:|---:|---:|---:|---:| +| 16 | 256 | 136 | 12.90 ms | 6.74 ms | 1.91x | +| 32 | 1,024 | 528 | 50.71 ms | 26.17 ms | 1.94x | +| 64 | 4,096 | 2,080 | 244.51 ms | 116.94 ms | 2.09x | + +Maximum absolute matrix difference: `1.1102230246251565e-15`. +These are local simulator measurements. They do not establish quantum +advantage or guarantee the same elapsed-time improvement on other systems. diff --git a/pyqpanda-algorithm/pyqpanda_alg/QSVR/QSVR.py b/pyqpanda-algorithm/pyqpanda_alg/QSVR/QSVR.py index 4ee3c4da..e9d50b53 100644 --- a/pyqpanda-algorithm/pyqpanda_alg/QSVR/QSVR.py +++ b/pyqpanda-algorithm/pyqpanda_alg/QSVR/QSVR.py @@ -119,10 +119,20 @@ def dist(self, x, y): return re def k_kernel(self, X, Y): + """Build a fidelity kernel, reusing symmetry for equal sample arrays. + + For X == Y, K[i, j] = K[j, i] because fidelity is symmetric. Only + n * (n + 1) / 2 simulator calls are needed instead of n ** 2. + Distinct sample arrays retain the full pairwise evaluation, even + when their shapes match. Diagonal values are still simulated. + """ matrix = np.zeros((len(X), len(Y))) + symmetric = np.array_equal(X, Y) for i in range(len(X)): - for j in range(len(Y)): + for j in range(i if symmetric else 0, len(Y)): matrix[i][j] = self.dist(X[i], Y[j]) + if symmetric: + matrix[j][i] = matrix[i][j] return matrix def get_res(self): diff --git a/test/QAlgBase/Test_QSVR_kernel.py b/test/QAlgBase/Test_QSVR_kernel.py new file mode 100644 index 00000000..acf879ea --- /dev/null +++ b/test/QAlgBase/Test_QSVR_kernel.py @@ -0,0 +1,103 @@ +"""Check kernel values independently and guard the simulator-call budget.""" + +import numpy as np +import pytest +from sklearn.svm import SVR + +from pyqpanda_alg.QSVR import Quantum_SVR + + +def encoded_state(point): + """Evaluate the two-qubit feature map with NumPy gate matrices only.""" + def rx(angle): + c, s = np.cos(angle / 2), np.sin(angle / 2) + return np.array([[c, -1j * s], [-1j * s, c]]) + + def ry(angle): + c, s = np.cos(angle / 2), np.sin(angle / 2) + return np.array([[c, -s], [s, c]]) + + # q0 is the least significant bit. H on both qubits prepares |++>. + state = np.ones(4, dtype=complex) / 2 + state = np.kron(ry(point[1]), ry(point[0])) @ state + state = np.diag([1, 1, 1, -1]) @ state + return np.kron(rx(point[0]), rx(point[1])) @ state + + +def reference_kernel(x, y): + result = np.empty((len(x), len(y))) + for i, first in enumerate(x): + for j, second in enumerate(y): + result[i, j] = abs(np.vdot(encoded_state(second), + encoded_state(first))) ** 2 + return result + + +@pytest.fixture +def model(): + x = np.array([[-0.9, 0.1], [0.3, -0.4], [1.2, 0.8], [-0.2, 1.7]]) + return Quantum_SVR(x, np.array([0.1, -0.3, 1.4, 0.7])) + + +@pytest.mark.parametrize("case, expected_calls", [ + ("same_object", 10), + ("equal_copy", 10), + ("equal_lists", 10), + ("duplicates", 10), + ("permuted", 16), + ("rectangular", 8), + ("singleton", 1), + ("empty_left", 0), + ("empty_right", 0), + ("empty_both", 0), +]) +def test_qsvr_kernel_values_and_call_budget(model, monkeypatch, case, expected_calls): + x = model.x.copy() + if case == "same_object": + y = x + elif case == "equal_copy": + y = x.copy() + elif case == "equal_lists": + x, y = x.tolist(), x.tolist() + elif case == "duplicates": + x[1] = x[0] + y = x.copy() + elif case == "permuted": + y = x[::-1].copy() + elif case == "rectangular": + y = x[:2].copy() + elif case == "singleton": + x, y = x[:1], x[:1].copy() + elif case == "empty_left": + x, y = x[:0], x.copy() + elif case == "empty_right": + y = x[:0] + else: + x, y = x[:0], x[:0].copy() + + calls = [] + original_dist = model.dist + + def measured_dist(first, second): + calls.append((first, second)) + return original_dist(first, second) + + monkeypatch.setattr(model, "dist", measured_dist) + actual = model.k_kernel(x, y) + np.testing.assert_allclose(actual, reference_kernel(x, y), atol=1e-12, rtol=1e-12) + assert actual.shape == (len(x), len(y)) + assert len(calls) == expected_calls + + +def test_qsvr_predictions_match_independent_precomputed_kernel(model): + expected_model = SVR(kernel="precomputed", gamma=0.1) + kernel = reference_kernel(model.x, model.x) + expected_model.fit(kernel, model.y) + + predicted, targets = model.get_res() + + # Gate-level roundoff can change libsvm's last step within its stopping + # tolerance; matrix entries themselves are checked to 1e-12 above. + np.testing.assert_allclose(predicted, expected_model.predict(kernel), + atol=2 * expected_model.tol, rtol=0) + np.testing.assert_array_equal(targets, model.y)