Skip to main content

oxicuda_quantum/kernel/
projected.rs

1//! Projected quantum kernels (PQK) beyond global overlap fidelity.
2//!
3//! Reference: Huang, Broughton, Mohseni, Babbush, Boixo, Neven & McClean,
4//! "Power of data in quantum machine learning", Nat. Commun. 12, 2631 (2021),
5//! §"Projected quantum kernels".
6//!
7//! The global overlap (fidelity) kernel `k(x,y) = |⟨ψ(x)|ψ(y)⟩|²` concentrates
8//! exponentially: for many qubits, off-diagonal entries collapse to ≈0 and the
9//! Gram matrix approaches the identity, destroying generalization. The
10//! **projected** quantum kernel instead measures only **local** (reduced)
11//! information about the embedded state and compares data points in that
12//! low-dimensional classical feature space.
13//!
14//! Concretely, for each data point `x` we embed `|ψ(x)⟩` and record the
15//! single-qubit Pauli expectation vector
16//!
17//! ```text
18//! ρ₁(x) = ( ⟨X_q⟩, ⟨Y_q⟩, ⟨Z_q⟩ )_{q=0..n-1}  ∈ ℝ^{3n},
19//! ```
20//!
21//! (equivalent to the Bloch vectors of all single-qubit reduced density
22//! matrices). The PQK is the Gaussian (RBF) kernel over these classical feature
23//! vectors:
24//!
25//! ```text
26//! k_PQK(x,y) = exp( -γ · Σ_q Σ_{P∈{X,Y,Z}} ( ⟨P_q⟩_x − ⟨P_q⟩_y )² ).
27//! ```
28//!
29//! This is positive-semidefinite by construction (an RBF kernel on real feature
30//! vectors) and provably avoids the exponential concentration of the fidelity
31//! kernel.
32
33use crate::error::{QuantumError, QuantumResult};
34use crate::pauli::expval::expectation_value;
35use crate::pauli::hamiltonian::Hamiltonian;
36use crate::pauli::pauli_string::PauliOp;
37use crate::statevec::state::StateVector;
38
39/// How a data vector is embedded into a quantum state before its local
40/// observables are read out.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum PqkEmbedding {
43    /// Angle embedding: `Ry(x_i)` on qubit `i`.
44    Angle,
45    /// ZZ feature map (Havlíček 2019) with the given number of repetitions.
46    ZzFeatureMap { reps: usize },
47}
48
49/// Configuration for a projected quantum kernel.
50#[derive(Debug, Clone)]
51pub struct ProjectedKernelConfig {
52    /// RBF bandwidth `γ > 0`.
53    pub gamma: f32,
54    /// Embedding used to map classical data into quantum states.
55    pub embedding: PqkEmbedding,
56}
57
58impl ProjectedKernelConfig {
59    /// Validate the configuration.
60    ///
61    /// # Errors
62    /// Returns [`QuantumError::InvalidParameter`] if `gamma <= 0`.
63    pub fn new(gamma: f32, embedding: PqkEmbedding) -> QuantumResult<Self> {
64        if gamma.is_nan() || gamma <= 0.0 || !gamma.is_finite() {
65            return Err(QuantumError::InvalidParameter {
66                name: "gamma".into(),
67            });
68        }
69        Ok(Self { gamma, embedding })
70    }
71}
72
73/// Embed `data` and return the `3·n` vector of single-qubit Pauli expectations
74/// `(⟨X_0⟩,⟨Y_0⟩,⟨Z_0⟩, ⟨X_1⟩,…)` — the concatenated Bloch vectors.
75///
76/// # Errors
77/// Propagates embedding / expectation-value errors.
78pub fn local_pauli_features(data: &[f32], embedding: PqkEmbedding) -> QuantumResult<Vec<f32>> {
79    if data.is_empty() {
80        return Err(QuantumError::EmptyInput);
81    }
82    let sv: StateVector = match embedding {
83        PqkEmbedding::Angle => crate::embedding::angle::angle_embedding(data)?,
84        PqkEmbedding::ZzFeatureMap { reps } => {
85            crate::embedding::zz_feature::zz_feature_map(data, reps.max(1))?
86        }
87    };
88    let n = sv.n_qubits;
89    let mut feats = Vec::with_capacity(3 * n);
90    for q in 0..n {
91        for p in [PauliOp::X, PauliOp::Y, PauliOp::Z] {
92            let mut ops = vec![PauliOp::I; n];
93            ops[q] = p;
94            let mut ham = Hamiltonian::new();
95            ham.add_term(1.0, ops);
96            let ev = expectation_value(&sv, &ham)?;
97            feats.push(ev);
98        }
99    }
100    Ok(feats)
101}
102
103/// Compute the projected quantum kernel value `k_PQK(x, y)`.
104///
105/// # Errors
106/// Returns [`QuantumError::DimensionMismatch`] if `x` and `y` differ in length,
107/// or propagates embedding errors.
108pub fn projected_kernel(x: &[f32], y: &[f32], cfg: &ProjectedKernelConfig) -> QuantumResult<f32> {
109    if x.len() != y.len() {
110        return Err(QuantumError::DimensionMismatch {
111            expected: x.len(),
112            got: y.len(),
113        });
114    }
115    let fx = local_pauli_features(x, cfg.embedding)?;
116    let fy = local_pauli_features(y, cfg.embedding)?;
117    let sq_dist: f32 = fx
118        .iter()
119        .zip(fy.iter())
120        .map(|(a, b)| {
121            let d = a - b;
122            d * d
123        })
124        .sum();
125    Ok((-cfg.gamma * sq_dist).exp())
126}
127
128/// Build the full PQK Gram matrix `K[i][j] = k_PQK(xs[i], xs[j])`.
129///
130/// Features are computed once per data point and cached, so the cost is
131/// `O(m · embed) + O(m²·3n)` rather than `O(m²·embed)`.
132///
133/// # Errors
134/// Returns [`QuantumError::EmptyInput`] for an empty data set; propagates
135/// embedding errors.
136pub fn projected_kernel_matrix(
137    xs: &[Vec<f32>],
138    cfg: &ProjectedKernelConfig,
139) -> QuantumResult<Vec<Vec<f32>>> {
140    if xs.is_empty() {
141        return Err(QuantumError::EmptyInput);
142    }
143    let m = xs.len();
144    // Precompute features.
145    let mut feats = Vec::with_capacity(m);
146    for x in xs {
147        feats.push(local_pauli_features(x, cfg.embedding)?);
148    }
149    let mut mat = vec![vec![0.0_f32; m]; m];
150    for i in 0..m {
151        for j in i..m {
152            let sq_dist: f32 = feats[i]
153                .iter()
154                .zip(feats[j].iter())
155                .map(|(a, b)| {
156                    let d = a - b;
157                    d * d
158                })
159                .sum();
160            let k = (-cfg.gamma * sq_dist).exp();
161            mat[i][j] = k;
162            mat[j][i] = k;
163        }
164    }
165    Ok(mat)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn config_rejects_bad_gamma() {
174        assert!(ProjectedKernelConfig::new(0.0, PqkEmbedding::Angle).is_err());
175        assert!(ProjectedKernelConfig::new(-1.0, PqkEmbedding::Angle).is_err());
176        assert!(ProjectedKernelConfig::new(1.0, PqkEmbedding::Angle).is_ok());
177    }
178
179    #[test]
180    fn self_kernel_is_one() {
181        let cfg = ProjectedKernelConfig::new(1.0, PqkEmbedding::Angle).expect("cfg");
182        let x = vec![0.5_f32, 1.2, 0.3];
183        let k = projected_kernel(&x, &x, &cfg).expect("kernel");
184        assert!((k - 1.0).abs() < 1e-6, "k={k}");
185    }
186
187    #[test]
188    fn kernel_in_unit_interval_and_decreases_with_distance() {
189        let cfg = ProjectedKernelConfig::new(2.0, PqkEmbedding::Angle).expect("cfg");
190        let x = vec![0.1_f32, 0.2];
191        let near = vec![0.15_f32, 0.22];
192        let far = vec![3.0_f32, -2.5];
193        let k_near = projected_kernel(&x, &near, &cfg).expect("k_near");
194        let k_far = projected_kernel(&x, &far, &cfg).expect("k_far");
195        assert!((0.0..=1.0).contains(&k_near), "k_near={k_near}");
196        assert!((0.0..=1.0).contains(&k_far), "k_far={k_far}");
197        assert!(k_near > k_far, "near {k_near} should exceed far {k_far}");
198    }
199
200    #[test]
201    fn gram_matrix_is_symmetric_psd_diag_one() {
202        let cfg = ProjectedKernelConfig::new(1.5, PqkEmbedding::Angle).expect("cfg");
203        let xs = vec![vec![0.3_f32, 0.7], vec![1.0_f32, -0.5], vec![-0.2_f32, 0.9]];
204        let mat = projected_kernel_matrix(&xs, &cfg).expect("matrix");
205        // Diagonal == 1, symmetric.
206        for (i, row) in mat.iter().enumerate() {
207            assert!((row[i] - 1.0).abs() < 1e-6);
208            for (j, &v) in row.iter().enumerate() {
209                assert!((v - mat[j][i]).abs() < 1e-6);
210            }
211        }
212        // PSD check via a few random test vectors v: vᵀ K v ≥ 0.
213        use crate::handle::LcgRng;
214        let mut rng = LcgRng::new(9);
215        for _ in 0..20 {
216            let v: Vec<f32> = (0..3).map(|_| rng.next_normal()).collect();
217            let mut quad = 0.0_f32;
218            for i in 0..3 {
219                for j in 0..3 {
220                    quad += v[i] * mat[i][j] * v[j];
221                }
222            }
223            assert!(quad >= -1e-4, "quadratic form negative: {quad}");
224        }
225    }
226
227    #[test]
228    fn zz_embedding_variant_works() {
229        let cfg =
230            ProjectedKernelConfig::new(1.0, PqkEmbedding::ZzFeatureMap { reps: 1 }).expect("cfg");
231        let x = vec![0.4_f32, 0.8];
232        let y = vec![0.5_f32, 0.7];
233        let k = projected_kernel(&x, &y, &cfg).expect("k");
234        assert!((0.0..=1.0).contains(&k), "k={k}");
235    }
236}