Skip to main content

oxicuda_quantum/kernel/
trainable.rs

1//! Trainable quantum (embedding) kernels optimized by gradient ascent.
2//!
3//! Reference: Hubregtsen, Wierichs, Gil-Fuster, Derks, Faehrmann & Meyer,
4//! "Training quantum embedding kernels on near-term quantum computers",
5//! Phys. Rev. A 106, 042431 (2022).
6//!
7//! A *quantum embedding kernel* is the fidelity kernel of a **parametrized**
8//! feature map `|ψ_θ(x)⟩`:
9//!
10//! ```text
11//! k_θ(x, y) = |⟨ψ_θ(x) | ψ_θ(y)⟩|².
12//! ```
13//!
14//! The embedding parameters `θ` are trained (classically, here) to make the
15//! kernel useful for a labelled data set by maximizing the **kernel-target
16//! alignment** with the ideal label kernel `Y_ij = y_i y_j` (`y_i ∈ {-1,+1}`):
17//!
18//! ```text
19//! A(θ) = ⟨K_θ, Y⟩_F / ( ‖K_θ‖_F · ‖Y‖_F ),
20//! ```
21//!
22//! the Frobenius inner product of the (centered-by-labels) Gram matrix and the
23//! outer product of the labels. We optimize `A(θ)` by gradient ascent; gradients
24//! are obtained with the parameter-shift rule applied to every kernel entry.
25//!
26//! ## Trainable embedding
27//! The feature map is a single hardware-efficient layer with a per-feature
28//! trainable scale: qubit `i` receives `Ry(θ_i · x_i)`, followed by a CNOT
29//! entangling ladder. The trainable scales `θ` let the optimizer adapt the
30//! data-dependent rotation magnitude (the dominant knob controlling the kernel's
31//! effective bandwidth and expressivity).
32
33use crate::error::{QuantumError, QuantumResult};
34use crate::gates::controlled::apply_cnot;
35use crate::gates::parametric::gate_ry;
36use crate::statevec::apply_1q::apply_1q_inplace;
37use crate::statevec::state::StateVector;
38
39/// Configuration for kernel-alignment training.
40#[derive(Debug, Clone)]
41pub struct TrainableKernelConfig {
42    /// Number of qubits / features.
43    pub n_features: usize,
44    /// Gradient-ascent iterations.
45    pub iters: usize,
46    /// Learning rate for the alignment ascent.
47    pub lr: f32,
48}
49
50impl TrainableKernelConfig {
51    /// Validate the configuration.
52    ///
53    /// # Errors
54    /// Returns [`QuantumError::InvalidParameter`] for a zero feature count, zero
55    /// iterations, or a non-finite/non-positive learning rate.
56    pub fn new(n_features: usize, iters: usize, lr: f32) -> QuantumResult<Self> {
57        if n_features == 0 {
58            return Err(QuantumError::InvalidParameter {
59                name: "n_features".into(),
60            });
61        }
62        if iters == 0 {
63            return Err(QuantumError::InvalidParameter {
64                name: "iters".into(),
65            });
66        }
67        if !lr.is_finite() || lr <= 0.0 {
68            return Err(QuantumError::InvalidParameter { name: "lr".into() });
69        }
70        Ok(Self {
71            n_features,
72            iters,
73            lr,
74        })
75    }
76}
77
78/// A trainable quantum embedding kernel with per-feature rotation scales `θ`.
79#[derive(Debug, Clone)]
80pub struct TrainableKernel {
81    n_features: usize,
82    /// Trainable per-feature scale parameters.
83    pub theta: Vec<f32>,
84}
85
86impl TrainableKernel {
87    /// Construct with all scales initialized to `1.0` (plain angle embedding).
88    #[must_use]
89    pub fn new(n_features: usize) -> Self {
90        Self {
91            n_features,
92            theta: vec![1.0_f32; n_features],
93        }
94    }
95
96    /// Construct with explicit initial scales.
97    ///
98    /// # Errors
99    /// Returns [`QuantumError::DimensionMismatch`] if `theta.len() != n_features`.
100    pub fn with_theta(n_features: usize, theta: Vec<f32>) -> QuantumResult<Self> {
101        if theta.len() != n_features {
102            return Err(QuantumError::DimensionMismatch {
103                expected: n_features,
104                got: theta.len(),
105            });
106        }
107        Ok(Self { n_features, theta })
108    }
109
110    /// Number of features / qubits.
111    #[must_use]
112    pub fn n_features(&self) -> usize {
113        self.n_features
114    }
115
116    /// Embed `x` into `|ψ_θ(x)⟩` with the given scales.
117    fn embed_with(&self, x: &[f32], theta: &[f32]) -> QuantumResult<StateVector> {
118        if x.len() != self.n_features {
119            return Err(QuantumError::DimensionMismatch {
120                expected: self.n_features,
121                got: x.len(),
122            });
123        }
124        let n = self.n_features;
125        let mut sv = StateVector::new_zero_state(n)?;
126        for q in 0..n {
127            apply_1q_inplace(&mut sv, q, &gate_ry(theta[q] * x[q]))?;
128        }
129        for q in 0..n.saturating_sub(1) {
130            apply_cnot(&mut sv, q, q + 1)?;
131        }
132        Ok(sv)
133    }
134
135    /// Kernel value `k_θ(x,y)` using the **current** `self.theta`.
136    ///
137    /// # Errors
138    /// Propagates embedding errors.
139    pub fn kernel(&self, x: &[f32], y: &[f32]) -> QuantumResult<f32> {
140        self.kernel_with(x, y, &self.theta)
141    }
142
143    /// Kernel value with an explicit scale vector (used for parameter shifts).
144    fn kernel_with(&self, x: &[f32], y: &[f32], theta: &[f32]) -> QuantumResult<f32> {
145        let psi_x = self.embed_with(x, theta)?;
146        let psi_y = self.embed_with(y, theta)?;
147        let ip = psi_x.inner_product(&psi_y)?;
148        Ok(ip.norm_sqr())
149    }
150
151    /// Full Gram matrix for `xs` using the current scales.
152    ///
153    /// # Errors
154    /// Returns [`QuantumError::EmptyInput`] for empty data; propagates embedding
155    /// errors.
156    pub fn gram(&self, xs: &[Vec<f32>]) -> QuantumResult<Vec<Vec<f32>>> {
157        if xs.is_empty() {
158            return Err(QuantumError::EmptyInput);
159        }
160        let m = xs.len();
161        let mut mat = vec![vec![0.0_f32; m]; m];
162        for i in 0..m {
163            for j in i..m {
164                let k = self.kernel(&xs[i], &xs[j])?;
165                mat[i][j] = k;
166                mat[j][i] = k;
167            }
168        }
169        Ok(mat)
170    }
171
172    /// Kernel-target alignment `A = ⟨K, Y⟩_F / (‖K‖_F ‖Y‖_F)` for `±1` labels,
173    /// evaluated with an explicit `theta`.
174    fn alignment_with(&self, xs: &[Vec<f32>], labels: &[f32], theta: &[f32]) -> QuantumResult<f32> {
175        let m = xs.len();
176        let mut k_dot_y = 0.0_f32;
177        let mut k_norm_sq = 0.0_f32;
178        let mut y_norm_sq = 0.0_f32;
179        for i in 0..m {
180            for j in 0..m {
181                let kij = self.kernel_with(&xs[i], &xs[j], theta)?;
182                let yij = labels[i] * labels[j];
183                k_dot_y += kij * yij;
184                k_norm_sq += kij * kij;
185                y_norm_sq += yij * yij;
186            }
187        }
188        let denom = (k_norm_sq.sqrt() * y_norm_sq.sqrt()).max(1e-12);
189        Ok(k_dot_y / denom)
190    }
191
192    /// Kernel-target alignment with the current scales.
193    ///
194    /// # Errors
195    /// Returns [`QuantumError::DimensionMismatch`] if `labels.len() != xs.len()`,
196    /// [`QuantumError::EmptyInput`] for empty data, and propagates embedding errors.
197    pub fn alignment(&self, xs: &[Vec<f32>], labels: &[f32]) -> QuantumResult<f32> {
198        if xs.is_empty() {
199            return Err(QuantumError::EmptyInput);
200        }
201        if labels.len() != xs.len() {
202            return Err(QuantumError::DimensionMismatch {
203                expected: xs.len(),
204                got: labels.len(),
205            });
206        }
207        self.alignment_with(xs, labels, &self.theta)
208    }
209
210    /// Train the scales `θ` by gradient ascent on the kernel-target alignment.
211    ///
212    /// Gradients use the parameter-shift rule on each scale component (the
213    /// alignment is a smooth function of the scales). Returns the alignment value
214    /// at every iteration (length `iters + 1`, including the initial alignment).
215    ///
216    /// # Errors
217    /// Returns [`QuantumError::DimensionMismatch`] on a label/data length
218    /// mismatch and propagates embedding errors.
219    pub fn train_alignment(
220        &mut self,
221        xs: &[Vec<f32>],
222        labels: &[f32],
223        cfg: &TrainableKernelConfig,
224    ) -> QuantumResult<Vec<f32>> {
225        if xs.is_empty() {
226            return Err(QuantumError::EmptyInput);
227        }
228        if labels.len() != xs.len() {
229            return Err(QuantumError::DimensionMismatch {
230                expected: xs.len(),
231                got: labels.len(),
232            });
233        }
234        let shift = std::f32::consts::FRAC_PI_2;
235        let mut history = Vec::with_capacity(cfg.iters + 1);
236        history.push(self.alignment_with(xs, labels, &self.theta)?);
237
238        for _ in 0..cfg.iters {
239            let mut grad = vec![0.0_f32; self.n_features];
240            for p in 0..self.n_features {
241                let mut t_plus = self.theta.clone();
242                let mut t_minus = self.theta.clone();
243                t_plus[p] += shift;
244                t_minus[p] -= shift;
245                let a_plus = self.alignment_with(xs, labels, &t_plus)?;
246                let a_minus = self.alignment_with(xs, labels, &t_minus)?;
247                grad[p] = 0.5 * (a_plus - a_minus);
248            }
249            // Ascent (maximize alignment).
250            for (t, g) in self.theta.iter_mut().zip(grad.iter()) {
251                *t += cfg.lr * g;
252            }
253            history.push(self.alignment_with(xs, labels, &self.theta)?);
254        }
255        Ok(history)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn config_validation() {
265        assert!(TrainableKernelConfig::new(0, 5, 0.1).is_err());
266        assert!(TrainableKernelConfig::new(2, 0, 0.1).is_err());
267        assert!(TrainableKernelConfig::new(2, 5, 0.0).is_err());
268        assert!(TrainableKernelConfig::new(2, 5, 0.1).is_ok());
269    }
270
271    #[test]
272    fn self_kernel_is_one() {
273        let tk = TrainableKernel::new(2);
274        let x = vec![0.5_f32, 1.0];
275        let k = tk.kernel(&x, &x).expect("kernel");
276        assert!((k - 1.0).abs() < 1e-5, "k={k}");
277    }
278
279    #[test]
280    fn gram_symmetric_diag_one() {
281        let tk = TrainableKernel::new(2);
282        let xs = vec![vec![0.3_f32, 0.7], vec![1.0_f32, -0.5]];
283        let g = tk.gram(&xs).expect("gram");
284        assert!((g[0][0] - 1.0).abs() < 1e-5);
285        assert!((g[1][1] - 1.0).abs() < 1e-5);
286        assert!((g[0][1] - g[1][0]).abs() < 1e-6);
287    }
288
289    #[test]
290    fn alignment_in_unit_interval() {
291        let tk = TrainableKernel::new(2);
292        let xs = vec![
293            vec![0.2_f32, 0.1],
294            vec![0.3_f32, 0.2],
295            vec![2.0_f32, 1.8],
296            vec![2.1_f32, 1.9],
297        ];
298        let labels = vec![1.0_f32, 1.0, -1.0, -1.0];
299        let a = tk.alignment(&xs, &labels).expect("alignment");
300        assert!((-1.0..=1.0).contains(&a), "alignment={a}");
301    }
302
303    #[test]
304    fn training_increases_alignment() {
305        // Two well-separated clusters with opposite labels; training the scales
306        // should not decrease (and generally increases) the alignment.
307        let xs = vec![
308            vec![0.1_f32, 0.0],
309            vec![0.0_f32, 0.1],
310            vec![1.5_f32, 1.6],
311            vec![1.6_f32, 1.5],
312        ];
313        let labels = vec![1.0_f32, 1.0, -1.0, -1.0];
314        let cfg = TrainableKernelConfig::new(2, 25, 0.4).expect("cfg");
315        let mut tk = TrainableKernel::new(2);
316        let history = tk.train_alignment(&xs, &labels, &cfg).expect("train");
317        let first = history[0];
318        let last = *history.last().expect("non-empty history");
319        assert!(last >= first - 1e-3, "alignment dropped: {first} → {last}");
320        assert!(last.is_finite());
321    }
322
323    #[test]
324    fn dimension_mismatch_rejected() {
325        let tk = TrainableKernel::new(3);
326        let x = vec![0.1_f32, 0.2]; // wrong length
327        let y = vec![0.1_f32, 0.2, 0.3];
328        assert!(tk.kernel(&x, &y).is_err());
329    }
330}