Skip to main content

quantrs2_ml/
qaoa_warm_start.rs

1//! QAOA warm-start initialization using spectral relaxation.
2//!
3//! Initializes QAOA variational parameters from a classical spectral
4//! relaxation solution (Egger et al. 2021, "Warm-starting quantum optimization").
5//!
6//! Instead of random initial angles, we use the Fiedler vector of the graph
7//! Laplacian to compute initial qubit rotation angles, giving QAOA a head
8//! start near the classical solution.
9
10use scirs2_core::ndarray::{Array2, ArrayView2};
11use std::f64::consts::PI;
12
13use crate::error::{MLError, Result};
14
15/// Strategy for computing the classical warm-start solution.
16#[non_exhaustive]
17#[derive(Debug, Clone)]
18pub enum WarmStartStrategy {
19    /// Spectral relaxation via Fiedler vector of graph Laplacian.
20    Spectral,
21    /// Degree-proportional initialization (faster but lower quality).
22    Degree,
23    /// Random initialization (baseline) with a fixed seed.
24    Random {
25        /// RNG seed for reproducibility.
26        seed: u64,
27    },
28}
29
30/// Graph for MaxCut warm-start.
31#[derive(Debug, Clone)]
32pub struct Graph {
33    /// Number of vertices.
34    pub n_vertices: usize,
35    /// Weighted edge list: (u, v, weight).
36    pub edges: Vec<(usize, usize, f64)>,
37}
38
39/// Result of warm-start initialization.
40#[derive(Debug, Clone)]
41pub struct WarmStartAngles {
42    /// Initial gamma angles (problem parameters), one per QAOA layer.
43    pub gammas: Vec<f64>,
44    /// Initial beta angles (mixer parameters), one per QAOA layer.
45    pub betas: Vec<f64>,
46    /// Classical objective value (MaxCut lower bound).
47    pub classical_objective: f64,
48    /// Fiedler vector values mapped to [-1, 1], one per vertex.
49    pub vertex_assignments: Vec<f64>,
50}
51
52/// QAOA warm-start optimizer.
53#[derive(Debug, Clone)]
54pub struct WarmStartQAOAOptimizer {
55    /// The graph to solve MaxCut on.
56    pub graph: Graph,
57    /// Number of QAOA layers (p parameter).
58    pub n_layers: usize,
59    /// Which strategy to use for the classical initial solution.
60    pub strategy: WarmStartStrategy,
61}
62
63// ---------------------------------------------------------------------------
64// Internal linear-algebra helpers
65// ---------------------------------------------------------------------------
66
67/// Gaussian elimination with partial pivoting, returning the solution vector.
68///
69/// # Errors
70/// Returns `MLError::NumericalError` if the matrix is singular.
71fn solve_linear_system_local(a: &Array2<f64>, b: &[f64]) -> Result<Vec<f64>> {
72    let n = b.len();
73    if a.nrows() != n || a.ncols() != n {
74        return Err(MLError::DimensionMismatch(format!(
75            "Matrix ({} x {}) incompatible with rhs length {}",
76            a.nrows(),
77            a.ncols(),
78            n
79        )));
80    }
81
82    // Build augmented matrix [A | b]
83    let mut aug: Vec<Vec<f64>> = (0..n)
84        .map(|i| {
85            let mut row: Vec<f64> = (0..n).map(|j| a[[i, j]]).collect();
86            row.push(b[i]);
87            row
88        })
89        .collect();
90
91    // Forward elimination with partial pivoting
92    for k in 0..n {
93        // Find pivot
94        let mut max_val = aug[k][k].abs();
95        let mut max_idx = k;
96        for row in (k + 1)..n {
97            let val = aug[row][k].abs();
98            if val > max_val {
99                max_val = val;
100                max_idx = row;
101            }
102        }
103
104        if max_val < 1e-12 {
105            return Err(MLError::NumericalError(format!(
106                "Singular matrix: |pivot| = {:.2e} < 1e-12 at column {}",
107                max_val, k
108            )));
109        }
110
111        if max_idx != k {
112            aug.swap(k, max_idx);
113        }
114
115        let pivot = aug[k][k];
116        for i in (k + 1)..n {
117            let factor = aug[i][k] / pivot;
118            for col in k..=n {
119                let sub = factor * aug[k][col];
120                aug[i][col] -= sub;
121            }
122        }
123    }
124
125    // Back substitution
126    let mut x = vec![0.0_f64; n];
127    for i in (0..n).rev() {
128        let mut sum = aug[i][n];
129        for j in (i + 1)..n {
130            sum -= aug[i][j] * x[j];
131        }
132        x[i] = sum / aug[i][i];
133    }
134
135    Ok(x)
136}
137
138// ---------------------------------------------------------------------------
139// Graph methods
140// ---------------------------------------------------------------------------
141
142impl Graph {
143    /// Compute the graph Laplacian L = D - A.
144    ///
145    /// `D` is the diagonal degree matrix (weighted degrees) and `A` is the
146    /// weighted adjacency matrix.
147    pub fn laplacian(&self) -> Array2<f64> {
148        let n = self.n_vertices;
149        let mut l = Array2::<f64>::zeros((n, n));
150
151        for &(u, v, w) in &self.edges {
152            // Adjacency entries (undirected)
153            l[[u, v]] -= w;
154            l[[v, u]] -= w;
155            // Degree entries
156            l[[u, u]] += w;
157            l[[v, v]] += w;
158        }
159
160        l
161    }
162
163    /// Compute the Fiedler vector (second-smallest eigenvector of the Laplacian).
164    ///
165    /// Uses inverse power iteration with a small shift λ = 0.1 to avoid the
166    /// trivial zero eigenvalue, deflating the constant component at each step
167    /// so the iteration converges to the Fiedler vector rather than the
168    /// all-ones eigenvector.
169    ///
170    /// Returns a unit vector whose entries are the vertex assignments in [-1, 1].
171    ///
172    /// # Errors
173    /// Returns `MLError::NumericalError` if the shifted Laplacian is singular.
174    pub fn fiedler_vector(&self) -> Result<Vec<f64>> {
175        let n = self.n_vertices;
176        if n < 2 {
177            return Err(MLError::InvalidInput(
178                "Graph must have at least 2 vertices to compute the Fiedler vector".to_string(),
179            ));
180        }
181
182        let l = self.laplacian();
183
184        // L_shifted = L + λI  (λ = 0.1 keeps the zero eigenvalue at 0.1 and
185        //  all other eigenvalues remain ≥ 0.1, so the inverse converges to the
186        //  eigenvector for the *smallest* eigenvalue of L_shifted, which is the
187        //  constant all-ones vector.  We deflate that away each iteration.)
188        let lambda_shift = 0.1_f64;
189        let mut l_shifted = l.clone();
190        for i in 0..n {
191            l_shifted[[i, i]] += lambda_shift;
192        }
193
194        // Initialise x orthogonal to the constant vector (subtract mean, then normalise).
195        let mut rng = fastrand::Rng::with_seed(42);
196        let mut x: Vec<f64> = (0..n).map(|_| rng.f64() * 2.0 - 1.0).collect();
197
198        // Subtract mean so x ⊥ 1 from the start
199        let mean = x.iter().sum::<f64>() / n as f64;
200        for xi in x.iter_mut() {
201            *xi -= mean;
202        }
203        // Normalise
204        let norm = x.iter().map(|v| v * v).sum::<f64>().sqrt();
205        if norm < 1e-14 {
206            // Pathological initialisation – use a deterministic fallback
207            for (i, xi) in x.iter_mut().enumerate() {
208                *xi = (i as f64) - (n as f64 - 1.0) / 2.0;
209            }
210            let norm2 = x.iter().map(|v| v * v).sum::<f64>().sqrt();
211            for xi in x.iter_mut() {
212                *xi /= norm2;
213            }
214        } else {
215            for xi in x.iter_mut() {
216                *xi /= norm;
217            }
218        }
219
220        // Inverse power iteration with deflation
221        for _ in 0..100 {
222            // Solve L_shifted * x_new = x
223            let x_new = solve_linear_system_local(&l_shifted, &x)?;
224
225            // Deflate the constant component: x_new -= mean(x_new)
226            let m = x_new.iter().sum::<f64>() / n as f64;
227            let mut x_new: Vec<f64> = x_new.into_iter().map(|v| v - m).collect();
228
229            // Normalise
230            let norm = x_new.iter().map(|v| v * v).sum::<f64>().sqrt();
231            if norm < 1e-14 {
232                break; // Converged or degenerate
233            }
234            for xi in x_new.iter_mut() {
235                *xi /= norm;
236            }
237
238            x = x_new;
239        }
240
241        // Clip to [-1, 1] to absorb floating-point drift
242        let x_clipped: Vec<f64> = x.into_iter().map(|v| v.clamp(-1.0, 1.0)).collect();
243        Ok(x_clipped)
244    }
245
246    /// Compute the MaxCut value for the given vertex assignments.
247    ///
248    /// For each edge (u, v, w), adds w * (1 − sign(a_u) * sign(a_v)) / 2 to the total.
249    pub fn maxcut_value(&self, assignments: &[f64]) -> f64 {
250        self.edges
251            .iter()
252            .map(|&(u, v, w)| w * (1.0 - assignments[u].signum() * assignments[v].signum()) / 2.0)
253            .sum()
254    }
255}
256
257// ---------------------------------------------------------------------------
258// WarmStartQAOAOptimizer methods
259// ---------------------------------------------------------------------------
260
261impl WarmStartQAOAOptimizer {
262    /// Compute initial QAOA angles using the selected warm-start strategy.
263    ///
264    /// # Errors
265    /// Returns `MLError` if the underlying computation fails (e.g. singular Laplacian).
266    pub fn compute_initial_angles(&self) -> Result<WarmStartAngles> {
267        let p = self.n_layers;
268        if p == 0 {
269            return Err(MLError::InvalidInput(
270                "n_layers must be at least 1".to_string(),
271            ));
272        }
273
274        let n = self.graph.n_vertices;
275
276        // Uniform angle initialisation (same for all strategies)
277        let gammas = vec![PI / (2.0 * p as f64); p];
278        let betas = vec![PI / (4.0 * p as f64); p];
279
280        match &self.strategy {
281            WarmStartStrategy::Spectral => {
282                let fiedler = self.graph.fiedler_vector()?;
283
284                // Map Fiedler values to rotation angles: θ_i = arccos(v_i)
285                // The Fiedler vector values are already in [-1, 1] after clipping.
286                let _thetas: Vec<f64> = fiedler.iter().map(|&v| v.acos()).collect();
287
288                let classical_objective = self.graph.maxcut_value(&fiedler);
289
290                Ok(WarmStartAngles {
291                    gammas,
292                    betas,
293                    classical_objective,
294                    vertex_assignments: fiedler,
295                })
296            }
297
298            WarmStartStrategy::Degree => {
299                // Degree-proportional: assign ±1 based on whether vertex degree is
300                // above or below the mean degree.
301                let mut degrees = vec![0.0_f64; n];
302                for &(u, v, w) in &self.graph.edges {
303                    degrees[u] += w;
304                    degrees[v] += w;
305                }
306                let mean_degree = degrees.iter().sum::<f64>() / n as f64;
307                let assignments: Vec<f64> = degrees
308                    .iter()
309                    .map(|&d| if d >= mean_degree { 1.0 } else { -1.0 })
310                    .collect();
311
312                let classical_objective = self.graph.maxcut_value(&assignments);
313
314                Ok(WarmStartAngles {
315                    gammas,
316                    betas,
317                    classical_objective,
318                    vertex_assignments: assignments,
319                })
320            }
321
322            WarmStartStrategy::Random { seed } => {
323                // Seeded random assignments in [-1, 1]
324                let mut rng = fastrand::Rng::with_seed(*seed);
325                let assignments: Vec<f64> = (0..n).map(|_| rng.f64() * 2.0 - 1.0).collect();
326
327                let classical_objective = self.graph.maxcut_value(&assignments);
328
329                Ok(WarmStartAngles {
330                    gammas,
331                    betas,
332                    classical_objective,
333                    vertex_assignments: assignments,
334                })
335            }
336        }
337    }
338
339    /// Run warm-start optimisation using a classical proxy cost function.
340    ///
341    /// The proxy cost is:
342    ///   C(γ, β) = −Σ_{(i,j)∈E} w_ij * (1 − cos(θ_i − θ_j)) / 2
343    ///
344    /// where θ_i = γ[0] * vertex_assignments[i] + β[0] (per-vertex angle).
345    /// Gradient descent is applied for `n_steps` steps.
346    ///
347    /// # Errors
348    /// Returns `MLError` if the initial angle computation fails.
349    pub fn optimize(&self, n_steps: usize) -> Result<WarmStartAngles> {
350        let mut angles = self.compute_initial_angles()?;
351        let lr = 0.01_f64;
352
353        // We parameterise the per-vertex angles as
354        //   φ_v = Σ_k (gammas[k] * v_assignments[v]) + betas[k]
355        // For simplicity in the classical proxy we use a single effective angle:
356        //   φ_v = gammas[0] * v_assignments[v]
357        // and differentiate w.r.t. gammas[0] and betas (treated as global shift).
358
359        for _ in 0..n_steps {
360            // Compute per-vertex angles using first layer parameters only
361            let phi: Vec<f64> = angles
362                .vertex_assignments
363                .iter()
364                .map(|&v| angles.gammas[0] * v + angles.betas[0])
365                .collect();
366
367            // Cost: C = -Σ w_ij * (1 - cos(φ_i - φ_j)) / 2
368            // Gradient w.r.t. gammas[0]:
369            //   dC/dγ = Σ w_ij * sin(φ_i - φ_j) * (v_i - v_j) / 2
370            // Gradient w.r.t. betas[0]:
371            //   dC/dβ = Σ w_ij * sin(φ_i - φ_j)
372            let mut d_gamma = 0.0_f64;
373            let mut d_beta = 0.0_f64;
374
375            for &(u, v_idx, w) in &self.graph.edges {
376                let diff = phi[u] - phi[v_idx];
377                let sin_diff = diff.sin();
378                let a_u = angles.vertex_assignments[u];
379                let a_v = angles.vertex_assignments[v_idx];
380                d_gamma += w * sin_diff * (a_u - a_v) / 2.0;
381                d_beta += w * sin_diff;
382            }
383
384            // Minimise cost → gradient descent (cost is already negated above, so signs flip)
385            angles.gammas[0] -= lr * d_gamma;
386            angles.betas[0] -= lr * d_beta;
387
388            // Propagate the same update magnitude to remaining layers (uniform)
389            for k in 1..self.n_layers {
390                angles.gammas[k] -= lr * d_gamma / (k as f64 + 1.0);
391                angles.betas[k] -= lr * d_beta / (k as f64 + 1.0);
392            }
393        }
394
395        // Recompute objective with updated angles
396        let phi_final: Vec<f64> = angles
397            .vertex_assignments
398            .iter()
399            .map(|&v| angles.gammas[0] * v + angles.betas[0])
400            .collect();
401
402        let cost: f64 = self
403            .graph
404            .edges
405            .iter()
406            .map(|&(u, v_idx, w)| {
407                let diff = phi_final[u] - phi_final[v_idx];
408                -w * (1.0 - diff.cos()) / 2.0
409            })
410            .sum();
411
412        // Return negative cost as objective (maximisation)
413        angles.classical_objective = -cost;
414
415        Ok(angles)
416    }
417}
418
419// ---------------------------------------------------------------------------
420// Tests
421// ---------------------------------------------------------------------------
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_graph_laplacian() {
429        // 4-vertex complete graph K4
430        let g = Graph {
431            n_vertices: 4,
432            edges: vec![
433                (0, 1, 1.0),
434                (0, 2, 1.0),
435                (0, 3, 1.0),
436                (1, 2, 1.0),
437                (1, 3, 1.0),
438                (2, 3, 1.0),
439            ],
440        };
441        let l = g.laplacian();
442        // Diagonal entries should be degree = 3
443        assert!((l[[0, 0]] - 3.0).abs() < 1e-10);
444        // Off-diagonal should be -1
445        assert!((l[[0, 1]] + 1.0).abs() < 1e-10);
446    }
447
448    #[test]
449    fn test_warm_start_spectral() {
450        // 4-vertex path graph (MaxCut = 2)
451        let g = Graph {
452            n_vertices: 4,
453            edges: vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)],
454        };
455        let opt = WarmStartQAOAOptimizer {
456            graph: g,
457            n_layers: 2,
458            strategy: WarmStartStrategy::Spectral,
459        };
460        let angles = opt
461            .compute_initial_angles()
462            .expect("spectral warm-start should succeed");
463        // Should have 2 gammas and 2 betas
464        assert_eq!(angles.gammas.len(), 2);
465        assert_eq!(angles.betas.len(), 2);
466        // Classical objective should be non-negative
467        assert!(angles.classical_objective >= 0.0);
468        // Vertex assignments should be in [-1, 1]
469        for v in &angles.vertex_assignments {
470            assert!(
471                *v >= -1.0 - 1e-10 && *v <= 1.0 + 1e-10,
472                "vertex assignment {v} out of range"
473            );
474        }
475    }
476
477    #[test]
478    fn test_warm_start_better_than_random_k4() {
479        // Complete graph K4, optimal MaxCut = 4
480        let g = Graph {
481            n_vertices: 4,
482            edges: vec![
483                (0, 1, 1.0),
484                (0, 2, 1.0),
485                (0, 3, 1.0),
486                (1, 2, 1.0),
487                (1, 3, 1.0),
488                (2, 3, 1.0),
489            ],
490        };
491        let opt_spectral = WarmStartQAOAOptimizer {
492            graph: g.clone(),
493            n_layers: 1,
494            strategy: WarmStartStrategy::Spectral,
495        };
496        let angles = opt_spectral
497            .compute_initial_angles()
498            .expect("spectral warm-start on K4 should succeed");
499        // Spectral warm-start should give positive MaxCut estimate
500        assert!(
501            angles.classical_objective > 0.0,
502            "Spectral warm-start should give positive objective, got {}",
503            angles.classical_objective
504        );
505    }
506
507    #[test]
508    fn test_degree_strategy() {
509        let g = Graph {
510            n_vertices: 3,
511            edges: vec![(0, 1, 1.0), (1, 2, 1.0)],
512        };
513        let opt = WarmStartQAOAOptimizer {
514            graph: g,
515            n_layers: 1,
516            strategy: WarmStartStrategy::Degree,
517        };
518        let angles = opt
519            .compute_initial_angles()
520            .expect("degree strategy warm-start should succeed");
521        assert_eq!(angles.gammas.len(), 1);
522    }
523
524    #[test]
525    fn test_random_strategy() {
526        let g = Graph {
527            n_vertices: 4,
528            edges: vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0), (3, 0, 1.0)],
529        };
530        let opt = WarmStartQAOAOptimizer {
531            graph: g,
532            n_layers: 2,
533            strategy: WarmStartStrategy::Random { seed: 12345 },
534        };
535        let angles = opt
536            .compute_initial_angles()
537            .expect("random strategy warm-start should succeed");
538        // Should produce the right number of angles
539        assert_eq!(angles.gammas.len(), 2);
540        assert_eq!(angles.betas.len(), 2);
541        // Vertex assignments from seeded random are in (-1, 1)
542        for v in &angles.vertex_assignments {
543            assert!(
544                *v >= -1.0 && *v <= 1.0,
545                "random assignment {v} out of range"
546            );
547        }
548    }
549
550    #[test]
551    fn test_optimize_runs() {
552        let g = Graph {
553            n_vertices: 4,
554            edges: vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)],
555        };
556        let opt = WarmStartQAOAOptimizer {
557            graph: g,
558            n_layers: 2,
559            strategy: WarmStartStrategy::Spectral,
560        };
561        let result = opt.optimize(50).expect("optimize should succeed");
562        assert_eq!(result.gammas.len(), 2);
563    }
564
565    #[test]
566    fn test_maxcut_value() {
567        // Path 0-1-2-3: optimal cut = {0,2} vs {1,3}, value = 3
568        let g = Graph {
569            n_vertices: 4,
570            edges: vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)],
571        };
572        // All alternating: 0→+1, 1→-1, 2→+1, 3→-1
573        let assignments = [1.0, -1.0, 1.0, -1.0];
574        let cut = g.maxcut_value(&assignments);
575        // All 3 edges are cut (signs differ)
576        assert!((cut - 3.0).abs() < 1e-10, "Expected cut=3, got {cut}");
577    }
578
579    #[test]
580    fn test_linear_system_singular() {
581        // Singular matrix should return NumericalError
582        let a = Array2::<f64>::zeros((2, 2));
583        let b = vec![1.0, 2.0];
584        let result = solve_linear_system_local(&a, &b);
585        assert!(result.is_err());
586    }
587}