# Copyright 2026, IBM Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import difflib
import os
import warnings
from functools import lru_cache
import numpy as np
# ====== Qiskit imports ======
from qiskit import QuantumCircuit
from qiskit.quantum_info import Pauli
# ====== Embedding functions imports ======
from sklearn.decomposition import NMF, PCA
from sklearn.manifold import Isomap, LocallyLinearEmbedding, SpectralEmbedding
from umap import UMAP
import qbiocode.utils.qutils as qutils
[docs]
def pqk(
X_train,
X_test,
args,
store=False,
data_key="",
encoding="Z",
data_map=True,
primitive="estimator",
entanglement="linear",
reps=2,
):
"""
This function generates quantum circuits, computes projections of the data onto these circuits.
It uses a feature map to encode the data into quantum states and then measures the expectation values
of Pauli operators to obtain the features.
This function requires a quantum backend (simulator or real quantum hardware) for execution.
It supports various configurations such as encoding methods, entanglement strategies, and repetitions
of the feature map. Optionally the results are saved to files for training and test projections.
Args:
X_train (np.ndarray): Training data features.
X_test (np.ndarray): Test data features.
args (dict): Backend configuration. Requires ``backend`` (``'simulator'``,
``'simulator_aer'``, or an ``'ibm_*'`` device) and, for the
simulator, ``seed`` so the statevector primitive is reproducible.
store (bool): If true projections are stored, using data_key as indefitier
data_key (str): Key for the dataset, default is ''.
encoding (str): Encoding method for the quantum circuit, default is 'Z'.
data_map (bool): If true ensures that all multiplicative factors of data features inside single qubit gates are 1.0. Not applicable for Hejsemberg feature maps
primitive (str): Primitive type to use, default is 'estimator'.
entanglement (str): Entanglement strategy, default is 'linear'.
reps (int): Number of repetitions for the feature map, default is 2.
Returns:
modeleval (dict): A dictionary containing evaluation metrics and model parameters.
"""
feat_dimension = X_train.shape[1]
# Shared with compute_pqk so the two PQK paths cannot drift again: the copy
# that used to live here coerced its result with float(), which qiskit's
# symbolic ParameterVector cannot satisfy, so every data_map=True call raised
# "Parameter expression with unbound parameters ... is not numeric".
data_map_func = qutils.unit_coefficient_data_map if data_map else None
# choose a method for mapping your features onto the circuit
feature_map, _ = qutils.get_feature_map(
feature_map=encoding,
feat_dimension=X_train.shape[1],
reps=reps,
entanglement=entanglement,
data_map_func=data_map_func,
)
# Build quantum circuit
circuit = QuantumCircuit(feature_map.num_qubits)
circuit.compose(feature_map, inplace=True)
num_qubits = circuit.num_qubits
# Generate the backend, session and primitive
backend, session, prim = qutils.get_backend_session(args, "estimator", num_qubits=num_qubits)
try:
# Transpile
if args["backend"] != "simulator":
circuit = qutils.transpile_circuit(
circuit, opt_level=3, backend=backend, PT=True, initial_layout=None
)
for f_tr in ["train", "test"]:
if "train" in f_tr:
dat = X_train.copy()
else:
dat = X_test.copy()
# Identity operator on all qubits
id = "I" * feat_dimension
# We group all commuting observables
# These groups are the Pauli X, Y and Z operators on individual qubits
# Apply the circuit layout to the observable if mapped to device
if args["backend"] != "simulator":
observables_x = []
observables_y = []
observables_z = []
for i in range(feat_dimension):
observables_x.append(
Pauli(id[:i] + "X" + id[(i + 1) :]).apply_layout(
circuit.layout, num_qubits=backend.num_qubits
)
)
observables_y.append(
Pauli(id[:i] + "Y" + id[(i + 1) :]).apply_layout(
circuit.layout, num_qubits=backend.num_qubits
)
)
observables_z.append(
Pauli(id[:i] + "Z" + id[(i + 1) :]).apply_layout(
circuit.layout, num_qubits=backend.num_qubits
)
)
else:
observables_x = [Pauli(id[:i] + "X" + id[(i + 1) :]) for i in range(feat_dimension)]
observables_y = [Pauli(id[:i] + "Y" + id[(i + 1) :]) for i in range(feat_dimension)]
observables_z = [Pauli(id[:i] + "Z" + id[(i + 1) :]) for i in range(feat_dimension)]
# projections[i][j][k] will be the expectation value of the j-th Pauli operator (0: X, 1: Y, 2: Z)
# of datapoint i on qubit k
projections = []
for i in range(len(dat)):
# Get training sample
parameters = dat[i]
# We define the primitive unified blocs (PUBs) consisting of the embedding circuit,
# set of observables and the circuit parameters
pub_x = (circuit, observables_x, parameters)
pub_y = (circuit, observables_y, parameters)
pub_z = (circuit, observables_z, parameters)
job = prim.run([pub_x, pub_y, pub_z])
job_result_x = job.result()[0].data.evs
job_result_y = job.result()[1].data.evs
job_result_z = job.result()[2].data.evs
# Record <X>, <Y> and <Z> on all qubits for the current datapoint
projections.append([job_result_x, job_result_y, job_result_z])
if store:
if not os.path.exists("pqk_projections"):
os.makedirs("pqk_projections")
file_projection = os.path.join(
"pqk_projections", "pqk_projection_" + data_key + "_" + f_tr + ".npy"
)
np.save(file_projection, projections)
if "train" in f_tr:
X_train_prj = np.array(projections.copy()).reshape(len(projections), -1)
else:
X_test_prj = np.array(projections.copy()).reshape(len(projections), -1)
finally:
if not isinstance(session, type(None)):
session.close()
return X_train_prj, X_test_prj
# ---------------------------------------------------------------------------
# Method catalogue
# ---------------------------------------------------------------------------
#: Feature-reduction modes backed by scikit-learn / UMAP.
SKLEARN_METHODS = ("none", "pca", "nmf", "lle", "isomap", "spectral", "umap")
#: The headline QuVINE names, for discoverability. `QUVINE_METHODS` holds all of
#: them; these are the ones worth naming next to `pca`/`nmf`/`umap`.
QUVINE_HEADLINE_METHODS = (
"quvine_fused",
"quvine_rwr",
"quvine_dtqw",
"quvine_ctqw",
"node2vec",
"netmf",
"appnp",
)
# Methods that cannot follow the fit-on-train / transform-on-test contract, because
# they have no out-of-sample `transform`: the embedding is fit on the concatenated
# train+test rows and sliced back. Test *features* therefore participate; test
# *labels* never do. See `is_transductive`.
_TRANSDUCTIVE_SKLEARN_METHODS = frozenset({"spectral"})
@lru_cache(maxsize=1)
def _quvine_method_names():
"""All QuVINE method names, or ``()`` if the QuVINE app cannot be imported.
Name resolution is stdlib-only, so this works without the ``[quvine]`` extra —
the names are listed even when running one would raise
:class:`~qbiocode.apps.quvine.QuvineDependencyError`.
"""
try:
from qbiocode.apps.quvine import list_methods
return tuple(list_methods())
except ImportError:
return ()
#: Every QuVINE method name accepted by :func:`get_embeddings`, e.g.
#: ``quvine_rwr``, ``quvine_ctqw_heat``, ``node2vec``, ``graphgps_rwr_poly``.
QUVINE_METHODS = _quvine_method_names()
def _is_quvine_method(embedding: str) -> bool:
"""Return True if ``embedding`` names a QuVINE embedding method.
QuVINE method names (``quvine_rwr``, ``quvine_ctqw``, ``node2vec``, ``graphsage``,
``baseline_filter_heat``, ``filter_ctqw_heat``, ...) resolve through the QuVINE app's
``resolve_method`` and never collide with the sklearn embedding modes handled below.
Returning ``False`` when the QuVINE app cannot be imported is what keeps the
``[quvine]`` extra non-breaking: every sklearn mode still works, and a QuVINE name
falls through to the ``ValueError`` below.
"""
try:
from qbiocode.apps.quvine import resolve_method
except ImportError:
# QuVINE app unavailable: treat as "not a QuVINE method" so the sklearn
# embeddings still work.
return False
try:
resolve_method(embedding)
return True
except KeyError:
# Unknown name -> not a QuVINE method. Any other error is a real bug and propagates.
return False
[docs]
def is_transductive(embedding: str) -> bool:
"""Return True if ``embedding`` is fit on the combined train and test rows.
Inductive methods (``pca``, ``nmf``, ``lle``, ``isomap``, ``umap``) are fit on the
training rows alone and applied to the test rows through ``transform``. Transductive
methods — ``spectral`` and every QuVINE method — have no out-of-sample ``transform``,
so the embedding is computed once over ``vstack([X_train, X_test])`` and sliced back.
Test *features* therefore participate in the embedding; test *labels* never reach it.
This is the standard protocol for unsupervised graph and manifold embeddings, but it
means an embedding fit this way cannot be reused on rows unseen at fit time, and a
reported test score is a transductive score. Callers that need the distinction — e.g.
to skip a method in a strictly inductive benchmark — should branch on this.
Args:
embedding (str): An embedding name accepted by :func:`get_embeddings`.
Returns:
bool: True for a transductive method. Unknown names return False rather than
raising, so this is safe to call before validation.
Examples:
>>> is_transductive("pca")
False
>>> is_transductive("spectral")
True
"""
name = str(embedding).lower().strip()
if name in _TRANSDUCTIVE_SKLEARN_METHODS:
return True
return _is_quvine_method(name)
def _warn_transductive(embedding: str) -> None:
"""Warn once per call that test features participate in the embedding."""
warnings.warn(
f"Embedding {embedding!r} is transductive: it has no out-of-sample transform, so "
f"it is fit on the concatenated train and test rows and then sliced back. Test "
f"features participate in the embedding; test labels do not. Scores obtained this "
f"way are transductive and are not comparable to a strictly inductive protocol. "
f"Use qbiocode.embeddings.is_transductive() to branch on this.",
UserWarning,
stacklevel=3,
)
def _nonnegative_for_nmf(X_test, embedding):
"""Clip ``X_test`` at zero so NMF can transform it, and say how much was clipped.
NMF is defined only on non-negative input. A MinMaxScaler fit on the *training*
split alone -- which is the leakage-free protocol, and what QProfiler now does --
maps train into [0, 1] but leaves test free to fall outside it, so a test row
below the training minimum arrives negative and ``NMF.transform`` raises
``ValueError: Negative values in data passed to X in NMF``. That is what made
every shipped QProfiler config crash: ``embeddings: ['pca', 'nmf', 'none']`` with
``scaling: ['True']``.
Clipping at zero is the honest handling: the alternative -- scaling on train+test
together, so nothing ever falls outside [0, 1] -- is the leak this release
removed. The clip is reported rather than silent, because a large excursion means
the test split is genuinely outside the training range and the embedding is being
asked to extrapolate.
"""
X_test = np.asarray(X_test, dtype=float)
negative = X_test < 0
count = int(negative.sum())
if count:
warnings.warn(
f"{count} of {X_test.size} test entries were negative and have been "
f"clipped to 0 for the {embedding!r} embedding (most negative: "
f"{float(X_test.min()):.4g}). NMF is only defined on non-negative input, "
f"and a scaler fit on the training split alone -- the leakage-free "
f"protocol -- does not bound the test split to the training range.",
UserWarning,
stacklevel=4,
)
X_test = np.clip(X_test, 0.0, None)
return X_test
def _unknown_embedding_error(embedding: str) -> ValueError:
"""Build the error for an unrecognized embedding name, with close matches."""
known = list(SKLEARN_METHODS) + list(QUVINE_METHODS)
suggestions = difflib.get_close_matches(embedding, known, n=5)
hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
quvine_note = ""
if not QUVINE_METHODS:
quvine_note = (
" QuVINE method names are unavailable in this environment; install them with: "
'pip install "qbiocode[quvine]".'
)
return ValueError(
f"Unknown embedding {embedding!r}.{hint} Valid sklearn modes are "
f"{list(SKLEARN_METHODS)}; QuVINE methods are listed by "
f"qbiocode.embeddings.QUVINE_METHODS ({len(QUVINE_METHODS)} names)."
f"{quvine_note}"
)
[docs]
def check_embedding_name(embedding: str) -> str:
"""Normalize and validate an embedding name, raising if it is unknown.
:func:`get_embeddings` calls this, so a caller that is about to run many
embeddings can validate the whole list *before* doing any work and get the
identical message. QProfiler uses it for exactly that: a typo in the sixth
entry of ``embeddings`` used to surface only after the first five had run.
Args:
embedding (str): Method name, case-insensitive, surrounding space ignored.
Returns:
str: The normalized (lower-cased, stripped) name.
Raises:
ValueError: if ``embedding`` is not a string, or names no known method.
The message lists close matches and the valid sklearn modes.
"""
if not isinstance(embedding, str):
raise ValueError(
f"embedding must be a string naming an embedding method; got "
f"{embedding!r} ({type(embedding).__name__}). Valid options: "
f"{list(SKLEARN_METHODS)}"
+ (f" plus {len(QUVINE_METHODS)} QuVINE methods." if QUVINE_METHODS else ".")
)
name = embedding.lower().strip()
if name not in SKLEARN_METHODS and not _is_quvine_method(name):
raise _unknown_embedding_error(name)
return name
def _quvine_embed(embedding, X_train, X_test, n_components, n_neighbors=30, quvine_args=None):
"""Embed via QuVINE, treating the (train+test) feature matrix as one cell graph.
QuVINE is a *transductive* graph-embedding method: it embeds every node of a graph at
once (keyed by node identity), so it cannot follow sklearn's fit-on-train /
transform-on-test contract. A single kNN graph is built over the concatenated
``[X_train, X_test]`` feature rows, all nodes are embedded once, then the rows are
sliced back into train/test by construction order. The class *label* never enters
``embed`` -- only feature-derived graph structure -- so this is the standard
transductive protocol, not label leakage. See :func:`is_transductive`.
Args:
embedding (str): QuVINE method name (e.g. ``"quvine_ctqw"``, ``"filter_ctqw_heat"``).
X_train, X_test (array-like): feature matrices; rows are samples.
n_components (int): output embedding width (drives QuVINE ``dimension``).
n_neighbors (int): neighbors for the kNN sample graph.
quvine_args (dict, optional): extra OmegaConf overrides merged onto the QuVINE config
(e.g. ``{"walks": {"steps": 4}}``). ``dimension`` is always set to ``n_components``.
Returns:
tuple: ``(Z_train, Z_test)`` each with ``n_components`` columns.
Raises:
QuvineDependencyError: if the ``[quvine]`` extra is not installed. The message names
the extra and the install command.
"""
import networkx as nx
from sklearn.neighbors import kneighbors_graph
from qbiocode.apps.quvine import embed
quvine_args = dict(quvine_args or {})
X_train = np.asarray(X_train)
X_test = np.asarray(X_test)
n_train = X_train.shape[0]
X_all = np.vstack([X_train, X_test])
node_ids = [f"c{i}" for i in range(X_all.shape[0])] # QuVINE SGNS requires string ids
# Build a symmetric kNN graph over all samples; edge weight = 1/(1+distance).
k = int(min(n_neighbors, max(1, X_all.shape[0] - 1)))
A = kneighbors_graph(X_all, n_neighbors=k, mode="distance", include_self=False)
A = A.maximum(A.T) # symmetrize
G = nx.Graph()
G.add_nodes_from(node_ids)
Acoo = A.tocoo()
for i, j, d in zip(Acoo.row, Acoo.col, Acoo.data):
if i < j:
G.add_edge(node_ids[i], node_ids[j], weight=float(1.0 / (1.0 + d)))
overrides = {"dimension": int(n_components)}
overrides.update(quvine_args)
result = embed(G, embedding, overrides=overrides)
Z = np.asarray(result.embedding, dtype=float) # rows aligned to node_ids order
# Safety net: some arms may not honor `dimension` exactly -> reduce to n_components.
if Z.shape[1] != n_components:
Z = PCA(n_components=int(min(n_components, *Z.shape))).fit_transform(Z)
return Z[:n_train], Z[n_train:]
[docs]
def get_embeddings(
embedding: str,
X_train,
X_test,
n_neighbors=30,
n_components=None,
method=None,
quvine_args=None,
):
"""Apply an embedding to the training and test datasets.
Inductive methods are fit on ``X_train`` and applied to ``X_test`` through
``transform``. Transductive methods (``spectral`` and every QuVINE method) have no
out-of-sample ``transform``, so they are fit once on the concatenated rows and sliced
back; a ``UserWarning`` is emitted and :func:`is_transductive` reports which is which.
Args:
embedding (str): The embedding to use.
* scikit-learn / UMAP modes: ``'none'``, ``'pca'``, ``'nmf'``, ``'lle'``,
``'isomap'``, ``'spectral'``, ``'umap'``. Listed in
:data:`SKLEARN_METHODS`.
* QuVINE graph embeddings, accepted on exactly the same footing:
``'quvine_fused'``, ``'quvine_rwr'``, ``'quvine_dtqw'``, ``'quvine_ctqw'``,
``'node2vec'``, ``'netmf'``, ``'appnp'`` and 76 more. The full list is
:data:`QUVINE_METHODS`; the headline names are
:data:`QUVINE_HEADLINE_METHODS`. These need the optional extra:
``pip install "qbiocode[quvine]"``.
X_train (array-like): The training dataset.
X_test (array-like): The test dataset.
n_neighbors (int, optional): Number of neighbors for the neighbor-based embeddings
and for the QuVINE kNN sample graph. Defaults to 30.
n_components (int, optional): Width of the embedding. If None, defaults to the
number of features in ``X_train``.
method (str, optional): Method for Locally Linear Embedding. Defaults to None.
quvine_args (dict, optional): Extra config overrides forwarded to QuVINE ``embed``
when ``embedding`` is a QuVINE method, e.g.
``{"walks": {"steps": 4}, "train": {"epochs": 10}}``. Ignored for the
sklearn modes.
Returns:
tuple: ``(X_train_embedded, X_test_embedded)``.
Raises:
ValueError: if ``embedding`` is not a known name (the message lists close
matches), if ``n_components`` is not a positive integer, if
``n_components`` exceeds the feature count for an sklearn mode, or if
``X_train``/``X_test`` are not 2-D arrays with matching widths.
QuvineDependencyError: if a QuVINE method is requested without the ``[quvine]``
extra installed. The message names the extra and the install command.
Warns:
UserWarning: once per call, when ``embedding`` is transductive.
Examples:
>>> Z_tr, Z_te = get_embeddings("pca", X_train, X_test, n_components=8)
>>> Z_tr, Z_te = get_embeddings("quvine_rwr", X_train, X_test, n_components=8)
"""
embedding = check_embedding_name(embedding)
# Coerced and shape-checked before anything reads X_train.shape: a list of
# lists (the natural thing to pass from a notebook) otherwise failed on the
# `n_components is None` default below with "'list' object has no attribute
# 'shape'", which names neither the parameter nor the requirement.
X_train = np.asarray(X_train)
X_test = np.asarray(X_test)
if X_train.ndim != 2 or X_test.ndim != 2:
raise ValueError(
f"X_train and X_test must be 2-D (n_samples, n_features); got "
f"{X_train.ndim}-D and {X_test.ndim}-D. Reshape a single sample with "
f"X.reshape(1, -1)."
)
if X_train.shape[1] != X_test.shape[1]:
raise ValueError(
f"X_train and X_test must have the same number of features -- one "
f"embedding is fitted for both -- got {X_train.shape[1]} and "
f"{X_test.shape[1]}."
)
if X_train.shape[0] == 0:
raise ValueError(
"X_train is empty; an embedding cannot be fitted on zero samples."
)
# Default the embedding width to the feature count. This is the documented behavior, but
# without it `n_components=None` (the documented default!) reaches the comparison below and
# raises TypeError: '<=' not supported between 'NoneType' and 'int'.
if n_components is None:
n_components = X_train.shape[1]
# Validate at the boundary: a bad width otherwise surfaces as an sklearn assertion or,
# for QuVINE, as a failure deep inside the walk code.
if isinstance(n_components, bool) or not isinstance(n_components, (int, np.integer)):
raise ValueError(
f"n_components must be a positive integer or None; got {n_components!r}."
)
n_components = int(n_components)
if n_components < 1:
raise ValueError(f"n_components must be >= 1; got {n_components}.")
# check_embedding_name above already rejected unknown names, so this only
# decides which of the two routes the (valid) name takes.
is_quvine = _is_quvine_method(embedding)
if embedding != "none" and (is_quvine or embedding in _TRANSDUCTIVE_SKLEARN_METHODS):
_warn_transductive(embedding)
# QuVINE methods are transductive graph embeddings; route them before the sklearn
# feature-reduction path (their output width is independent of the feature count, so the
# n_components <= n_features check below does not apply).
if is_quvine:
return _quvine_embed(
embedding,
X_train,
X_test,
n_components,
n_neighbors=n_neighbors,
quvine_args=quvine_args,
)
if n_components > X_train.shape[1]:
raise ValueError(
f"n_components={n_components} exceeds the {X_train.shape[1]} features in "
f"X_train; {embedding!r} cannot produce more components than input features."
)
if "none" == embedding:
return X_train, X_test
else:
embedding_model = None
if "pca" == embedding:
embedding_model = PCA(n_components=n_components)
elif "nmf" == embedding:
embedding_model = NMF(n_components=n_components)
elif "lle" == embedding:
if method is None:
embedding_model = LocallyLinearEmbedding(
n_neighbors=n_neighbors, n_components=n_components, method="standard"
)
else:
embedding_model = LocallyLinearEmbedding(
n_neighbors=n_neighbors, n_components=n_components, method="modified"
)
elif "isomap" == embedding:
embedding_model = Isomap(
n_neighbors=n_neighbors,
n_components=n_components,
)
elif "umap" == embedding:
embedding_model = UMAP(
n_neighbors=n_neighbors,
n_components=n_components,
)
if "spectral" == embedding:
# SpectralEmbedding has no out-of-sample `transform`, so it must be fit
# transductively on the combined train+test rows and then sliced back. Only
# feature structure is used (no labels), so no label leakage is introduced --
# the UserWarning above declares it. Same protocol as the QuVINE arms.
n_train = X_train.shape[0]
X_all = np.vstack([X_train, X_test])
Z = SpectralEmbedding(n_components=n_components, eigen_solver="arpack").fit_transform(X_all)
return Z[:n_train], Z[n_train:]
X_train = embedding_model.fit_transform(X_train)
if "nmf" == embedding:
X_test = _nonnegative_for_nmf(X_test, embedding)
X_test = embedding_model.transform(X_test)
return X_train, X_test