From 75fc2f876567f6834d53c043d64393572a3cfd7c Mon Sep 17 00:00:00 2001 From: Shashi Jagtap Date: Fri, 11 Sep 2026 11:00:05 +0000 Subject: [PATCH 1/2] Spike: QuantumSVM feature_map=auto (Qmes-style) Add classical auto selection for QuantumSVM encodings. Prefer optional Qmes when installed; otherwise use a lightweight meta-feature heuristic mapped onto the SuperQuantX feature-map registry. Manual feature_map strings remain unchanged. --- CHANGELOG.md | 5 + docs/design/qmes-feature-map-auto.md | 107 ++++++ pyproject.toml | 11 + src/superquantx/algorithms/quantum_svm.py | 64 +++- src/superquantx/backends/cirq_backend.py | 13 + src/superquantx/backends/pennylane_backend.py | 14 + src/superquantx/backends/qiskit_backend.py | 12 + src/superquantx/backends/simulator_backend.py | 10 + src/superquantx/utils/__init__.py | 22 ++ src/superquantx/utils/feature_map_auto.py | 337 ++++++++++++++++++ src/superquantx/utils/feature_map_registry.py | 103 ++++++ tests/unit/test_feature_map_auto.py | 227 ++++++++++++ 12 files changed, 918 insertions(+), 7 deletions(-) create mode 100644 docs/design/qmes-feature-map-auto.md create mode 100644 src/superquantx/utils/feature_map_auto.py create mode 100644 src/superquantx/utils/feature_map_registry.py create mode 100644 tests/unit/test_feature_map_auto.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d849c97..5ce6493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Spike: `QuantumSVM(feature_map="auto")` Qmes-style encoding selection with + optional Qmes extra and a classical heuristic fallback + (`docs/design/qmes-feature-map-auto.md`). + ### Planned - Quantum reinforcement learning algorithms - Quantum natural language processing diff --git a/docs/design/qmes-feature-map-auto.md b/docs/design/qmes-feature-map-auto.md new file mode 100644 index 0000000..feaee03 --- /dev/null +++ b/docs/design/qmes-feature-map-auto.md @@ -0,0 +1,107 @@ +# Design note: QuantumSVM `feature_map="auto"` (Qmes-style spike) + +Status: spike on branch `spike/qmes-feature-map-auto`. +Inspired by [Qmes](https://github.com/tungduy1704/Qmes) ([arXiv:2609.04652](https://arxiv.org/abs/2609.04652)). + +## Why + +QuantumSVM performance depends strongly on the encoding circuit. Exhaustive +kernel evaluation of every candidate is expensive. Qmes shows that classical +dataset complexity features can rank encoding circuits without quantum +evaluation at inference time. This spike adds a practical `feature_map="auto"` +path that recommends a name from SuperQuantX's existing feature-map registry. + +## What we adopted + +1. **Registry** (`utils/feature_map_registry.py`) + Canonical names: `ZZFeatureMap`, `PauliFeatureMap`, `AmplitudeMap`, + `AngleEncoding`, `ZFeatureMap`. Manual string aliases still work. + +2. **Qmes mapping** (`QMES_TO_SUPERQUANTX`) + + | Qmes pool | SuperQuantX registry | Rationale | + |---|---|---| + | unit | AmplitudeMap | Amplitude / square-root style embedding | + | SRx | AngleEncoding | Separable single-qubit rotations | + | RY | AngleEncoding | Separable RY angle encoding | + | HERx | PauliFeatureMap | Hardware-efficient rotations + CX | + | RY_CX | PauliFeatureMap | Angle + linear CX | + | ZFM | ZFeatureMap | Separable Z rotations | + | HD | ZZFeatureMap | High-dim / entangling analogue | + +3. **Selector** (`utils/feature_map_auto.py`) + - `mode="auto"`: try Qmes if installed, else heuristic. + - `mode="qmes"`: require optional `[qmes]` extra. + - `mode="heuristic"`: local meta-features only (no problexity, no quantum). + - Heuristic uses sample/feature ratio, mean |correlation|, class imbalance, + and a cheap logistic-regression separability proxy. + +4. **QuantumSVM** + - `feature_map="auto"` or `None` resolves at `fit()` time. + - Stores `selected_feature_map_` and `feature_map_recommendation_`. + - Existing manual strings/objects are unchanged. + +5. **Backends** + Simulator / PennyLane / Qiskit / Cirq `create_feature_map` recognize + `AngleEncoding` and `ZFeatureMap` (AmplitudeMap already present). + +## What we deferred + +- Full Qmes circuit pool implementations (unit, SRx, HERx, RY_CX, HD gate + schedules, Qsun backend, fixed 4-qubit PCA pipeline). +- Shipping or re-training a SuperQuantX-native pairwise OvO recommender on + our own meta-dataset and backends. +- Problexity's full 22-d / 12-d complexity vectors in the default heuristic. +- Noise-aware or hardware-aware recommendation. +- Regression-task auto selection wired into a QuantumSVR (QSVM remains + classification-focused). +- Changing the default constructor value away from `ZZFeatureMap` (auto is + opt-in). + +## Circuit-pool gaps + +Qmes evaluates seven Qsun encodings with a shared 4-qubit PCA + MinMax +preprocess. SuperQuantX backends implement a smaller, differently shaped set. +Closest-name mapping is approximate: + +- Exact Qmes unit / HD gate lists are not reproduced. +- `AmplitudeMap` remains a partial/placeholder on several backends. +- Entanglement patterns (linear CX vs ISWAP brickwork) differ. +- Recommendation quality of the heuristic is unvalidated against Qmes regret. + +## How to try + +```python +from sklearn.datasets import make_classification +from superquantx.algorithms import QuantumSVM + +X, y = make_classification(n_samples=40, n_features=4, n_informative=3, random_state=0) +qsvm = QuantumSVM(backend="simulator", feature_map="auto", shots=100) +qsvm.fit(X, y) +print(qsvm.selected_feature_map_) +print(qsvm.feature_map_recommendation_) +``` + +Optional Qmes path: + +```bash +pip install "superquantx[qmes]" +# or: pip install "git+https://github.com/tungduy1704/Qmes.git" +``` + +```python +qsvm = QuantumSVM( + backend="simulator", + feature_map="auto", + auto_feature_map_mode="qmes", +) +``` + +## Follow-ups + +1. Expand the registry with first-class Qmes-like encodings (or adapters). +2. Build a SuperQuantX meta-dataset on simulator kernels and train a pairwise + recommender (or fine-tune Qmes with registered SQX circuits). +3. Expose `recommend_feature_map` in the CLI / docs tutorials. +4. Add regression auto-selection once a Quantum kernel regressor lands. +5. Benchmark heuristic vs Qmes vs fixed `ZZFeatureMap` on shared datasets. diff --git a/pyproject.toml b/pyproject.toml index efcb618..29b8736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,15 @@ ocean = [ "dimod>=0.12.22", ] +# Optional Qmes-backed feature_map="auto" recommender +# (https://github.com/tungduy1704/Qmes , arXiv:2609.04652). +# Install from git until a stable PyPI release is available. +qmes = [ + "Qmes @ git+https://github.com/tungduy1704/Qmes.git", + "problexity>=0.5.0", + "pandas>=1.3", +] + # Development dependencies - latest versions dev = [ "pytest>=8.0.0", @@ -265,6 +274,8 @@ module = [ "optuna.*", "hyperopt.*", "cvxpy.*", + "Qmes.*", + "problexity.*", ] ignore_missing_imports = true diff --git a/src/superquantx/algorithms/quantum_svm.py b/src/superquantx/algorithms/quantum_svm.py index a5a0e44..e4d27a5 100644 --- a/src/superquantx/algorithms/quantum_svm.py +++ b/src/superquantx/algorithms/quantum_svm.py @@ -32,12 +32,16 @@ class QuantumSVM(SupervisedQuantumAlgorithm): Args: backend: Quantum backend for circuit execution - feature_map: Type of quantum feature map ('ZZFeatureMap', 'PauliFeatureMap', etc.) + feature_map: Feature map name ('ZZFeatureMap', 'PauliFeatureMap', + 'AmplitudeMap', 'AngleEncoding', 'ZFeatureMap'), a custom object, + or ``"auto"`` / ``None`` to select via the Qmes-inspired recommender. feature_map_reps: Number of repetitions in the feature map C: Regularization parameter for SVM gamma: Kernel coefficient (for RBF-like quantum kernels) quantum_kernel: Custom quantum kernel function shots: Number of measurement shots + auto_feature_map_mode: When feature_map is auto/None, selection mode + (``auto``, ``qmes``, or ``heuristic``). **kwargs: Additional parameters Example: @@ -45,19 +49,23 @@ class QuantumSVM(SupervisedQuantumAlgorithm): >>> qsvm.fit(X_train, y_train) >>> predictions = qsvm.predict(X_test) >>> accuracy = qsvm.score(X_test, y_test) + >>> # Spike: automatic encoding selection (Qmes if installed, else heuristic) + >>> qsvm_auto = QuantumSVM(backend='simulator', feature_map='auto') + >>> qsvm_auto.fit(X_train, y_train) """ def __init__( self, backend: str | Any, - feature_map: str = 'ZZFeatureMap', + feature_map: str | Any | None = 'ZZFeatureMap', feature_map_reps: int = 2, C: float = 1.0, gamma: float | None = None, quantum_kernel: Callable | None = None, shots: int = 1024, normalize_data: bool = True, + auto_feature_map_mode: str = 'auto', **kwargs ) -> None: super().__init__(backend=backend, shots=shots, **kwargs) @@ -68,6 +76,7 @@ def __init__( self.gamma = gamma self.quantum_kernel = quantum_kernel self.normalize_data = normalize_data + self.auto_feature_map_mode = auto_feature_map_mode # Classical components self.svm = None @@ -76,19 +85,56 @@ def __init__( # Quantum components self.kernel_matrix_ = None self.feature_map_circuit_ = None + self.selected_feature_map_: str | Any | None = None + self.feature_map_recommendation_: dict[str, Any] | None = None # Training data storage (needed for kernel computation) self.X_train_ = None - logger.info(f"Initialized QuantumSVM with feature_map={feature_map}, reps={feature_map_reps}") + logger.info( + f"Initialized QuantumSVM with feature_map={feature_map}, " + f"reps={feature_map_reps}" + ) - def _create_feature_map(self, n_features: int) -> Any: + def _resolve_feature_map_name( + self, X: np.ndarray, y: np.ndarray | None = None + ) -> str | Any: + """Resolve feature_map='auto'/None or normalize a manual string.""" + from ..utils.feature_map_auto import resolve_feature_map + + recommendation = resolve_feature_map( + self.feature_map, + X=X, + y=y, + auto_mode=self.auto_feature_map_mode, # type: ignore[arg-type] + task='classification', + ) + self.feature_map_recommendation_ = recommendation.as_dict() + self.selected_feature_map_ = recommendation.feature_map + if recommendation.source in ('auto', 'qmes', 'heuristic', 'default'): + logger.info( + "Auto-selected feature_map=%s via %s", + recommendation.feature_map, + recommendation.source, + ) + return recommendation.feature_map + + def _create_feature_map(self, n_features: int, feature_map: str | Any | None = None) -> Any: """Create quantum feature map circuit.""" + fm = feature_map if feature_map is not None else self.selected_feature_map_ + if fm is None: + fm = self.feature_map if self.feature_map not in (None, 'auto') else 'ZZFeatureMap' + + # Custom non-string feature map objects are returned as-is for backends + # that already understand them. + if not isinstance(fm, str): + return fm + try: if hasattr(self.backend, 'create_feature_map'): return self.backend.create_feature_map( n_features=n_features, - feature_map=self.feature_map, + feature_map=fm, reps=self.feature_map_reps ) else: @@ -166,8 +212,9 @@ def fit(self, X: np.ndarray, y: np.ndarray, **kwargs) -> 'QuantumSVM': self.X_train_ = X.copy() - # Create quantum feature map - self.feature_map_circuit_ = self._create_feature_map(X.shape[1]) + # Resolve auto/manual feature map, then build the circuit + resolved = self._resolve_feature_map_name(X, y) + self.feature_map_circuit_ = self._create_feature_map(X.shape[1], resolved) # Compute quantum kernel matrix logger.info("Computing quantum kernel matrix...") @@ -190,6 +237,8 @@ def fit(self, X: np.ndarray, y: np.ndarray, **kwargs) -> 'QuantumSVM': 'train_accuracy': train_accuracy, 'n_support_vectors': self.svm.n_support_, 'kernel_matrix_shape': self.kernel_matrix_.shape, + 'selected_feature_map': self.selected_feature_map_, + 'feature_map_recommendation': self.feature_map_recommendation_, }) logger.info(f"Training completed. Accuracy: {train_accuracy:.3f}, " @@ -334,6 +383,7 @@ def get_params(self, deep: bool = True) -> dict[str, Any]: 'C': self.C, 'gamma': self.gamma, 'normalize_data': self.normalize_data, + 'auto_feature_map_mode': self.auto_feature_map_mode, }) return params diff --git a/src/superquantx/backends/cirq_backend.py b/src/superquantx/backends/cirq_backend.py index 4a9c6e2..d0b6b1c 100644 --- a/src/superquantx/backends/cirq_backend.py +++ b/src/superquantx/backends/cirq_backend.py @@ -250,12 +250,25 @@ def create_feature_map(self, n_features: int, feature_map: str, reps: int = 1) - circuit_info = self._create_pauli_feature_map(n_features, reps) elif feature_map == 'AmplitudeMap': circuit_info = self._create_amplitude_map(n_features) + elif feature_map == 'ZFeatureMap': + circuit_info = self._create_z_feature_map(n_features, reps) + elif feature_map == 'AngleEncoding': + circuit_info = self._create_angle_encoding_map(n_features) else: logger.warning(f"Unknown feature map '{feature_map}', using angle encoding") circuit_info = self._create_angle_encoding_map(n_features) return circuit_info + def _create_z_feature_map(self, n_features: int, reps: int) -> tuple[Any, list]: + """Create separable Z-rotation feature map.""" + qubits = [cirq.LineQubit(i) for i in range(n_features)] + circuit = cirq.Circuit() + for r in range(reps): + for i, q in enumerate(qubits): + circuit.append(cirq.rz(cirq.Symbol(f'x_{i}_z_{r}'))(q)) + return circuit, qubits + def _create_zz_feature_map(self, n_features: int, reps: int) -> tuple[Any, list]: """Create ZZ feature map circuit.""" qubits = [cirq.LineQubit(i) for i in range(n_features)] diff --git a/src/superquantx/backends/pennylane_backend.py b/src/superquantx/backends/pennylane_backend.py index 3ce4df6..82e678c 100644 --- a/src/superquantx/backends/pennylane_backend.py +++ b/src/superquantx/backends/pennylane_backend.py @@ -149,10 +149,24 @@ def create_feature_map(self, n_features: int, feature_map: str, reps: int = 1) - return self._create_pauli_feature_map(n_features, reps) elif feature_map == 'AmplitudeMap': return self._create_amplitude_map(n_features) + elif feature_map == 'ZFeatureMap': + return self._create_z_feature_map(n_features, reps) + elif feature_map == 'AngleEncoding': + return self._create_angle_encoding_map(n_features) else: logger.warning(f"Unknown feature map '{feature_map}', using angle encoding") return self._create_angle_encoding_map(n_features) + def _create_z_feature_map(self, n_features: int, reps: int) -> Callable: + """Create separable Z-rotation feature map.""" + def z_feature_map(x): + for r in range(reps): + for i in range(min(n_features, self.wires)): + qml.RZ(2.0 * x[i], wires=i) + return [qml.expval(qml.PauliZ(i)) for i in range(min(n_features, self.wires))] + + return z_feature_map + def _create_zz_feature_map(self, n_features: int, reps: int) -> Callable: """Create ZZ feature map circuit.""" def zz_feature_map(x): diff --git a/src/superquantx/backends/qiskit_backend.py b/src/superquantx/backends/qiskit_backend.py index e463c4c..8d2037c 100644 --- a/src/superquantx/backends/qiskit_backend.py +++ b/src/superquantx/backends/qiskit_backend.py @@ -289,10 +289,22 @@ def create_feature_map(self, n_features: int, feature_map: str, reps: int = 1) - return self._create_pauli_feature_map(n_features, reps) elif feature_map == 'AmplitudeMap': return self._create_amplitude_map(n_features) + elif feature_map == 'ZFeatureMap': + return self._create_z_feature_map(n_features, reps) + elif feature_map == 'AngleEncoding': + return self._create_angle_encoding_map(n_features) else: logger.warning(f"Unknown feature map '{feature_map}', using angle encoding") return self._create_angle_encoding_map(n_features) + def _create_z_feature_map(self, n_features: int, reps: int) -> Any: + """Create separable Z-rotation feature map.""" + circuit = QuantumCircuit(n_features) + for r in range(reps): + for i in range(n_features): + circuit.rz(0.0, i) + return circuit + def _create_pauli_feature_map(self, n_features: int, reps: int) -> Any: """Create Pauli feature map circuit.""" circuit = QuantumCircuit(n_features) diff --git a/src/superquantx/backends/simulator_backend.py b/src/superquantx/backends/simulator_backend.py index 99467d8..5d9f083 100644 --- a/src/superquantx/backends/simulator_backend.py +++ b/src/superquantx/backends/simulator_backend.py @@ -480,6 +480,16 @@ def feature_map_circuit(x): for i in range(n_features - 1): circuit = self.add_gate(circuit, 'CNOT', [i, i + 1]) + elif feature_map == 'ZFeatureMap': + for i in range(n_features): + circuit = self.add_gate(circuit, 'RZ', i, [2.0 * x[i]]) + + elif feature_map in ('AngleEncoding', 'AmplitudeMap'): + # AmplitudeMap falls back to angle encoding on the pure + # simulator (full amplitude prep is backend-specific). + for i in range(n_features): + circuit = self.add_gate(circuit, 'RY', i, [x[i]]) + else: # Default angle encoding for i in range(n_features): circuit = self.add_gate(circuit, 'RY', i, [x[i]]) diff --git a/src/superquantx/utils/__init__.py b/src/superquantx/utils/__init__.py index 61ac034..d97c1ac 100644 --- a/src/superquantx/utils/__init__.py +++ b/src/superquantx/utils/__init__.py @@ -22,6 +22,19 @@ pauli_feature_map, zz_feature_map, ) +from .feature_map_registry import ( + FEATURE_MAP_REGISTRY, + QMES_TO_SUPERQUANTX, + list_feature_maps, + map_qmes_circuit, + normalize_feature_map_name, +) +from .feature_map_auto import ( + FeatureMapRecommendation, + qmes_available, + recommend_feature_map, + resolve_feature_map, +) from .optimization import ( adam_optimizer, gradient_descent, @@ -68,6 +81,15 @@ "create_feature_map", "pauli_feature_map", "zz_feature_map", + "FEATURE_MAP_REGISTRY", + "QMES_TO_SUPERQUANTX", + "list_feature_maps", + "map_qmes_circuit", + "normalize_feature_map_name", + "FeatureMapRecommendation", + "qmes_available", + "recommend_feature_map", + "resolve_feature_map", # Quantum utilities "fidelity", diff --git a/src/superquantx/utils/feature_map_auto.py b/src/superquantx/utils/feature_map_auto.py new file mode 100644 index 0000000..5cdb06f --- /dev/null +++ b/src/superquantx/utils/feature_map_auto.py @@ -0,0 +1,337 @@ +"""Qmes-inspired automatic feature-map selection for QuantumSVM. + +Selection is entirely classical at inference time (no quantum evaluation). + +Strategy: +1. If the optional ``Qmes`` package is importable, call its pre-trained + recommender and map the top circuit onto SuperQuantX's registry. +2. Otherwise (or if Qmes fails), run a lightweight meta-feature heuristic + that picks among SuperQuantX registry names only. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Literal + +import numpy as np + +from .feature_map_registry import ( + DEFAULT_FEATURE_MAP, + QMES_TO_SUPERQUANTX, + map_qmes_circuit, + normalize_feature_map_name, +) + + +logger = logging.getLogger(__name__) + +AutoMode = Literal["auto", "qmes", "heuristic"] + + +@dataclass +class FeatureMapRecommendation: + """Result of automatic feature-map selection.""" + + feature_map: str + source: str + ranking: list[str] = field(default_factory=list) + qmes_ranking: list[str] = field(default_factory=list) + meta_features: dict[str, float] = field(default_factory=dict) + details: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return { + "feature_map": self.feature_map, + "source": self.source, + "ranking": list(self.ranking), + "qmes_ranking": list(self.qmes_ranking), + "meta_features": dict(self.meta_features), + "details": dict(self.details), + } + + +def qmes_available() -> bool: + """Return True if the optional Qmes package can be imported.""" + try: + import Qmes # noqa: F401 + + return True + except ImportError: + return False + + +def extract_lightweight_meta_features( + X: np.ndarray, + y: np.ndarray | None = None, +) -> dict[str, float]: + """Compute cheap classical descriptors used by the heuristic fallback. + + These are intentionally simpler than Qmes/problexity measures so the + spike has no hard dependency on problexity when Qmes is absent. + """ + X = np.asarray(X, dtype=float) + if X.ndim != 2: + raise ValueError(f"X must be 2D, got shape {X.shape}") + + n_samples, n_features = X.shape + feats: dict[str, float] = { + "n_samples": float(n_samples), + "n_features": float(n_features), + "samples_per_feature": float(n_samples) / max(n_features, 1), + "feature_std_mean": float(np.mean(np.std(X, axis=0))) if n_samples > 1 else 0.0, + "feature_corr_abs_mean": _mean_abs_corr(X), + } + + if y is not None: + y = np.asarray(y) + classes, counts = np.unique(y, return_counts=True) + feats["n_classes"] = float(len(classes)) + probs = counts.astype(float) / max(n_samples, 1) + # Normalized entropy imbalance in [0, 1]; 0 = balanced binary. + if len(classes) > 1: + entropy = -np.sum(probs * np.log(probs + 1e-12)) + feats["class_imbalance"] = float( + 1.0 - entropy / np.log(len(classes)) + ) + else: + feats["class_imbalance"] = 0.0 + feats["linear_sep_proxy"] = _linear_separability_proxy(X, y) + else: + feats["n_classes"] = 0.0 + feats["class_imbalance"] = 0.0 + feats["linear_sep_proxy"] = 0.5 + + return feats + + +def _mean_abs_corr(X: np.ndarray) -> float: + if X.shape[0] < 3 or X.shape[1] < 2: + return 0.0 + std = np.std(X, axis=0) + keep = std > 1e-12 + if np.count_nonzero(keep) < 2: + return 0.0 + corr = np.corrcoef(X[:, keep], rowvar=False) + if corr.ndim == 0: + return 0.0 + mask = ~np.eye(corr.shape[0], dtype=bool) + vals = np.abs(corr[mask]) + vals = vals[np.isfinite(vals)] + return float(np.mean(vals)) if vals.size else 0.0 + + +def _linear_separability_proxy(X: np.ndarray, y: np.ndarray) -> float: + """Train a tiny linear model; higher score => more linearly separable.""" + try: + from sklearn.linear_model import LogisticRegression + from sklearn.model_selection import cross_val_score + from sklearn.preprocessing import LabelEncoder, StandardScaler + except ImportError: + return 0.5 + + y_enc = LabelEncoder().fit_transform(y) + if len(np.unique(y_enc)) < 2 or X.shape[0] < 6: + return 0.5 + + Xs = StandardScaler().fit_transform(X) + # Cap size for speed in the selector path. + if Xs.shape[0] > 200: + rng = np.random.default_rng(0) + idx = rng.choice(Xs.shape[0], size=200, replace=False) + Xs, y_enc = Xs[idx], y_enc[idx] + + clf = LogisticRegression(max_iter=200, solver="lbfgs") + try: + n_splits = 3 if Xs.shape[0] >= 15 else 2 + scores = cross_val_score(clf, Xs, y_enc, cv=n_splits, scoring="accuracy") + return float(np.mean(scores)) + except Exception: + clf.fit(Xs, y_enc) + return float(clf.score(Xs, y_enc)) + + +def recommend_feature_map_heuristic( + X: np.ndarray, + y: np.ndarray | None = None, +) -> FeatureMapRecommendation: + """Select a registry feature map from lightweight meta-features only.""" + meta = extract_lightweight_meta_features(X, y) + n_features = meta["n_features"] + spp = meta["samples_per_feature"] + lin = meta["linear_sep_proxy"] + corr = meta["feature_corr_abs_mean"] + imbalance = meta["class_imbalance"] + + # Prefer compact amplitude embedding when dimensionality is high and data + # is scarce relative to features (inspired by Qmes unit for dense tabular). + if n_features >= 16 and spp < 8.0: + choice = "AmplitudeMap" + reason = "high_dim_scarce_samples" + # Strong feature correlation + weak linear separation favors entangling ZZ. + elif corr >= 0.35 and lin < 0.75: + choice = "ZZFeatureMap" + reason = "correlated_nonlinear" + # Mild nonlinearity / multiclass: Pauli-style hardware-efficient map. + elif lin < 0.85 or imbalance > 0.25 or meta["n_classes"] > 2: + choice = "PauliFeatureMap" + reason = "moderate_complexity" + # Nearly linear / separable: separable Z rotations. + elif lin >= 0.92 and corr < 0.2: + choice = "ZFeatureMap" + reason = "highly_linear" + else: + choice = "AngleEncoding" + reason = "default_angle" + + # Stable preference order for reporting (selected first). + preference = [ + choice, + "ZZFeatureMap", + "PauliFeatureMap", + "AngleEncoding", + "ZFeatureMap", + "AmplitudeMap", + ] + ranking: list[str] = [] + for name in preference: + if name not in ranking: + ranking.append(name) + + return FeatureMapRecommendation( + feature_map=choice, + source="heuristic", + ranking=ranking, + meta_features=meta, + details={"reason": reason}, + ) + + +def recommend_feature_map_qmes( + X: np.ndarray, + y: np.ndarray, + *, + task: Literal["classification", "regression"] = "classification", + top_k: int = 3, +) -> FeatureMapRecommendation: + """Use Qmes recommend() and map onto SuperQuantX registry names.""" + from Qmes import get_extractor, load_default_recommender, recommend + + result = recommend( + X, + y, + extractor=get_extractor(task), + recommender=load_default_recommender(task), + top_k=top_k, + ) + qmes_ranking = list(result.get("ranking") or result.get("top_k") or []) + if not qmes_ranking: + raise RuntimeError("Qmes recommend() returned an empty ranking") + + mapped: list[str] = [] + for qname in qmes_ranking: + sqx = map_qmes_circuit(qname) + if sqx not in mapped: + mapped.append(sqx) + + top = mapped[0] + return FeatureMapRecommendation( + feature_map=top, + source="qmes", + ranking=mapped, + qmes_ranking=qmes_ranking, + details={ + "top_k_qmes": list(result.get("top_k", [])), + "votes": dict(result.get("votes", {})), + "mapping": dict(QMES_TO_SUPERQUANTX), + }, + ) + + +def recommend_feature_map( + X: np.ndarray, + y: np.ndarray | None = None, + *, + mode: AutoMode = "auto", + task: Literal["classification", "regression"] = "classification", +) -> FeatureMapRecommendation: + """Recommend a SuperQuantX feature-map name for the given dataset. + + Args: + X: Feature matrix. + y: Labels/targets. Required for Qmes; optional for heuristic. + mode: ``auto`` tries Qmes then falls back; ``qmes`` requires Qmes; + ``heuristic`` forces the local meta-feature rule. + task: Qmes task type (classification or regression). + + Returns: + FeatureMapRecommendation with a registry feature_map string. + """ + X = np.asarray(X) + if mode == "heuristic": + return recommend_feature_map_heuristic(X, y) + + if mode in ("auto", "qmes"): + if y is None and mode == "qmes": + raise ValueError("Qmes mode requires y labels/targets") + if y is not None and qmes_available(): + try: + return recommend_feature_map_qmes(X, y, task=task) + except Exception as exc: + if mode == "qmes": + raise + logger.warning( + "Qmes recommendation failed (%s); using heuristic fallback", + exc, + ) + elif mode == "qmes": + raise ImportError( + "Qmes is not installed. Install the optional extra: " + 'pip install "superquantx[qmes]" ' + "(or pip install git+https://github.com/tungduy1704/Qmes.git)" + ) + + return recommend_feature_map_heuristic(X, y) + + +def resolve_feature_map( + feature_map: str | None, + X: np.ndarray | None = None, + y: np.ndarray | None = None, + *, + auto_mode: AutoMode = "auto", + task: Literal["classification", "regression"] = "classification", +) -> FeatureMapRecommendation: + """Resolve a user-supplied feature_map argument. + + ``None`` or ``"auto"`` triggers recommendation. Any other string is + normalized against the registry without looking at the data. + """ + if feature_map is None or ( + isinstance(feature_map, str) and feature_map.strip().lower() == "auto" + ): + if X is None: + return FeatureMapRecommendation( + feature_map=DEFAULT_FEATURE_MAP, + source="default", + ranking=[DEFAULT_FEATURE_MAP], + details={"reason": "auto_without_data"}, + ) + return recommend_feature_map(X, y, mode=auto_mode, task=task) + + if not isinstance(feature_map, str): + # Caller passed a custom object; leave it alone. + return FeatureMapRecommendation( + feature_map=feature_map, # type: ignore[arg-type] + source="custom_object", + ranking=[], + details={}, + ) + + name = normalize_feature_map_name(feature_map) + return FeatureMapRecommendation( + feature_map=name, + source="manual", + ranking=[name], + ) diff --git a/src/superquantx/utils/feature_map_registry.py b/src/superquantx/utils/feature_map_registry.py new file mode 100644 index 0000000..3d1f5ea --- /dev/null +++ b/src/superquantx/utils/feature_map_registry.py @@ -0,0 +1,103 @@ +"""Feature-map name registry for SuperQuantX quantum kernels. + +Backends historically accept a small set of string names +(``ZZFeatureMap``, ``PauliFeatureMap``, ``AmplitudeMap``) plus a default +angle-encoding fallback. This module is the single source of truth for +those names, plus aliases used by the Qmes-inspired auto selector. +""" + +from __future__ import annotations + +from typing import Final + + +# Canonical names that QuantumSVM / backends should recognize. +FEATURE_MAP_REGISTRY: Final[dict[str, str]] = { + "ZZFeatureMap": ( + "Hadamard + RZ data encoding with linear ZZ (RZZ/CNOT) entanglement. " + "Closest SuperQuantX analogue of Qmes HD / strongly entangling maps." + ), + "PauliFeatureMap": ( + "Per-qubit RX/RY/RZ encoding with linear CNOT entanglement. " + "Closest analogue of Qmes HERx and RY_CX." + ), + "AmplitudeMap": ( + "Amplitude-style embedding (compact state preparation). " + "Closest analogue of Qmes unit (square-root amplitude)." + ), + "AngleEncoding": ( + "Separable single-qubit RY (or RX) rotations without entanglement. " + "Closest analogue of Qmes RY and SRx." + ), + "ZFeatureMap": ( + "Separable RZ rotations (no entanglement). " + "Closest analogue of Qmes ZFM." + ), +} + +# Names accepted by create_feature_map on backends today (case-sensitive). +SUPPORTED_FEATURE_MAP_NAMES: Final[tuple[str, ...]] = tuple(FEATURE_MAP_REGISTRY.keys()) + +# Default when the caller does not request auto selection. +DEFAULT_FEATURE_MAP: Final[str] = "ZZFeatureMap" + +# Qmes circuit pool -> SuperQuantX registry name. +# Gaps (exact gate schedules, qubit count fixed at 4 in Qmes, ISWAP brickwork +# for HD, etc.) are intentional for this spike; see docs/design/qmes-feature-map-auto.md. +QMES_TO_SUPERQUANTX: Final[dict[str, str]] = { + "unit": "AmplitudeMap", + "SRx": "AngleEncoding", + "RY": "AngleEncoding", + "HERx": "PauliFeatureMap", + "RY_CX": "PauliFeatureMap", + "ZFM": "ZFeatureMap", + "HD": "ZZFeatureMap", +} + + +def normalize_feature_map_name(name: str) -> str: + """Normalize aliases to a canonical registry name. + + Raises: + ValueError: If the name is not recognized. + """ + if name in FEATURE_MAP_REGISTRY: + return name + + aliases = { + "zz": "ZZFeatureMap", + "zzfeaturemap": "ZZFeatureMap", + "pauli": "PauliFeatureMap", + "paulifeaturemap": "PauliFeatureMap", + "amplitude": "AmplitudeMap", + "amplitudemap": "AmplitudeMap", + "angle": "AngleEncoding", + "angleencoding": "AngleEncoding", + "ry": "AngleEncoding", + "z": "ZFeatureMap", + "zfeaturemap": "ZFeatureMap", + "zfm": "ZFeatureMap", + } + key = name.replace("_", "").replace("-", "").lower() + if key in aliases: + return aliases[key] + + raise ValueError( + f"Unknown feature map '{name}'. " + f"Supported: {list(SUPPORTED_FEATURE_MAP_NAMES)}" + ) + + +def list_feature_maps() -> list[str]: + """Return canonical feature-map names in registry order.""" + return list(SUPPORTED_FEATURE_MAP_NAMES) + + +def map_qmes_circuit(qmes_name: str) -> str: + """Map a Qmes circuit-pool name to a SuperQuantX registry entry.""" + if qmes_name not in QMES_TO_SUPERQUANTX: + raise ValueError( + f"Unknown Qmes circuit '{qmes_name}'. " + f"Known: {list(QMES_TO_SUPERQUANTX)}" + ) + return QMES_TO_SUPERQUANTX[qmes_name] diff --git a/tests/unit/test_feature_map_auto.py b/tests/unit/test_feature_map_auto.py new file mode 100644 index 0000000..57e290e --- /dev/null +++ b/tests/unit/test_feature_map_auto.py @@ -0,0 +1,227 @@ +"""Unit tests for Qmes-inspired feature_map='auto' selection (no hardware).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from superquantx.utils.feature_map_auto import ( + FeatureMapRecommendation, + extract_lightweight_meta_features, + qmes_available, + recommend_feature_map, + recommend_feature_map_heuristic, + resolve_feature_map, +) +from superquantx.utils.feature_map_registry import ( + FEATURE_MAP_REGISTRY, + QMES_TO_SUPERQUANTX, + list_feature_maps, + map_qmes_circuit, + normalize_feature_map_name, +) + + +class TestFeatureMapRegistry: + def test_registry_contains_expected_names(self): + names = list_feature_maps() + assert "ZZFeatureMap" in names + assert "PauliFeatureMap" in names + assert "AmplitudeMap" in names + assert "AngleEncoding" in names + assert "ZFeatureMap" in names + assert set(names) == set(FEATURE_MAP_REGISTRY) + + def test_qmes_mapping_covers_full_pool(self): + expected = {"unit", "SRx", "RY", "HERx", "RY_CX", "ZFM", "HD"} + assert set(QMES_TO_SUPERQUANTX) == expected + for qname, sqx in QMES_TO_SUPERQUANTX.items(): + assert sqx in FEATURE_MAP_REGISTRY + assert map_qmes_circuit(qname) == sqx + + def test_normalize_aliases(self): + assert normalize_feature_map_name("zz") == "ZZFeatureMap" + assert normalize_feature_map_name("AngleEncoding") == "AngleEncoding" + assert normalize_feature_map_name("zfm") == "ZFeatureMap" + with pytest.raises(ValueError): + normalize_feature_map_name("not-a-real-map") + + +class TestHeuristicSelector: + def test_meta_features_shape(self): + rng = np.random.default_rng(0) + X = rng.normal(size=(30, 4)) + y = np.array([0] * 15 + [1] * 15) + meta = extract_lightweight_meta_features(X, y) + assert meta["n_samples"] == 30.0 + assert meta["n_features"] == 4.0 + assert 0.0 <= meta["class_imbalance"] <= 1.0 + assert 0.0 <= meta["linear_sep_proxy"] <= 1.0 + + def test_heuristic_returns_registry_name(self): + rng = np.random.default_rng(1) + X = rng.normal(size=(40, 3)) + y = (X[:, 0] > 0).astype(int) + rec = recommend_feature_map_heuristic(X, y) + assert isinstance(rec, FeatureMapRecommendation) + assert rec.source == "heuristic" + assert rec.feature_map in FEATURE_MAP_REGISTRY + assert rec.ranking[0] == rec.feature_map + + def test_high_dim_scarce_prefers_amplitude(self): + rng = np.random.default_rng(2) + X = rng.normal(size=(20, 20)) + y = rng.integers(0, 2, size=20) + rec = recommend_feature_map_heuristic(X, y) + assert rec.feature_map == "AmplitudeMap" + assert rec.details["reason"] == "high_dim_scarce_samples" + + def test_mode_heuristic_forces_local_path(self): + rng = np.random.default_rng(3) + X = rng.normal(size=(25, 4)) + y = rng.integers(0, 2, size=25) + rec = recommend_feature_map(X, y, mode="heuristic") + assert rec.source == "heuristic" + + +class TestResolveFeatureMap: + def test_manual_string(self): + rec = resolve_feature_map("PauliFeatureMap") + assert rec.source == "manual" + assert rec.feature_map == "PauliFeatureMap" + + def test_auto_without_data_defaults(self): + rec = resolve_feature_map("auto") + assert rec.feature_map == "ZZFeatureMap" + assert rec.source == "default" + + def test_auto_with_data_uses_selector(self): + rng = np.random.default_rng(4) + X = rng.normal(size=(20, 4)) + y = rng.integers(0, 2, size=20) + rec = resolve_feature_map("auto", X=X, y=y, auto_mode="heuristic") + assert rec.feature_map in FEATURE_MAP_REGISTRY + assert rec.source == "heuristic" + + def test_none_is_auto(self): + rng = np.random.default_rng(5) + X = rng.normal(size=(16, 3)) + y = rng.integers(0, 2, size=16) + rec = resolve_feature_map(None, X=X, y=y, auto_mode="heuristic") + assert rec.source == "heuristic" + + +class TestQmesOptionalPath: + def test_qmes_available_is_bool(self): + assert isinstance(qmes_available(), bool) + + def test_qmes_mode_without_install_raises(self): + rng = np.random.default_rng(6) + X = rng.normal(size=(12, 3)) + y = rng.integers(0, 2, size=12) + with patch( + "superquantx.utils.feature_map_auto.qmes_available", + return_value=False, + ): + with pytest.raises(ImportError, match="Qmes is not installed"): + recommend_feature_map(X, y, mode="qmes") + + def test_qmes_recommendation_is_mapped(self): + rng = np.random.default_rng(7) + X = rng.normal(size=(20, 4)) + y = rng.integers(0, 2, size=20) + + fake_result = { + "top_k": ["HERx", "RY", "unit"], + "ranking": ["HERx", "RY", "unit", "SRx", "RY_CX", "HD", "ZFM"], + "votes": {"HERx": 5, "RY": 4}, + } + + mock_qmes = MagicMock() + mock_qmes.get_extractor.return_value = MagicMock() + mock_qmes.load_default_recommender.return_value = MagicMock() + mock_qmes.recommend.return_value = fake_result + + with patch.dict("sys.modules", {"Qmes": mock_qmes}): + from superquantx.utils import feature_map_auto as fma + + rec = fma.recommend_feature_map_qmes(X, y) + assert rec.source == "qmes" + assert rec.feature_map == "PauliFeatureMap" # HERx mapped + assert rec.qmes_ranking[0] == "HERx" + assert "AngleEncoding" in rec.ranking # RY mapped + assert "AmplitudeMap" in rec.ranking # unit mapped + + def test_auto_falls_back_when_qmes_errors(self): + rng = np.random.default_rng(8) + X = rng.normal(size=(18, 4)) + y = rng.integers(0, 2, size=18) + + with patch( + "superquantx.utils.feature_map_auto.qmes_available", + return_value=True, + ), patch( + "superquantx.utils.feature_map_auto.recommend_feature_map_qmes", + side_effect=RuntimeError("boom"), + ): + rec = recommend_feature_map(X, y, mode="auto") + assert rec.source == "heuristic" + + +class TestQuantumSVMAutoWiring: + def test_qsvm_auto_sets_selected_feature_map(self): + from superquantx.algorithms.quantum_svm import QuantumSVM + + rng = np.random.default_rng(9) + X = rng.normal(size=(20, 3)) + y = rng.integers(0, 2, size=20) + + qsvm = QuantumSVM( + backend="simulator", + feature_map="auto", + auto_feature_map_mode="heuristic", + shots=32, + ) + + # Avoid expensive kernel path: stub backend kernel + SVM fit pieces + qsvm.backend.compute_kernel_matrix = MagicMock( + return_value=np.eye(X.shape[0]) + ) + # predict during fit uses kernel against training data + def _kernel(X1, X2=None, feature_map=None, shots=None): + n1 = len(X1) + n2 = len(X2) if X2 is not None else n1 + return np.eye(max(n1, n2))[:n1, :n2] + + qsvm.backend.compute_kernel_matrix = _kernel + qsvm.fit(X, y) + + assert qsvm.selected_feature_map_ in FEATURE_MAP_REGISTRY + assert qsvm.feature_map_recommendation_ is not None + assert qsvm.feature_map_recommendation_["source"] == "heuristic" + assert qsvm.is_fitted + + def test_qsvm_manual_unchanged(self): + from superquantx.algorithms.quantum_svm import QuantumSVM + + rng = np.random.default_rng(10) + X = rng.normal(size=(16, 3)) + y = rng.integers(0, 2, size=16) + + qsvm = QuantumSVM( + backend="simulator", + feature_map="ZZFeatureMap", + shots=32, + ) + + def _kernel(X1, X2=None, feature_map=None, shots=None): + n1 = len(X1) + n2 = len(X2) if X2 is not None else n1 + return np.eye(max(n1, n2))[:n1, :n2] + + qsvm.backend.compute_kernel_matrix = _kernel + qsvm.fit(X, y) + assert qsvm.selected_feature_map_ == "ZZFeatureMap" + assert qsvm.feature_map_recommendation_["source"] == "manual" From 4176d5577ff9de401e143a2f92f325048fe39f31 Mon Sep 17 00:00:00 2001 From: Superagentic AI <190614718+Superagentic-AI@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:10:46 +0000 Subject: [PATCH 2/2] Make Qmes an optional runtime import instead of a git-URL extra Hatchling and PyPI reject direct references in extras, which broke CI install. Qmes is now installed manually from git, the classical heuristic fallback is unchanged, and the design doc states the paper limits (7 circuits, 4 qubits, noiseless simulation; arXiv:2609.04652). --- CHANGELOG.md | 3 +- docs/design/qmes-feature-map-auto.md | 41 +++++++++++++++++++++-- pyproject.toml | 12 +++---- src/superquantx/utils/feature_map_auto.py | 6 ++-- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce6493..0a95288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Spike: `QuantumSVM(feature_map="auto")` Qmes-style encoding selection with - optional Qmes extra and a classical heuristic fallback + optional runtime Qmes import (installed manually from git, not a + package extra) and a classical heuristic fallback (`docs/design/qmes-feature-map-auto.md`). ### Planned diff --git a/docs/design/qmes-feature-map-auto.md b/docs/design/qmes-feature-map-auto.md index feaee03..41e5d16 100644 --- a/docs/design/qmes-feature-map-auto.md +++ b/docs/design/qmes-feature-map-auto.md @@ -3,6 +3,42 @@ Status: spike on branch `spike/qmes-feature-map-auto`. Inspired by [Qmes](https://github.com/tungduy1704/Qmes) ([arXiv:2609.04652](https://arxiv.org/abs/2609.04652)). +## Paper scope and limits (read before demoing) + +The Qmes results in arXiv:2609.04652 hold only within this scope: + +- **7 circuits**: a fixed pool (unit, SRx, RY, HERx, RY_CX, ZFM, HD), + implemented in the bundled Qsun simulator, not in SuperQuantX backends. +- **4 qubits**: every dataset is reduced by PCA to at most 4 components and + rescaled before kernel evaluation, so rankings describe 4-qubit circuits. +- **Noiseless simulation**: all circuit scores come from noiseless + state-vector simulation. Hardware noise and noise models are not modelled, + so a recommendation says nothing about performance on real QPUs. +- Supervised tabular data only (labels required), meta-features computed on + at most 600 subsampled points, fixed non-trainable kernels. +- Reported gains (2.2x lower mean regret for classification, 4.2x for + regression versus a non-adaptive baseline) are leave-one-out results on the + authors' 105 classification and 86 regression benchmark datasets. + +SuperQuantX maps the Qmes circuit names onto its own, differently shaped +feature maps (see below), so the paper's regret numbers do not transfer +directly to SuperQuantX kernels. Treat `feature_map="auto"` as a heuristic +starting point, not a validated optimum. + +## Installing Qmes (optional) + +Qmes is an optional runtime import, not a package extra. It is not published +on PyPI, and PyPI (and hatchling) reject direct git references in extras, so +SuperQuantX does not declare it as a dependency. Install it manually if you +want the pre-trained recommender: + +```bash +pip install git+https://github.com/tungduy1704/Qmes +``` + +Without Qmes, `feature_map="auto"` (mode `auto`) uses the built-in classical +heuristic. Tests run without Qmes installed. + ## Why QuantumSVM performance depends strongly on the encoding circuit. Exhaustive @@ -31,7 +67,7 @@ path that recommends a name from SuperQuantX's existing feature-map registry. 3. **Selector** (`utils/feature_map_auto.py`) - `mode="auto"`: try Qmes if installed, else heuristic. - - `mode="qmes"`: require optional `[qmes]` extra. + - `mode="qmes"`: require the optional Qmes package (runtime import only). - `mode="heuristic"`: local meta-features only (no problexity, no quantum). - Heuristic uses sample/feature ratio, mean |correlation|, class imbalance, and a cheap logistic-regression separability proxy. @@ -85,8 +121,7 @@ print(qsvm.feature_map_recommendation_) Optional Qmes path: ```bash -pip install "superquantx[qmes]" -# or: pip install "git+https://github.com/tungduy1704/Qmes.git" +pip install git+https://github.com/tungduy1704/Qmes ``` ```python diff --git a/pyproject.toml b/pyproject.toml index 29b8736..628f142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,14 +96,10 @@ ocean = [ "dimod>=0.12.22", ] -# Optional Qmes-backed feature_map="auto" recommender -# (https://github.com/tungduy1704/Qmes , arXiv:2609.04652). -# Install from git until a stable PyPI release is available. -qmes = [ - "Qmes @ git+https://github.com/tungduy1704/Qmes.git", - "problexity>=0.5.0", - "pandas>=1.3", -] +# Note: Qmes (https://github.com/tungduy1704/Qmes, arXiv:2609.04652) is an +# optional runtime import for QuantumSVM(feature_map="auto"). It is not on +# PyPI, and PyPI rejects direct git references in extras, so it is installed +# manually: pip install git+https://github.com/tungduy1704/Qmes.git # Development dependencies - latest versions dev = [ diff --git a/src/superquantx/utils/feature_map_auto.py b/src/superquantx/utils/feature_map_auto.py index 5cdb06f..c882bcc 100644 --- a/src/superquantx/utils/feature_map_auto.py +++ b/src/superquantx/utils/feature_map_auto.py @@ -287,9 +287,9 @@ def recommend_feature_map( ) elif mode == "qmes": raise ImportError( - "Qmes is not installed. Install the optional extra: " - 'pip install "superquantx[qmes]" ' - "(or pip install git+https://github.com/tungduy1704/Qmes.git)" + "Qmes is not installed. It is an optional runtime import " + "(not on PyPI). Install it manually with: " + "pip install git+https://github.com/tungduy1704/Qmes.git" ) return recommend_feature_map_heuristic(X, y)