From 75fc2f876567f6834d53c043d64393572a3cfd7c Mon Sep 17 00:00:00 2001 From: Shashi Jagtap Date: Fri, 11 Sep 2026 11:00:05 +0000 Subject: [PATCH] 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"