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}