Skip to main content

quantrs2_anneal/
quantum_boltzmann_machine.rs

1//! Quantum Boltzmann Machines for machine learning with quantum annealing
2//!
3//! This module provides implementations of Restricted Boltzmann Machines (RBMs)
4//! and other Boltzmann machine variants that leverage quantum annealing for
5//! sampling and training, enabling quantum machine learning applications.
6
7use scirs2_core::random::prelude::*;
8use scirs2_core::random::ChaCha8Rng;
9use scirs2_core::random::{Rng, SeedableRng};
10use scirs2_core::SliceRandomExt;
11use std::collections::HashMap;
12use std::time::{Duration, Instant};
13use thiserror::Error;
14
15use crate::ising::{IsingError, IsingModel};
16use crate::simulator::{AnnealingParams, AnnealingSolution, QuantumAnnealingSimulator};
17
18/// Errors that can occur in quantum Boltzmann machine operations
19#[derive(Error, Debug)]
20pub enum QbmError {
21    /// Ising model error
22    #[error("Ising error: {0}")]
23    IsingError(#[from] IsingError),
24
25    /// Invalid model configuration
26    #[error("Invalid model: {0}")]
27    InvalidModel(String),
28
29    /// Training error
30    #[error("Training error: {0}")]
31    TrainingError(String),
32
33    /// Sampling error
34    #[error("Sampling error: {0}")]
35    SamplingError(String),
36
37    /// Data format error
38    #[error("Data error: {0}")]
39    DataError(String),
40}
41
42/// Result type for QBM operations
43pub type QbmResult<T> = Result<T, QbmError>;
44
45/// Accumulated contrastive-divergence gradient statistics for one mini-batch.
46///
47/// For a Restricted Boltzmann Machine the log-likelihood gradient of the
48/// weights is `⟨v_i h_j⟩_data − ⟨v_i h_j⟩_model`, the visible-bias gradient is
49/// `⟨v_i⟩_data − ⟨v_i⟩_model`, and the hidden-bias gradient is
50/// `⟨h_j⟩_data − ⟨h_j⟩_model`. The "data" expectations are taken over the
51/// positive phase (training sample clamped on the visible units) and the
52/// "model" expectations over the negative phase (the reconstruction produced by
53/// `k` steps of (quantum) Gibbs sampling). These accumulators sum the per-sample
54/// contributions; the caller divides by the batch size to obtain the mean
55/// gradient used for the parameter update.
56#[derive(Debug, Clone)]
57struct CdGradients {
58    /// Sum over the batch of `v_i h_j` correlations, positive minus negative.
59    weight_grad: Vec<Vec<f64>>,
60    /// Sum over the batch of `v_i`, positive minus negative.
61    visible_bias_grad: Vec<f64>,
62    /// Sum over the batch of `h_j`, positive minus negative.
63    hidden_bias_grad: Vec<f64>,
64    /// Number of samples accumulated (used to form the mean).
65    count: usize,
66}
67
68impl CdGradients {
69    fn new(num_visible: usize, num_hidden: usize) -> Self {
70        Self {
71            weight_grad: vec![vec![0.0; num_hidden]; num_visible],
72            visible_bias_grad: vec![0.0; num_visible],
73            hidden_bias_grad: vec![0.0; num_hidden],
74            count: 0,
75        }
76    }
77
78    /// Accumulate the positive- minus negative-phase contribution of one sample.
79    ///
80    /// `v_pos`/`h_pos` are the visible data vector and the hidden activation
81    /// probabilities of the positive phase; `v_neg`/`h_neg` are the
82    /// reconstructed visible vector and hidden activation probabilities of the
83    /// negative phase. Using the hidden *probabilities* (rather than sampled
84    /// binary states) for the gradient is the standard low-variance estimator
85    /// (Hinton, "A Practical Guide to Training RBMs", 2010).
86    fn accumulate(&mut self, v_pos: &[f64], h_pos: &[f64], v_neg: &[f64], h_neg: &[f64]) {
87        for (i, weight_row) in self.weight_grad.iter_mut().enumerate() {
88            let vp = v_pos[i];
89            let vn = v_neg[i];
90            for (j, w) in weight_row.iter_mut().enumerate() {
91                *w += vp.mul_add(h_pos[j], -(vn * h_neg[j]));
92            }
93        }
94        for (i, g) in self.visible_bias_grad.iter_mut().enumerate() {
95            *g += v_pos[i] - v_neg[i];
96        }
97        for (j, g) in self.hidden_bias_grad.iter_mut().enumerate() {
98            *g += h_pos[j] - h_neg[j];
99        }
100        self.count += 1;
101    }
102}
103
104/// Type of Boltzmann machine unit
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum UnitType {
107    /// Binary units (0/1 or -1/+1)
108    Binary,
109    /// Gaussian units for continuous values
110    Gaussian,
111    /// Softmax units for categorical data
112    Softmax,
113}
114
115/// Configuration for a Boltzmann machine layer
116#[derive(Debug, Clone)]
117pub struct LayerConfig {
118    /// Number of units in the layer
119    pub num_units: usize,
120
121    /// Type of units
122    pub unit_type: UnitType,
123
124    /// Layer name
125    pub name: String,
126
127    /// Bias initialization range
128    pub bias_init_range: (f64, f64),
129
130    /// Whether to use quantum annealing for sampling
131    pub quantum_sampling: bool,
132}
133
134impl LayerConfig {
135    /// Create a new layer configuration
136    #[must_use]
137    pub const fn new(name: String, num_units: usize, unit_type: UnitType) -> Self {
138        Self {
139            num_units,
140            unit_type,
141            name,
142            bias_init_range: (-0.1, 0.1),
143            quantum_sampling: true,
144        }
145    }
146
147    /// Set bias initialization range
148    #[must_use]
149    pub const fn with_bias_range(mut self, min: f64, max: f64) -> Self {
150        self.bias_init_range = (min, max);
151        self
152    }
153
154    /// Enable or disable quantum sampling
155    #[must_use]
156    pub const fn with_quantum_sampling(mut self, enabled: bool) -> Self {
157        self.quantum_sampling = enabled;
158        self
159    }
160}
161
162/// Restricted Boltzmann Machine with quantum annealing support
163#[derive(Debug)]
164pub struct QuantumRestrictedBoltzmannMachine {
165    /// Visible layer configuration
166    visible_config: LayerConfig,
167
168    /// Hidden layer configuration
169    hidden_config: LayerConfig,
170
171    /// Visible unit biases
172    visible_biases: Vec<f64>,
173
174    /// Hidden unit biases
175    hidden_biases: Vec<f64>,
176
177    /// Weight matrix (visible x hidden)
178    weights: Vec<Vec<f64>>,
179
180    /// Training configuration
181    training_config: QbmTrainingConfig,
182
183    /// Random number generator
184    rng: ChaCha8Rng,
185
186    /// Training statistics
187    training_stats: Option<QbmTrainingStats>,
188}
189
190/// Configuration for QBM training
191#[derive(Debug, Clone)]
192pub struct QbmTrainingConfig {
193    /// Learning rate
194    pub learning_rate: f64,
195
196    /// Number of training epochs
197    pub epochs: usize,
198
199    /// Batch size for training
200    pub batch_size: usize,
201
202    /// Number of Gibbs sampling steps for negative phase
203    pub k_steps: usize,
204
205    /// Use persistent contrastive divergence
206    pub persistent_cd: bool,
207
208    /// Weight decay regularization
209    pub weight_decay: f64,
210
211    /// Momentum for parameter updates
212    pub momentum: f64,
213
214    /// Annealing parameters for quantum sampling
215    pub annealing_params: AnnealingParams,
216
217    /// Random seed
218    pub seed: Option<u64>,
219
220    /// Reconstruction error threshold for early stopping
221    pub error_threshold: Option<f64>,
222
223    /// Logging frequency (epochs)
224    pub log_frequency: usize,
225}
226
227impl Default for QbmTrainingConfig {
228    fn default() -> Self {
229        Self {
230            learning_rate: 0.01,
231            epochs: 100,
232            batch_size: 32,
233            k_steps: 1,
234            persistent_cd: false,
235            weight_decay: 0.0001,
236            momentum: 0.5,
237            annealing_params: AnnealingParams::default(),
238            seed: None,
239            error_threshold: None,
240            log_frequency: 10,
241        }
242    }
243}
244
245/// Training statistics for QBM
246#[derive(Debug, Clone)]
247pub struct QbmTrainingStats {
248    /// Training time
249    pub total_training_time: Duration,
250
251    /// Reconstruction error per epoch
252    pub reconstruction_errors: Vec<f64>,
253
254    /// Free energy difference per epoch
255    pub free_energy_diffs: Vec<f64>,
256
257    /// Number of epochs completed
258    pub epochs_completed: usize,
259
260    /// Final reconstruction error
261    pub final_reconstruction_error: f64,
262
263    /// Convergence achieved
264    pub converged: bool,
265
266    /// Quantum sampling statistics
267    pub quantum_sampling_stats: QuantumSamplingStats,
268}
269
270/// Statistics for quantum sampling in QBM
271#[derive(Debug, Clone)]
272pub struct QuantumSamplingStats {
273    /// Total quantum sampling time
274    pub total_sampling_time: Duration,
275
276    /// Number of quantum sampling calls
277    pub sampling_calls: usize,
278
279    /// Average annealing energy
280    pub average_annealing_energy: f64,
281
282    /// Success rate of quantum sampling
283    pub success_rate: f64,
284
285    /// Classical fallback usage percentage
286    pub classical_fallback_rate: f64,
287}
288
289impl Default for QuantumSamplingStats {
290    fn default() -> Self {
291        Self {
292            total_sampling_time: Duration::from_secs(0),
293            sampling_calls: 0,
294            average_annealing_energy: 0.0,
295            success_rate: 1.0,
296            classical_fallback_rate: 0.0,
297        }
298    }
299}
300
301/// Training sample for QBM
302#[derive(Debug, Clone)]
303pub struct TrainingSample {
304    /// Input data
305    pub data: Vec<f64>,
306
307    /// Optional label (for supervised variants)
308    pub label: Option<Vec<f64>>,
309}
310
311impl TrainingSample {
312    /// Create a new training sample
313    #[must_use]
314    pub const fn new(data: Vec<f64>) -> Self {
315        Self { data, label: None }
316    }
317
318    /// Create a labeled training sample
319    #[must_use]
320    pub const fn labeled(data: Vec<f64>, label: Vec<f64>) -> Self {
321        Self {
322            data,
323            label: Some(label),
324        }
325    }
326}
327
328/// Results from QBM inference
329#[derive(Debug, Clone)]
330pub struct QbmInferenceResult {
331    /// Reconstructed visible units
332    pub reconstruction: Vec<f64>,
333
334    /// Hidden unit activations
335    pub hidden_activations: Vec<f64>,
336
337    /// Free energy of the configuration
338    pub free_energy: f64,
339
340    /// Probability of the input
341    pub probability: f64,
342}
343
344impl QuantumRestrictedBoltzmannMachine {
345    /// Create a new Quantum RBM
346    pub fn new(
347        visible_config: LayerConfig,
348        hidden_config: LayerConfig,
349        training_config: QbmTrainingConfig,
350    ) -> QbmResult<Self> {
351        if visible_config.num_units == 0 || hidden_config.num_units == 0 {
352            return Err(QbmError::InvalidModel(
353                "Layer sizes must be > 0".to_string(),
354            ));
355        }
356
357        let rng = match training_config.seed {
358            Some(seed) => ChaCha8Rng::seed_from_u64(seed),
359            None => ChaCha8Rng::seed_from_u64(thread_rng().random()),
360        };
361
362        let mut rbm = Self {
363            visible_config: visible_config.clone(),
364            hidden_config: hidden_config.clone(),
365            visible_biases: vec![0.0; visible_config.num_units],
366            hidden_biases: vec![0.0; hidden_config.num_units],
367            weights: vec![vec![0.0; hidden_config.num_units]; visible_config.num_units],
368            training_config,
369            rng,
370            training_stats: None,
371        };
372
373        rbm.initialize_parameters()?;
374        Ok(rbm)
375    }
376
377    /// Initialize RBM parameters randomly
378    fn initialize_parameters(&mut self) -> QbmResult<()> {
379        // Initialize visible biases
380        let (v_min, v_max) = self.visible_config.bias_init_range;
381        for bias in &mut self.visible_biases {
382            *bias = self.rng.random_range(v_min..v_max);
383        }
384
385        // Initialize hidden biases
386        let (h_min, h_max) = self.hidden_config.bias_init_range;
387        for bias in &mut self.hidden_biases {
388            *bias = self.rng.random_range(h_min..h_max);
389        }
390
391        // Initialize weights using Xavier initialization
392        let fan_in = self.visible_config.num_units as f64;
393        let fan_out = self.hidden_config.num_units as f64;
394        let xavier_std = (2.0 / (fan_in + fan_out)).sqrt();
395
396        for i in 0..self.visible_config.num_units {
397            for j in 0..self.hidden_config.num_units {
398                self.weights[i][j] = self.rng.random_range(-xavier_std..xavier_std);
399            }
400        }
401
402        Ok(())
403    }
404
405    /// Train the RBM on a dataset
406    pub fn train(&mut self, dataset: &[TrainingSample]) -> QbmResult<()> {
407        if dataset.is_empty() {
408            return Err(QbmError::DataError("Dataset is empty".to_string()));
409        }
410
411        // Validate data dimensions
412        let expected_size = self.visible_config.num_units;
413        for (i, sample) in dataset.iter().enumerate() {
414            if sample.data.len() != expected_size {
415                return Err(QbmError::DataError(format!(
416                    "Sample {} has {} features, expected {}",
417                    i,
418                    sample.data.len(),
419                    expected_size
420                )));
421            }
422        }
423
424        println!("Starting QBM training with {} samples", dataset.len());
425
426        let start_time = Instant::now();
427        let mut reconstruction_errors = Vec::new();
428        let mut free_energy_diffs = Vec::new();
429        let mut quantum_stats = QuantumSamplingStats::default();
430
431        // Momentum terms
432        let mut weight_momentum =
433            vec![vec![0.0; self.hidden_config.num_units]; self.visible_config.num_units];
434        let mut visible_bias_momentum = vec![0.0; self.visible_config.num_units];
435        let mut hidden_bias_momentum = vec![0.0; self.hidden_config.num_units];
436
437        // Persistent chains for PCD
438        let mut persistent_chains = if self.training_config.persistent_cd {
439            Some(self.initialize_persistent_chains(self.training_config.batch_size)?)
440        } else {
441            None
442        };
443
444        for epoch in 0..self.training_config.epochs {
445            let epoch_start = Instant::now();
446            let mut epoch_error = 0.0;
447            let mut epoch_free_energy_diff = 0.0;
448            let mut num_batches = 0;
449
450            // Shuffle dataset
451            let mut shuffled_indices: Vec<usize> = (0..dataset.len()).collect();
452            use scirs2_core::random::prelude::*;
453            shuffled_indices.shuffle(&mut self.rng);
454
455            // Process batches
456            for batch_start in (0..dataset.len()).step_by(self.training_config.batch_size) {
457                let batch_end = (batch_start + self.training_config.batch_size).min(dataset.len());
458                let batch_indices = &shuffled_indices[batch_start..batch_end];
459
460                let batch_samples: Vec<&TrainingSample> =
461                    batch_indices.iter().map(|&i| &dataset[i]).collect();
462
463                // Perform contrastive divergence: this both estimates the batch
464                // error/free-energy diagnostics AND accumulates the real
465                // positive-minus-negative phase gradient statistics.
466                let (batch_error, batch_fe_diff, batch_gradients, batch_quantum_stats) =
467                    self.contrastive_divergence_batch(&batch_samples, &mut persistent_chains)?;
468
469                // Apply the real CD gradients with momentum and weight decay.
470                self.update_parameters_with_momentum(
471                    &batch_gradients,
472                    &mut weight_momentum,
473                    &mut visible_bias_momentum,
474                    &mut hidden_bias_momentum,
475                )?;
476
477                epoch_error += batch_error;
478                epoch_free_energy_diff += batch_fe_diff;
479                quantum_stats.merge(&batch_quantum_stats);
480                num_batches += 1;
481            }
482
483            let avg_error = epoch_error / f64::from(num_batches);
484            let avg_fe_diff = epoch_free_energy_diff / f64::from(num_batches);
485
486            reconstruction_errors.push(avg_error);
487            free_energy_diffs.push(avg_fe_diff);
488
489            // Logging
490            if epoch % self.training_config.log_frequency == 0 {
491                println!(
492                    "Epoch {}: Error = {:.6}, FE Diff = {:.6}, Time = {:.2?}",
493                    epoch,
494                    avg_error,
495                    avg_fe_diff,
496                    epoch_start.elapsed()
497                );
498            }
499
500            // Early stopping
501            if let Some(threshold) = self.training_config.error_threshold {
502                if avg_error < threshold {
503                    println!("Converged at epoch {epoch} with error {avg_error:.6}");
504                    break;
505                }
506            }
507        }
508
509        let total_time = start_time.elapsed();
510
511        // Store training statistics
512        self.training_stats = Some(QbmTrainingStats {
513            total_training_time: total_time,
514            reconstruction_errors: reconstruction_errors.clone(),
515            free_energy_diffs,
516            epochs_completed: reconstruction_errors.len(),
517            final_reconstruction_error: reconstruction_errors.last().copied().unwrap_or(0.0),
518            converged: self.training_config.error_threshold.map_or(false, |t| {
519                reconstruction_errors.last().unwrap_or(&f64::INFINITY) < &t
520            }),
521            quantum_sampling_stats: quantum_stats,
522        });
523
524        println!("Training completed in {total_time:.2?}");
525        Ok(())
526    }
527
528    /// Perform contrastive divergence for a batch.
529    ///
530    /// Runs the positive phase (sample hidden units given the clamped data) and
531    /// the negative phase (`k` steps of Gibbs/quantum sampling) for every sample,
532    /// accumulating the CD log-likelihood gradient
533    /// `⟨v h⟩_data − ⟨v h⟩_model` (and the analogous bias gradients) into a
534    /// [`CdGradients`] that the caller applies. Also returns the mean
535    /// reconstruction error and free-energy difference for diagnostics.
536    fn contrastive_divergence_batch(
537        &mut self,
538        batch: &[&TrainingSample],
539        persistent_chains: &mut Option<Vec<Vec<f64>>>,
540    ) -> QbmResult<(f64, f64, CdGradients, QuantumSamplingStats)> {
541        let mut total_error = 0.0;
542        let mut total_fe_diff = 0.0;
543        let mut quantum_stats = QuantumSamplingStats::default();
544        let mut gradients =
545            CdGradients::new(self.visible_config.num_units, self.hidden_config.num_units);
546
547        for (i, sample) in batch.iter().enumerate() {
548            // Positive phase: hidden activation probabilities given the data.
549            let hidden_probs_pos = self.sample_hidden_given_visible(&sample.data)?;
550
551            // Negative phase
552            let (visible_recon, hidden_probs_neg, sampling_stats) =
553                if self.training_config.persistent_cd {
554                    if let Some(ref mut chains) = persistent_chains {
555                        let chain_index = i % chains.len();
556                        let mut chain = chains[chain_index].clone();
557                        for _ in 0..self.training_config.k_steps {
558                            let h_probs = self.sample_hidden_given_visible(&chain)?;
559                            let h_states = self.sample_binary_units(&h_probs)?;
560                            chain = self.sample_visible_given_hidden(&h_states)?;
561                        }
562                        chains[chain_index] = chain.clone();
563                        let h_probs = self.sample_hidden_given_visible(&chain)?;
564                        (chain, h_probs, QuantumSamplingStats::default())
565                    } else {
566                        return Err(QbmError::TrainingError(
567                            "Persistent chains not initialized".to_string(),
568                        ));
569                    }
570                } else {
571                    // Standard CD-k
572                    let mut v_states = sample.data.clone();
573                    let mut sampling_stats = QuantumSamplingStats::default();
574
575                    for _ in 0..self.training_config.k_steps {
576                        let h_probs = self.sample_hidden_given_visible(&v_states)?;
577                        let h_states = if self.hidden_config.quantum_sampling {
578                            let (states, stats) = self.quantum_sample_hidden(&h_probs)?;
579                            sampling_stats.merge(&stats);
580                            states
581                        } else {
582                            self.sample_binary_units(&h_probs)?
583                        };
584
585                        v_states = if self.visible_config.quantum_sampling {
586                            let (states, stats) = self.quantum_sample_visible(&h_states)?;
587                            sampling_stats.merge(&stats);
588                            states
589                        } else {
590                            self.sample_visible_given_hidden(&h_states)?
591                        };
592                    }
593
594                    let h_probs_neg = self.sample_hidden_given_visible(&v_states)?;
595                    (v_states, h_probs_neg, sampling_stats)
596                };
597
598            // Accumulate the contrastive-divergence gradient for this sample.
599            // The positive phase uses the clamped data vector and its hidden
600            // probabilities; the negative phase uses the reconstruction and its
601            // hidden probabilities. Using probabilities (not binary samples) is
602            // the standard low-variance gradient estimator.
603            gradients.accumulate(
604                &sample.data,
605                &hidden_probs_pos,
606                &visible_recon,
607                &hidden_probs_neg,
608            );
609
610            // Compute reconstruction error
611            let error = sample
612                .data
613                .iter()
614                .zip(visible_recon.iter())
615                .map(|(orig, recon)| (orig - recon).powi(2))
616                .sum::<f64>()
617                / sample.data.len() as f64;
618
619            // Compute free energy difference
620            let fe_pos = self.free_energy(&sample.data)?;
621            let fe_neg = self.free_energy(&visible_recon)?;
622            let fe_diff = fe_pos - fe_neg;
623
624            total_error += error;
625            total_fe_diff += fe_diff;
626            quantum_stats.merge(&sampling_stats);
627        }
628
629        Ok((
630            total_error / batch.len() as f64,
631            total_fe_diff / batch.len() as f64,
632            gradients,
633            quantum_stats,
634        ))
635    }
636
637    /// Update parameters using the real contrastive-divergence gradient.
638    ///
639    /// Applies, for each parameter `θ`, the momentum update
640    /// `Δθ ← momentum·Δθ + learning_rate·g`, then `θ ← θ + Δθ`, where `g` is the
641    /// mean CD gradient over the batch. Weights additionally receive L2 weight
642    /// decay (`−learning_rate·decay·w`). This is gradient *ascent* on the
643    /// log-likelihood: the CD gradient already has the sign
644    /// `⟨·⟩_data − ⟨·⟩_model`, so we add it.
645    fn update_parameters_with_momentum(
646        &mut self,
647        gradients: &CdGradients,
648        weight_momentum: &mut [Vec<f64>],
649        visible_bias_momentum: &mut [f64],
650        hidden_bias_momentum: &mut [f64],
651    ) -> QbmResult<()> {
652        let lr = self.training_config.learning_rate;
653        let momentum = self.training_config.momentum;
654        let decay = self.training_config.weight_decay;
655
656        // Mean over the batch; guard against an empty batch.
657        let inv_count = if gradients.count > 0 {
658            1.0 / gradients.count as f64
659        } else {
660            return Ok(());
661        };
662
663        // Update weights with momentum and L2 weight decay.
664        for i in 0..self.visible_config.num_units {
665            for j in 0..self.hidden_config.num_units {
666                let gradient = gradients.weight_grad[i][j] * inv_count;
667                weight_momentum[i][j] = momentum.mul_add(weight_momentum[i][j], lr * gradient);
668                // w += momentum_term - lr * decay * w
669                self.weights[i][j] += weight_momentum[i][j] - lr * decay * self.weights[i][j];
670            }
671        }
672
673        // Update visible biases (no weight decay on biases, as is conventional).
674        for i in 0..self.visible_config.num_units {
675            let gradient = gradients.visible_bias_grad[i] * inv_count;
676            visible_bias_momentum[i] = momentum.mul_add(visible_bias_momentum[i], lr * gradient);
677            self.visible_biases[i] += visible_bias_momentum[i];
678        }
679
680        // Update hidden biases.
681        for j in 0..self.hidden_config.num_units {
682            let gradient = gradients.hidden_bias_grad[j] * inv_count;
683            hidden_bias_momentum[j] = momentum.mul_add(hidden_bias_momentum[j], lr * gradient);
684            self.hidden_biases[j] += hidden_bias_momentum[j];
685        }
686
687        Ok(())
688    }
689
690    /// Initialize persistent chains for PCD
691    fn initialize_persistent_chains(&mut self, num_chains: usize) -> QbmResult<Vec<Vec<f64>>> {
692        let mut chains = Vec::new();
693
694        for _ in 0..num_chains {
695            let chain: Vec<f64> = (0..self.visible_config.num_units)
696                .map(|_| if self.rng.random_bool(0.5) { 1.0 } else { 0.0 })
697                .collect();
698            chains.push(chain);
699        }
700
701        Ok(chains)
702    }
703
704    /// Sample hidden units given visible units
705    fn sample_hidden_given_visible(&self, visible: &[f64]) -> QbmResult<Vec<f64>> {
706        if visible.len() != self.visible_config.num_units {
707            return Err(QbmError::DataError("Visible size mismatch".to_string()));
708        }
709
710        let mut hidden_probs = vec![0.0; self.hidden_config.num_units];
711
712        for j in 0..self.hidden_config.num_units {
713            let activation = self.hidden_biases[j]
714                + visible
715                    .iter()
716                    .enumerate()
717                    .map(|(i, &v)| v * self.weights[i][j])
718                    .sum::<f64>();
719
720            hidden_probs[j] = match self.hidden_config.unit_type {
721                UnitType::Binary => sigmoid(activation),
722                UnitType::Gaussian => activation, // Linear for Gaussian
723                UnitType::Softmax => activation,  // Will be normalized later
724            };
725        }
726
727        // Apply softmax normalization if needed
728        if self.hidden_config.unit_type == UnitType::Softmax {
729            softmax_normalize(&mut hidden_probs);
730        }
731
732        Ok(hidden_probs)
733    }
734
735    /// Sample visible units given hidden units
736    fn sample_visible_given_hidden(&self, hidden: &[f64]) -> QbmResult<Vec<f64>> {
737        if hidden.len() != self.hidden_config.num_units {
738            return Err(QbmError::DataError("Hidden size mismatch".to_string()));
739        }
740
741        let mut visible_probs = vec![0.0; self.visible_config.num_units];
742
743        for i in 0..self.visible_config.num_units {
744            let activation = self.visible_biases[i]
745                + hidden
746                    .iter()
747                    .enumerate()
748                    .map(|(j, &h)| h * self.weights[i][j])
749                    .sum::<f64>();
750
751            visible_probs[i] = match self.visible_config.unit_type {
752                UnitType::Binary => sigmoid(activation),
753                UnitType::Gaussian => activation,
754                UnitType::Softmax => activation,
755            };
756        }
757
758        if self.visible_config.unit_type == UnitType::Softmax {
759            softmax_normalize(&mut visible_probs);
760        }
761
762        Ok(visible_probs)
763    }
764
765    /// Sample binary units from probabilities
766    fn sample_binary_units(&mut self, probabilities: &[f64]) -> QbmResult<Vec<f64>> {
767        Ok(probabilities
768            .iter()
769            .map(|&p| if self.rng.random_bool(p) { 1.0 } else { 0.0 })
770            .collect())
771    }
772
773    /// Quantum sample hidden units using annealing
774    fn quantum_sample_hidden(
775        &mut self,
776        probabilities: &[f64],
777    ) -> QbmResult<(Vec<f64>, QuantumSamplingStats)> {
778        let start_time = Instant::now();
779
780        // Create Ising model for sampling
781        let mut ising_model = IsingModel::new(probabilities.len());
782
783        // Set biases based on probabilities
784        for (i, &prob) in probabilities.iter().enumerate() {
785            let bias = -2.0 * (prob.ln() - (1.0 - prob).ln()); // Logit transformation
786            ising_model.set_bias(i, bias)?;
787        }
788
789        // Sample using quantum annealing
790        if let Ok(sample) = self.quantum_annealing_sample(&ising_model) {
791            let sampling_time = start_time.elapsed();
792            // The annealing energy is the exact Ising energy of the returned
793            // sample under the sampling Hamiltonian. With a single annealing
794            // call the average over draws is just this value.
795            let annealing_energy = ising_model
796                .energy(&sample)
797                .map_err(|e| QbmError::SamplingError(e.to_string()))?;
798            let stats = QuantumSamplingStats {
799                total_sampling_time: sampling_time,
800                sampling_calls: 1,
801                average_annealing_energy: annealing_energy,
802                success_rate: 1.0,
803                classical_fallback_rate: 0.0,
804            };
805
806            // Convert spins to 0/1
807            let binary_sample = sample
808                .iter()
809                .map(|&s| if s > 0 { 1.0 } else { 0.0 })
810                .collect();
811
812            Ok((binary_sample, stats))
813        } else {
814            // Fallback to classical sampling. No annealing took place, so the
815            // reported energy is the Ising energy of the classically-drawn
816            // configuration under the same Hamiltonian (binary 0/1 mapped to
817            // spins -1/+1) — a real quantity, not a fabricated zero.
818            let sample = self.sample_binary_units(probabilities)?;
819            let spins: Vec<i8> = sample
820                .iter()
821                .map(|&v| if v > 0.5 { 1 } else { -1 })
822                .collect();
823            let annealing_energy = ising_model
824                .energy(&spins)
825                .map_err(|e| QbmError::SamplingError(e.to_string()))?;
826            let stats = QuantumSamplingStats {
827                total_sampling_time: start_time.elapsed(),
828                sampling_calls: 1,
829                average_annealing_energy: annealing_energy,
830                success_rate: 0.0,
831                classical_fallback_rate: 1.0,
832            };
833            Ok((sample, stats))
834        }
835    }
836
837    /// Quantum sample visible units using annealing
838    fn quantum_sample_visible(
839        &mut self,
840        hidden_states: &[f64],
841    ) -> QbmResult<(Vec<f64>, QuantumSamplingStats)> {
842        let visible_probs = self.sample_visible_given_hidden(hidden_states)?;
843        self.quantum_sample_hidden(&visible_probs) // Same process
844    }
845
846    /// Perform quantum annealing sampling
847    fn quantum_annealing_sample(&self, model: &IsingModel) -> QbmResult<Vec<i8>> {
848        let mut simulator =
849            QuantumAnnealingSimulator::new(self.training_config.annealing_params.clone())
850                .map_err(|e| QbmError::SamplingError(e.to_string()))?;
851
852        let result = simulator
853            .solve(model)
854            .map_err(|e| QbmError::SamplingError(e.to_string()))?;
855
856        Ok(result.best_spins)
857    }
858
859    /// Compute free energy of a configuration
860    fn free_energy(&self, visible: &[f64]) -> QbmResult<f64> {
861        if visible.len() != self.visible_config.num_units {
862            return Err(QbmError::DataError("Visible size mismatch".to_string()));
863        }
864
865        // Visible bias term
866        let visible_term: f64 = visible
867            .iter()
868            .zip(self.visible_biases.iter())
869            .map(|(&v, &b)| v * b)
870            .sum();
871
872        // Hidden term (sum of log(1 + exp(activation)) for each hidden unit)
873        let hidden_term: f64 = (0..self.hidden_config.num_units)
874            .map(|j| {
875                let activation = self.hidden_biases[j]
876                    + visible
877                        .iter()
878                        .enumerate()
879                        .map(|(i, &v)| v * self.weights[i][j])
880                        .sum::<f64>();
881                activation.exp().ln_1p()
882            })
883            .sum();
884
885        Ok(-(visible_term + hidden_term))
886    }
887
888    /// Perform inference on input data
889    pub fn infer(&mut self, input: &[f64]) -> QbmResult<QbmInferenceResult> {
890        if input.len() != self.visible_config.num_units {
891            return Err(QbmError::DataError("Input size mismatch".to_string()));
892        }
893
894        // Compute hidden activations
895        let hidden_probs = self.sample_hidden_given_visible(input)?;
896        let hidden_states = self.sample_binary_units(&hidden_probs)?;
897
898        // Reconstruct visible units
899        let reconstruction = self.sample_visible_given_hidden(&hidden_states)?;
900
901        // Compute free energy and probability
902        let free_energy = self.free_energy(input)?;
903        let probability = (-free_energy).exp(); // Unnormalized
904
905        Ok(QbmInferenceResult {
906            reconstruction,
907            hidden_activations: hidden_probs,
908            free_energy,
909            probability,
910        })
911    }
912
913    /// Generate samples from the learned distribution
914    pub fn generate_samples(&mut self, num_samples: usize) -> QbmResult<Vec<Vec<f64>>> {
915        let mut samples = Vec::new();
916
917        for _ in 0..num_samples {
918            // Start with random visible state
919            let mut visible: Vec<f64> = (0..self.visible_config.num_units)
920                .map(|_| if self.rng.random_bool(0.5) { 1.0 } else { 0.0 })
921                .collect();
922
923            // Run Gibbs sampling for burn-in
924            for _ in 0..100 {
925                let hidden_probs = self.sample_hidden_given_visible(&visible)?;
926                let hidden_states = self.sample_binary_units(&hidden_probs)?;
927                visible = self.sample_visible_given_hidden(&hidden_states)?;
928            }
929
930            samples.push(visible);
931        }
932
933        Ok(samples)
934    }
935
936    /// Get training statistics
937    #[must_use]
938    pub const fn get_training_stats(&self) -> Option<&QbmTrainingStats> {
939        self.training_stats.as_ref()
940    }
941
942    /// Serialize the learned parameters (biases, weights and layer dimensions)
943    /// to a JSON file at `path`.
944    ///
945    /// Returns a [`QbmError::DataError`] if serialization or the file write
946    /// fails. The on-disk format round-trips through [`load_model`].
947    pub fn save_model(&self, path: &str) -> QbmResult<()> {
948        let params = QbmModelParameters {
949            num_visible: self.visible_config.num_units,
950            num_hidden: self.hidden_config.num_units,
951            visible_biases: self.visible_biases.clone(),
952            hidden_biases: self.hidden_biases.clone(),
953            weights: self.weights.clone(),
954        };
955
956        let json = serde_json::to_string_pretty(&params)
957            .map_err(|e| QbmError::DataError(format!("Failed to serialize model: {e}")))?;
958
959        std::fs::write(path, json)
960            .map_err(|e| QbmError::DataError(format!("Failed to write model to {path}: {e}")))?;
961
962        Ok(())
963    }
964
965    /// Load previously-saved parameters from the JSON file at `path`,
966    /// overwriting the current biases and weights.
967    ///
968    /// Returns a [`QbmError::DataError`] on a missing/invalid file or a
969    /// [`QbmError::InvalidModel`] if the stored layer dimensions do not match
970    /// this machine's configuration (loading mismatched shapes would silently
971    /// corrupt the model, so it is rejected).
972    pub fn load_model(&mut self, path: &str) -> QbmResult<()> {
973        let contents = std::fs::read_to_string(path)
974            .map_err(|e| QbmError::DataError(format!("Failed to read model from {path}: {e}")))?;
975
976        let params: QbmModelParameters = serde_json::from_str(&contents)
977            .map_err(|e| QbmError::DataError(format!("Failed to deserialize model: {e}")))?;
978
979        if params.num_visible != self.visible_config.num_units
980            || params.num_hidden != self.hidden_config.num_units
981        {
982            return Err(QbmError::InvalidModel(format!(
983                "Model dimensions ({}x{}) do not match this RBM ({}x{})",
984                params.num_visible,
985                params.num_hidden,
986                self.visible_config.num_units,
987                self.hidden_config.num_units
988            )));
989        }
990
991        if params.visible_biases.len() != self.visible_config.num_units
992            || params.hidden_biases.len() != self.hidden_config.num_units
993            || params.weights.len() != self.visible_config.num_units
994            || params
995                .weights
996                .iter()
997                .any(|row| row.len() != self.hidden_config.num_units)
998        {
999            return Err(QbmError::InvalidModel(
1000                "Stored parameter arrays have inconsistent shapes".to_string(),
1001            ));
1002        }
1003
1004        self.visible_biases = params.visible_biases;
1005        self.hidden_biases = params.hidden_biases;
1006        self.weights = params.weights;
1007
1008        Ok(())
1009    }
1010}
1011
1012/// Serializable snapshot of the learned RBM parameters used by
1013/// [`QuantumRestrictedBoltzmannMachine::save_model`] /
1014/// [`QuantumRestrictedBoltzmannMachine::load_model`].
1015#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1016struct QbmModelParameters {
1017    num_visible: usize,
1018    num_hidden: usize,
1019    visible_biases: Vec<f64>,
1020    hidden_biases: Vec<f64>,
1021    weights: Vec<Vec<f64>>,
1022}
1023
1024impl QuantumSamplingStats {
1025    /// Merge another stats object into this one
1026    fn merge(&mut self, other: &Self) {
1027        self.total_sampling_time += other.total_sampling_time;
1028        self.sampling_calls += other.sampling_calls;
1029
1030        if self.sampling_calls > 0 {
1031            let total_calls = self.sampling_calls as f64;
1032            self.average_annealing_energy = self.average_annealing_energy.mul_add(
1033                total_calls - other.sampling_calls as f64,
1034                other.average_annealing_energy * other.sampling_calls as f64,
1035            ) / total_calls;
1036
1037            self.success_rate = self.success_rate.mul_add(
1038                total_calls - other.sampling_calls as f64,
1039                other.success_rate * other.sampling_calls as f64,
1040            ) / total_calls;
1041
1042            self.classical_fallback_rate = self.classical_fallback_rate.mul_add(
1043                total_calls - other.sampling_calls as f64,
1044                other.classical_fallback_rate * other.sampling_calls as f64,
1045            ) / total_calls;
1046        }
1047    }
1048}
1049
1050/// Sigmoid activation function
1051fn sigmoid(x: f64) -> f64 {
1052    1.0 / (1.0 + (-x).exp())
1053}
1054
1055/// Apply softmax normalization in-place
1056fn softmax_normalize(values: &mut [f64]) {
1057    let max_val = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1058    let sum: f64 = values.iter().map(|&x| (x - max_val).exp()).sum();
1059
1060    for value in values.iter_mut() {
1061        *value = (*value - max_val).exp() / sum;
1062    }
1063}
1064
1065/// Helper functions for different QBM variants
1066
1067/// Create a binary-binary RBM for typical unsupervised learning
1068pub fn create_binary_rbm(
1069    num_visible: usize,
1070    num_hidden: usize,
1071    training_config: QbmTrainingConfig,
1072) -> QbmResult<QuantumRestrictedBoltzmannMachine> {
1073    let visible_config = LayerConfig::new("visible".to_string(), num_visible, UnitType::Binary);
1074    let hidden_config = LayerConfig::new("hidden".to_string(), num_hidden, UnitType::Binary);
1075
1076    QuantumRestrictedBoltzmannMachine::new(visible_config, hidden_config, training_config)
1077}
1078
1079/// Create a Gaussian-Bernoulli RBM for continuous input data
1080pub fn create_gaussian_bernoulli_rbm(
1081    num_visible: usize,
1082    num_hidden: usize,
1083    training_config: QbmTrainingConfig,
1084) -> QbmResult<QuantumRestrictedBoltzmannMachine> {
1085    let visible_config = LayerConfig::new("visible".to_string(), num_visible, UnitType::Gaussian);
1086    let hidden_config = LayerConfig::new("hidden".to_string(), num_hidden, UnitType::Binary);
1087
1088    QuantumRestrictedBoltzmannMachine::new(visible_config, hidden_config, training_config)
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094
1095    #[test]
1096    fn test_rbm_creation() {
1097        let training_config = QbmTrainingConfig {
1098            epochs: 10,
1099            ..Default::default()
1100        };
1101
1102        let rbm = create_binary_rbm(4, 3, training_config).expect("failed to create binary RBM");
1103        assert_eq!(rbm.visible_config.num_units, 4);
1104        assert_eq!(rbm.hidden_config.num_units, 3);
1105    }
1106
1107    #[test]
1108    fn test_sigmoid_function() {
1109        assert!((sigmoid(0.0) - 0.5).abs() < 1e-10);
1110        assert!(sigmoid(10.0) > 0.99);
1111        assert!(sigmoid(-10.0) < 0.01);
1112    }
1113
1114    #[test]
1115    fn test_softmax_normalization() {
1116        let mut values = vec![1.0, 2.0, 3.0];
1117        softmax_normalize(&mut values);
1118
1119        let sum: f64 = values.iter().sum();
1120        assert!((sum - 1.0).abs() < 1e-10);
1121        assert!(values.iter().all(|&x| x > 0.0 && x < 1.0));
1122    }
1123
1124    #[test]
1125    fn test_training_sample_creation() {
1126        let sample = TrainingSample::new(vec![1.0, 0.0, 1.0]);
1127        assert_eq!(sample.data.len(), 3);
1128        assert!(sample.label.is_none());
1129
1130        let labeled_sample = TrainingSample::labeled(vec![1.0, 0.0], vec![1.0]);
1131        assert_eq!(labeled_sample.data.len(), 2);
1132        assert_eq!(
1133            labeled_sample
1134                .label
1135                .as_ref()
1136                .expect("label should exist")
1137                .len(),
1138            1
1139        );
1140    }
1141
1142    #[test]
1143    fn test_layer_config() {
1144        let config = LayerConfig::new("test".to_string(), 10, UnitType::Binary)
1145            .with_bias_range(-0.5, 0.5)
1146            .with_quantum_sampling(false);
1147
1148        assert_eq!(config.num_units, 10);
1149        assert_eq!(config.unit_type, UnitType::Binary);
1150        assert_eq!(config.bias_init_range, (-0.5, 0.5));
1151        assert!(!config.quantum_sampling);
1152    }
1153}