Skip to main content

comnum_mimo/
message_passing.rs

1//! Message-passing detection of large MIMO systems: LAMA (complex Bayesian AMP) and
2//! orthogonal AMP (OAMP), with their state evolution.
3//!
4//! Both algorithms solve the linear model $y = H x + w$ ($H$ of $M$ rows and $N$
5//! columns, $w$ of variance $\sigma^{2}$ per entry, i.i.d. entries $x_{j}$ of a known
6//! prior) by alternating a linear step on the whole vector and a scalar MMSE denoiser
7//! on every entry. In the large-system limit the input of the denoiser behaves as the
8//! transmitted symbol in Gaussian noise of a variance that a scalar recursion, the
9//! state evolution, predicts: the MIMO system decouples into parallel AWGN channels.
10//!
11//! # Denoiser
12//!
13//! With $r = x + n$, $n$ of variance $\tau$ (circularly-symmetric complex, or real),
14//! the posterior mean and variance of $x$,
15//!
16//! $$
17//! F(r, \tau) = \mathbb{E}\[X \mid r\], \qquad G(r, \tau) = \mathrm{Var}\[X \mid r\],
18//! $$
19//!
20//! for a discrete alphabet $F = \sum_{a} w_{a} a$ with
21//! $w_{a} \propto p_{a} e^{-|r - a|^{2}/\tau}$ (Jeon et al., eqs. 20 and 21), or for the
22//! Bernoulli-Gaussian prior $(1 - \rho)\\, \delta(x) + \rho\\, \mathcal{N}(0, 1/\rho)$ of sparse
23//! recovery (Ma & Ping, eq. 43). The MSE and variance functions of the state evolution
24//! are
25//!
26//! $$
27//! \Psi(\sigma^{2}, \gamma^{2}) = \mathbb{E}\left|F(S + \sigma Z, \gamma^{2}) - S\right|^{2}, \qquad
28//! \Phi(\sigma^{2}, \gamma^{2}) = \mathbb{E}\\, G(S + \sigma Z, \gamma^{2}),
29//! $$
30//!
31//! computed by Gauss–Hermite quadrature ([`Prior::mse`]); a separable square QAM reduces
32//! to its real PAM, $\Psi(\sigma^{2}, \gamma^{2}) = 2 \Psi_{R}(\sigma^{2}/2, \gamma^{2}/2)$
33//! (Jeon et al., Lemma 8).
34//!
35//! # LAMA
36//!
37//! With the columns of $H$ normalised to a unit norm and $\beta = N/M$, from
38//! $\hat{s} = \mathbb{E}\[S\]$, $\gamma = \sigma^{2} + \beta \mathrm{Var}\[S\]$ and $r = y - H \hat{s}$
39//! (Jeon et al., Algorithm 2),
40//!
41//! $$
42//! z = \hat{s} + H^{H} r, \quad \hat{s}' = F(z, \gamma), \quad
43//! \gamma' = \sigma^{2} + \beta \langle G(z, \gamma) \rangle, \quad
44//! r' = y - H \hat{s}' + \frac{\beta \langle G(z, \gamma) \rangle}{\gamma}\\, r ,
45//! $$
46//!
47//! the last term being the Onsager correction. Its state evolution (Theorem 1,
48//! Corollary 2) is $\sigma^{2}_{t+1} = \sigma^{2} + \beta \Psi(\sigma^{2}_{t}, \sigma^{2}_{t})$
49//! ([`amp_state_evolution`]), whose fixed points give the minimum and exact recovery
50//! thresholds of Table II ([`amp_thresholds`]). [`Amp`] normalises the columns of any
51//! channel itself.
52//!
53//! # OAMP
54//!
55//! With the SVD $H = U \Lambda V^{H}$, a de-correlated linear estimator and a
56//! divergence-free denoiser (Ma & Ping, eqs. 19, 14 and 38):
57//!
58//! $$
59//! r = s + W (y - H s), \quad W = \frac{N}{\mathrm{tr}(\hat{W} H)} \hat{W}, \quad
60//! s' = C \left(F(r, \tau^{2}) - \frac{\overline{G}}{\tau^{2}} r\right), \quad
61//! C = \frac{\tau^{2}}{\tau^{2} - \overline{G}},
62//! $$
63//!
64//! with $\hat{W}$ the matched filter $H^{H}$, the pseudo-inverse or the LMMSE filter
65//! $v^{2} H^{H} (v^{2} H H^{H} + \sigma^{2} I)^{-1}$, $v^{2}$ estimated from the residual
66//! (eq. 30) and $\tau^{2}$ from the traces (eq. 31). The last iteration outputs
67//! $F(r, \tau^{2})$. [`oamp_state_evolution`] is the empirical state evolution of eq. 32
68//! with the optimal denoiser (eq. 39b).
69//!
70//! # Examples
71//! ```
72//! use comnum_mimo::message_passing::{amp_thresholds, Domain, Prior};
73//! use comnum_mapping::constellation::Constellation;
74//! // QPSK: LAMA is optimal at any SNR below β = 1.475 and recovers the noiseless
75//! // signal below β = 2.086 (Jeon et al., Table II)
76//! let qpsk = Prior::uniform(&Constellation::qam(4)?.normalized());
77//! let t = amp_thresholds(&qpsk, Domain::Complex);
78//! assert!((t.beta_min - 1.475).abs() < 2e-3 && (t.beta_max - 2.086).abs() < 2e-3);
79//! # Ok::<(), comnum_core::Error>(())
80//! ```
81//!
82//! # References
83//!
84//! 1. C. Jeon, R. Ghods, A. Maleki and C. Studer, "Optimal data detection in large
85//!    MIMO," arXiv:1811.01917, 2018.
86//! 2. J. Ma and L. Ping, "Orthogonal AMP," *IEEE Access*, 5, 2020–2033, 2017
87//!    (arXiv:1602.06509).
88
89use crate::detection::{Estimates, FLOOR, MimoDetector, check_shape};
90use comnum_core::{Error, Result, ensure, ensure_shape};
91use comnum_mapping::constellation::Constellation;
92use comnum_math::Jacobian;
93use comnum_math::linalg::svd;
94use comnum_math::{C64, golden_max};
95use ndarray::ArrayView2;
96
97/// Nodes of the Gauss–Hermite rule per dimension of the expectations over the noise.
98const NODES: usize = 96;
99
100/// The number domain of the model: complex (circularly-symmetric noise) or real.
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum Domain {
103    /// Complex symbols, channel and circularly-symmetric noise: MIMO.
104    Complex,
105    /// Real symbols, channel and noise (the real parts of the complex values are used):
106    /// the model of Ma & Ping and of real-valued compressed sensing.
107    Real,
108}
109
110/// The prior of every entry of $x$.
111#[non_exhaustive]
112#[derive(Clone, Debug, PartialEq)]
113pub enum Prior {
114    /// Points of an alphabet and their probabilities.
115    Discrete {
116        /// The points.
117        points: Vec<C64>,
118        /// Their probabilities (summing to 1).
119        probs: Vec<f64>,
120    },
121    /// $(1 - \rho)\\, \delta(x) + \rho\\, \mathcal{N}(0, 1/\rho)$: sparse, of unit variance
122    /// (Ma & Ping, eq. 43).
123    BernoulliGaussian {
124        /// The probability $\rho$ of a non-zero entry.
125        rho: f64,
126    },
127}
128
129impl Prior {
130    /// Equally likely points of a constellation.
131    pub fn uniform(c: &Constellation) -> Prior {
132        let m = c.order();
133        Prior::Discrete { points: c.points().to_vec(), probs: vec![1.0 / m as f64; m] }
134    }
135
136    /// The points of a constellation with the probabilities `px` (normalised;
137    /// uniform if `None`).
138    ///
139    /// # Errors
140    /// Those of [`Constellation::priors`].
141    pub fn with_probs(c: &Constellation, px: Option<&[f64]>) -> Result<Prior> {
142        Ok(Prior::Discrete { points: c.points().to_vec(), probs: c.priors(px)? })
143    }
144
145    /// The Bernoulli-Gaussian prior of sparsity `rho`.
146    ///
147    /// # Errors
148    /// [`comnum_core::Error::Config`] unless $0 < \rho \le 1$.
149    pub fn bernoulli_gaussian(rho: f64) -> Result<Prior> {
150        ensure(rho > 0.0 && rho <= 1.0, || format!("the sparsity must be in (0, 1], got {rho}"))?;
151        Ok(Prior::BernoulliGaussian { rho })
152    }
153
154    /// $\mathbb{E}\[X\]$.
155    pub fn mean(&self) -> C64 {
156        match self {
157            Prior::Discrete { points, probs } => points.iter().zip(probs).map(|(a, p)| a * *p).sum(),
158            Prior::BernoulliGaussian { .. } => C64::new(0.0, 0.0),
159        }
160    }
161
162    /// $\mathrm{Var}\[X\] = \mathbb{E}|X - \mathbb{E}X|^{2}$.
163    pub fn variance(&self) -> f64 {
164        match self {
165            Prior::Discrete { points, probs } => {
166                let m = self.mean();
167                points.iter().zip(probs).map(|(a, p)| p * (a - m).norm_sqr()).sum()
168            }
169            Prior::BernoulliGaussian { .. } => 1.0,
170        }
171    }
172
173    /// The posterior mean $F(r, \tau)$ and variance $G(r, \tau)$ of $x$ from
174    /// $r = x + n$, $n$ of variance $\tau$ in `domain` (see the module).
175    pub fn denoise(&self, r: C64, tau: f64, domain: Domain) -> (C64, f64) {
176        let r = match domain {
177            Domain::Complex => r,
178            Domain::Real => C64::new(r.re, 0.0),
179        };
180        // the exponent of the Gaussian likelihood: |r - a|² / τ (complex), (r - a)² / (2τ) (real)
181        let scale = match domain {
182            Domain::Complex => 1.0 / tau,
183            Domain::Real => 0.5 / tau,
184        };
185        match self {
186            Prior::Discrete { points, probs } => {
187                if tau <= 0.0 || !tau.is_finite() {
188                    let best = points
189                        .iter()
190                        .zip(probs)
191                        .filter(|(_, p)| **p > 0.0)
192                        .min_by(|a, b| (r - a.0).norm_sqr().total_cmp(&(r - b.0).norm_sqr()))
193                        .map_or(C64::new(0.0, 0.0), |(a, _)| *a);
194                    return (best, 0.0);
195                }
196                let mut best = f64::NEG_INFINITY;
197                for (a, p) in points.iter().zip(probs) {
198                    if *p > 0.0 {
199                        best = best.max(p.ln() - (r - a).norm_sqr() * scale);
200                    }
201                }
202                let (mut sum, mut mean, mut second) = (0.0, C64::new(0.0, 0.0), 0.0);
203                for (a, p) in points.iter().zip(probs) {
204                    if *p > 0.0 {
205                        let w = (p.ln() - (r - a).norm_sqr() * scale - best).exp();
206                        sum += w;
207                        mean += a * w;
208                        second += w * a.norm_sqr();
209                    }
210                }
211                let mean = mean / sum;
212                (mean, (second / sum - mean.norm_sqr()).max(0.0))
213            }
214            Prior::BernoulliGaussian { rho } => {
215                let rho = *rho;
216                let s = 1.0 / rho;
217                // log of the likelihood of r under N(0, v), up to the common constant
218                let log_lik = |v: f64| match domain {
219                    Domain::Complex => -r.norm_sqr() / v - v.ln(),
220                    Domain::Real => -0.5 * r.norm_sqr() / v - 0.5 * v.ln(),
221                };
222                let tau = tau.max(f64::MIN_POSITIVE);
223                let l1 = rho.ln() + log_lik(s + tau);
224                let l0 = (1.0 - rho).ln() + log_lik(tau);
225                // P(x ≠ 0 | r)
226                let pi = if rho >= 1.0 { 1.0 } else { 1.0 / (1.0 + (l0 - l1).exp()) };
227                let m = r * (s / (s + tau));
228                let v = s * tau / (s + tau);
229                let mean = m * pi;
230                (mean, (pi * (v + m.norm_sqr()) - mean.norm_sqr()).max(0.0))
231            }
232        }
233    }
234
235    /// The real PAM of a separable square QAM (Jeon et al., Definition 10): the real
236    /// parts with their marginal probabilities, if every point is the product of a real
237    /// and an imaginary level of the same set with independent probabilities.
238    fn separable(&self) -> Option<Prior> {
239        let Prior::Discrete { points, probs } = self else { return None };
240        let mut levels: Vec<f64> = Vec::new();
241        for a in points {
242            if !levels.iter().any(|l| (l - a.re).abs() <= 1e-9 * (1.0 + a.re.abs())) {
243                levels.push(a.re);
244            }
245        }
246        levels.sort_by(f64::total_cmp);
247        let k = levels.len();
248        if k < 2 || k * k != points.len() {
249            return None;
250        }
251        let index = |x: f64| levels.iter().position(|l| (l - x).abs() <= 1e-9 * (1.0 + x.abs()));
252        let mut joint = vec![f64::NAN; k * k];
253        for (a, p) in points.iter().zip(probs) {
254            let (i, j) = (index(a.re)?, index(a.im)?);
255            joint[i * k + j] = *p;
256        }
257        if joint.iter().any(|p| p.is_nan()) {
258            return None;
259        }
260        let marginal: Vec<f64> = (0..k).map(|i| (0..k).map(|j| joint[i * k + j]).sum()).collect();
261        let independent = (0..k).all(|i| (0..k).all(|j| (joint[i * k + j] - marginal[i] * marginal[j]).abs() <= 1e-12));
262        let symmetric = (0..k).all(|j| ((0..k).map(|i| joint[i * k + j]).sum::<f64>() - marginal[j]).abs() <= 1e-12);
263        (independent && symmetric)
264            .then(|| Prior::Discrete { points: levels.iter().map(|l| C64::new(*l, 0.0)).collect(), probs: marginal })
265    }
266
267    /// The MSE function $\Psi(\sigma^{2}, \gamma^{2})$ and the variance function
268    /// $\Phi(\sigma^{2}, \gamma^{2})$ of the denoiser applied with the variance
269    /// $\gamma^{2}$ to $S + \sigma Z$ (see the module), by Gauss–Hermite quadrature.
270    pub fn mse(&self, noise_var: f64, gamma2: f64, domain: Domain) -> (f64, f64) {
271        if domain == Domain::Complex {
272            if let Some(pam) = self.separable() {
273                let (psi, phi) = pam.mse(0.5 * noise_var, 0.5 * gamma2, Domain::Real);
274                return (2.0 * psi, 2.0 * phi);
275            }
276        }
277        let sigma = noise_var.max(0.0).sqrt();
278        let nodes = Quadrature::new(domain);
279        match self {
280            Prior::Discrete { points, probs } => {
281                let (mut psi, mut phi) = (0.0, 0.0);
282                for (a, p) in points.iter().zip(probs).filter(|(_, p)| **p > 0.0) {
283                    let a = match domain {
284                        Domain::Complex => *a,
285                        Domain::Real => C64::new(a.re, 0.0),
286                    };
287                    nodes.for_each(|z, w| {
288                        let (f, g) = self.denoise(a + z * sigma, gamma2, domain);
289                        psi += p * w * (f - a).norm_sqr();
290                        phi += p * w * g;
291                    });
292                }
293                (psi, phi)
294            }
295            Prior::BernoulliGaussian { rho } => {
296                // R = S + σZ is a mixture of zero-mean Gaussians of variances σ² and
297                // 1/ρ + σ²; given R, E|F(R, γ²) - S|² = |F(R, γ²) - F(R, σ²)|² + G(R, σ²)
298                let (mut psi, mut phi) = (0.0, 0.0);
299                for (weight, var) in [(1.0 - rho, noise_var), (*rho, 1.0 / rho + noise_var)] {
300                    if weight <= 0.0 {
301                        continue;
302                    }
303                    let sd = var.sqrt();
304                    nodes.for_each(|z, w| {
305                        let r = z * sd;
306                        let (f, g) = self.denoise(r, gamma2, domain);
307                        let (f0, g0) = self.denoise(r, noise_var, domain);
308                        psi += weight * w * ((f - f0).norm_sqr() + g0);
309                        phi += weight * w * g;
310                    });
311                }
312                (psi, phi)
313            }
314        }
315    }
316}
317
318/// The nodes $z$ and weights of $\mathbb{E} f(Z)$, $Z \sim \mathcal{CN}(0, 1)$ or
319/// $\mathcal{N}(0, 1)$.
320struct Quadrature {
321    nodes: Vec<(C64, f64)>,
322}
323
324impl Quadrature {
325    fn new(domain: Domain) -> Quadrature {
326        Quadrature { nodes: comnum_math::gaussian_nodes(1.0, domain == Domain::Complex, NODES) }
327    }
328
329    fn for_each(&self, mut f: impl FnMut(C64, f64)) {
330        for &(z, w) in &self.nodes {
331            f(z, w);
332        }
333    }
334}
335
336/// One iteration of a state evolution.
337#[derive(Clone, Copy, Debug, PartialEq)]
338pub struct StateEvolutionStep {
339    /// The variance of the noise of the decoupled channel at the input of the denoiser
340    /// ($\sigma^{2}_{t}$ of LAMA, $\tau^{2}_{t}$ of OAMP).
341    pub noise_var: f64,
342    /// The predicted MSE of the MMSE estimate $F$ of this iteration.
343    pub mse: f64,
344}
345
346/// The state evolution of LAMA without noise mismatch (Jeon et al., Corollary 2):
347/// $\sigma^{2}_{1} = \sigma^{2} + \beta \mathrm{Var}\[S\]$,
348/// $\sigma^{2}_{t+1} = \sigma^{2} + \beta \Psi(\sigma^{2}_{t}, \sigma^{2}_{t})$, for the
349/// iterations, prior and domain of `config`, with $\beta = N/M$ and the columns of $H$ of
350/// unit norm.
351pub fn amp_state_evolution(config: &MessagePassing, beta: f64, noise_var: f64) -> Vec<StateEvolutionStep> {
352    let MessagePassing { prior, domain, iterations } = config;
353    let (domain, iterations) = (*domain, *iterations);
354    let mut s2 = noise_var + beta * prior.variance();
355    let mut out = Vec::with_capacity(iterations);
356    for _ in 0..iterations {
357        let mse = prior.mse(s2, s2, domain).0;
358        out.push(StateEvolutionStep { noise_var: s2, mse });
359        s2 = noise_var + beta * mse;
360    }
361    out
362}
363
364/// The thresholds of LAMA for a prior (Jeon et al., Definitions 6 to 9, Table II).
365#[derive(Clone, Copy, Debug, PartialEq)]
366pub struct AmpThresholds {
367    /// The minimum recovery threshold: below it the fixed point is unique (and LAMA
368    /// optimal) at every noise variance.
369    pub beta_min: f64,
370    /// The exact recovery threshold: below it the noiseless signal is recovered.
371    pub beta_max: f64,
372    /// The minimum critical noise variance $N_{0}^{\min}(\beta_{\min})$.
373    pub n0_min: f64,
374    /// The maximum guaranteed noise variance $N_{0}^{\max}(\beta_{\max})$.
375    pub n0_max: f64,
376}
377
378/// The minimum and exact recovery thresholds of LAMA and the critical noise variances
379/// (Jeon et al., eqs. 33 and 34, Definitions 8 and 9):
380/// $\beta_{\max} = \min_{\sigma^{2}} \sigma^{2} / \Psi(\sigma^{2}, \sigma^{2})$,
381/// $\beta_{\min} = \min_{\sigma^{2}} 1/\Psi'(\sigma^{2})$; the noise variances are
382/// $\sigma^{2} - \beta \Psi(\sigma^{2})$ where $\beta \Psi'(\sigma^{2}) = 1$.
383pub fn amp_thresholds(prior: &Prior, domain: Domain) -> AmpThresholds {
384    let var = prior.variance();
385    let psi = |s2: f64| prior.mse(s2, s2, domain).0;
386    // the derivative in σ², by central differences in log σ²
387    let dpsi = |s2: f64| {
388        let h = 1e-4;
389        (psi(s2 * (1.0 + h)) - psi(s2 * (1.0 - h))) / (2.0 * h * s2)
390    };
391    // a coarse logarithmic scan, then a golden-section refinement in log σ²
392    let grid: Vec<f64> = (0..=240).map(|i| var * 10f64.powf(-6.0 + 8.0 * f64::from(i) / 240.0)).collect();
393    let refine = |f: &dyn Fn(f64) -> f64| -> f64 {
394        let (i, _) = grid
395            .iter()
396            .enumerate()
397            .map(|(i, s)| (i, f(*s)))
398            .fold((0, f64::NEG_INFINITY), |b, (i, v)| if v > b.1 { (i, v) } else { b });
399        let lo = grid[i.saturating_sub(1)].ln();
400        let hi = grid[(i + 1).min(grid.len() - 1)].ln();
401        golden_max(|x| f(x.exp()), lo, hi, 80).exp()
402    };
403    let s_max = refine(&|s2| psi(s2) / s2);
404    let beta_max = s_max / psi(s_max);
405    let s_min = refine(&dpsi);
406    let beta_min = 1.0 / dpsi(s_min);
407    let n0_min = s_min - beta_min * psi(s_min);
408    // N0max(βmax): the largest σ² - βΨ(σ²) over the roots of βΨ'(σ²) = 1
409    let h = |s2: f64| beta_max * dpsi(s2) - 1.0;
410    let mut n0_max = 0.0f64;
411    for w in grid.windows(2) {
412        let (a, b) = (h(w[0]), h(w[1]));
413        if a.signum() != b.signum() {
414            let x = comnum_math::bisect(|x| h(x.exp()).signum() == a.signum(), w[0].ln(), w[1].ln(), 80).exp();
415            n0_max = n0_max.max(x - beta_max * psi(x));
416        }
417    }
418    AmpThresholds { beta_min, beta_max, n0_min, n0_max }
419}
420
421/// How LAMA estimates the variance of the decoupled channel.
422#[derive(Clone, Copy, Debug, PartialEq, Eq)]
423pub enum AmpVariance {
424    /// The postulated variance $\sigma^{2} + \beta \langle G \rangle$ (Jeon et al., eq. 23).
425    Postulated,
426    /// The residual $\lVert r \rVert^{2} / M$ (Jeon et al., eq. 38), better in small
427    /// systems.
428    Residual,
429}
430
431impl std::str::FromStr for AmpVariance {
432    type Err = Error;
433
434    /// The variance of a name: `postulated` or `residual`, case-insensitive.
435    ///
436    /// # Errors
437    /// [`Error::Config`] for another name.
438    ///
439    /// # Examples
440    /// ```
441    /// use comnum_mimo::message_passing::AmpVariance;
442    /// assert_eq!("residual".parse::<AmpVariance>()?, AmpVariance::Residual);
443    /// # Ok::<(), comnum_core::Error>(())
444    /// ```
445    fn from_str(name: &str) -> Result<AmpVariance> {
446        match name.to_ascii_lowercase().as_str() {
447            "postulated" => Ok(AmpVariance::Postulated),
448            "residual" => Ok(AmpVariance::Residual),
449            other => Err(Error::Config(format!("unknown variance {other:?}: postulated or residual"))),
450        }
451    }
452}
453
454/// The iterations of a message-passing detector.
455#[derive(Clone, Debug, PartialEq)]
456pub struct Trace {
457    /// The input of the denoiser at the last iteration: $x$ in Gaussian noise of
458    /// variance [`Trace::noise_var`] in the large-system limit.
459    pub z: Vec<C64>,
460    /// The variance of that noise used by the denoiser.
461    pub noise_var: f64,
462    /// The MMSE estimate $F$ of every iteration.
463    pub estimates: Vec<Vec<C64>>,
464    /// The variance used by the denoiser at every iteration.
465    pub noise_vars: Vec<f64>,
466}
467
468impl Trace {
469    /// The final MMSE estimate.
470    pub fn estimate(&self) -> &[C64] {
471        self.estimates.last().map_or(&[], Vec::as_slice)
472    }
473}
474
475/// The configuration shared by [`lama`], [`oamp`] and their state evolutions: the prior
476/// of every entry of $x$, the domain of the model and the number of iterations.
477///
478/// # Examples
479/// ```
480/// use comnum_mimo::message_passing::{Domain, MessagePassing, Prior, amp_state_evolution};
481/// use comnum_mapping::constellation::Constellation;
482/// let qpsk = Prior::uniform(&Constellation::qam(4)?.normalized());
483/// let config = MessagePassing { prior: qpsk, iterations: 30, ..MessagePassing::default() };
484/// config.check()?;
485/// let se = amp_state_evolution(&config, 0.5, 0.05);
486/// assert_eq!(se.len(), 30);
487/// # Ok::<(), comnum_core::Error>(())
488/// ```
489#[derive(Clone, Debug, PartialEq)]
490pub struct MessagePassing {
491    /// The prior of every entry of $x$.
492    pub prior: Prior,
493    /// The domain of the symbols, the channel and the noise.
494    pub domain: Domain,
495    /// Iterations (at least 1 for the detectors).
496    pub iterations: usize,
497}
498
499impl Default for MessagePassing {
500    /// A Gaussian prior of unit variance ([`Prior::BernoulliGaussian`] with $\rho = 1$),
501    /// complex, 10 iterations.
502    fn default() -> MessagePassing {
503        MessagePassing { prior: Prior::BernoulliGaussian { rho: 1.0 }, domain: Domain::Complex, iterations: 10 }
504    }
505}
506
507impl MessagePassing {
508    /// Check the configuration.
509    ///
510    /// # Errors
511    /// [`Error::Config`] for zero iterations or a sparsity outside $(0, 1]$;
512    /// [`Error::Shape`] for a discrete prior without points or with one probability per
513    /// point missing.
514    pub fn check(&self) -> Result<()> {
515        ensure(self.iterations >= 1, || "at least one iteration is needed".into())?;
516        match &self.prior {
517            Prior::Discrete { points, probs } => {
518                ensure_shape(!points.is_empty(), || "a discrete prior needs at least one point".into())?;
519                comnum_core::same_len(points.len(), probs.len(), "points and probabilities of the prior")
520            }
521            Prior::BernoulliGaussian { rho } => Prior::bernoulli_gaussian(*rho).map(drop),
522        }
523    }
524}
525
526fn check(h: &ArrayView2<'_, C64>, y: &[C64], noise_var: f64, iterations: usize) -> Result<()> {
527    ensure_shape(h.nrows() == y.len(), || {
528        format!("the channel has {} rows for {} received samples", h.nrows(), y.len())
529    })?;
530    ensure_shape(h.ncols() >= 1 && h.nrows() >= 1, || "the channel must not be empty".into())?;
531    ensure(noise_var >= 0.0 && noise_var.is_finite(), || {
532        format!("the noise variance must be finite and non-negative, got {noise_var}")
533    })?;
534    ensure(iterations >= 1, || "at least one iteration is needed".into())
535}
536
537fn residual(h: &ArrayView2<'_, C64>, y: &[C64], s: &[C64]) -> Vec<C64> {
538    y.iter().enumerate().map(|(i, yi)| yi - h.row(i).iter().zip(s).map(|(a, b)| a * b).sum::<C64>()).collect()
539}
540
541fn adjoint(h: &ArrayView2<'_, C64>, r: &[C64]) -> Vec<C64> {
542    (0..h.ncols()).map(|j| h.column(j).iter().zip(r).map(|(a, b)| a.conj() * b).sum()).collect()
543}
544
545/// LAMA (Jeon et al., Algorithm 2) on $y = H x + w$, $w$ of variance `noise_var` per
546/// entry, with the prior, domain and iterations of `config` and the variance estimate
547/// `variance`: the columns of $H$ are first scaled to a unit mean norm (with $y$ and the
548/// noise variance), which leaves the problem unchanged.
549///
550/// # Errors
551/// [`comnum_core::Error::Shape`] for an empty channel or one whose rows do not match $y$;
552/// [`comnum_core::Error::Config`] for a negative noise variance, zero iterations or a zero
553/// channel.
554pub fn lama(
555    h: &ArrayView2<'_, C64>,
556    y: &[C64],
557    noise_var: f64,
558    config: &MessagePassing,
559    variance: AmpVariance,
560) -> Result<Trace> {
561    let MessagePassing { prior, domain, iterations } = config;
562    let (domain, iterations) = (*domain, *iterations);
563    check(h, y, noise_var, iterations)?;
564    let (m, n) = h.dim();
565    let c = h.iter().map(C64::norm_sqr).sum::<f64>() / n as f64;
566    ensure(c > 0.0, || "the channel is zero".into())?;
567    let k = 1.0 / c.sqrt();
568    let h = h.mapv(|v| v * k);
569    let y: Vec<C64> = y.iter().map(|v| v * k).collect();
570    let n0 = noise_var / c;
571    let beta = n as f64 / m as f64;
572    let mut s = vec![prior.mean(); n];
573    let mut gamma = n0 + beta * prior.variance();
574    let mut r = residual(&h.view(), &y, &s);
575    let mut trace =
576        Trace { z: Vec::new(), noise_var: gamma, estimates: Vec::with_capacity(iterations), noise_vars: Vec::new() };
577    for _ in 0..iterations {
578        if variance == AmpVariance::Residual {
579            gamma = (r.iter().map(C64::norm_sqr).sum::<f64>() / m as f64).max(FLOOR);
580        }
581        let back = adjoint(&h.view(), &r);
582        let z: Vec<C64> = s.iter().zip(&back).map(|(a, b)| a + b).collect();
583        let (next, g): (Vec<C64>, Vec<f64>) = z.iter().map(|zi| prior.denoise(*zi, gamma, domain)).unzip();
584        let gbar = g.iter().sum::<f64>() / n as f64;
585        let onsager = beta * gbar / gamma;
586        let fit = residual(&h.view(), &y, &next);
587        r = fit.iter().zip(&r).map(|(f, old)| f + old * onsager).collect();
588        trace.z = z;
589        trace.noise_var = gamma;
590        trace.noise_vars.push(gamma);
591        trace.estimates.push(next.clone());
592        s = next;
593        gamma = n0 + beta * gbar;
594    }
595    // the noise variance of the decoupled channel, back on the scale of the caller
596    trace.noise_var *= c;
597    for v in &mut trace.noise_vars {
598        *v *= c;
599    }
600    Ok(trace)
601}
602
603/// The linear estimator $\hat{W}$ of OAMP (Ma & Ping, eq. 15).
604#[derive(Clone, Copy, Debug, PartialEq, Eq)]
605pub enum OampLinear {
606    /// The matched filter $H^{H}$.
607    MatchedFilter,
608    /// The pseudo-inverse.
609    PseudoInverse,
610    /// The LMMSE filter $v^{2} H^{H} (v^{2} H H^{H} + \sigma^{2} I)^{-1}$ (optimal, Lemma 1).
611    Lmmse,
612}
613
614impl std::str::FromStr for OampLinear {
615    type Err = Error;
616
617    /// The estimator of a name: `lmmse`, `pinv` (the pseudo-inverse) or `mf` (the
618    /// matched filter), case-insensitive.
619    ///
620    /// # Errors
621    /// [`Error::Config`] for another name.
622    ///
623    /// # Examples
624    /// ```
625    /// use comnum_mimo::message_passing::OampLinear;
626    /// assert_eq!("pinv".parse::<OampLinear>()?, OampLinear::PseudoInverse);
627    /// # Ok::<(), comnum_core::Error>(())
628    /// ```
629    fn from_str(name: &str) -> Result<OampLinear> {
630        match name.to_ascii_lowercase().as_str() {
631            "lmmse" => Ok(OampLinear::Lmmse),
632            "pinv" => Ok(OampLinear::PseudoInverse),
633            "mf" => Ok(OampLinear::MatchedFilter),
634            other => Err(Error::Config(format!("unknown linear estimator {other:?}: lmmse, pinv or mf"))),
635        }
636    }
637}
638
639impl OampLinear {
640    /// The singular values $\hat{g}_{i}$ of $\hat{W}$ for those $\lambda_{i}$ of $H$.
641    fn gains(self, lambda: &[f64], v2: f64, noise_var: f64) -> Vec<f64> {
642        let top = lambda.iter().copied().fold(0.0, f64::max);
643        lambda
644            .iter()
645            .map(|&l| match self {
646                OampLinear::MatchedFilter => l,
647                OampLinear::PseudoInverse => {
648                    if l > 1e-12 * top {
649                        1.0 / l
650                    } else {
651                        0.0
652                    }
653                }
654                OampLinear::Lmmse => v2 * l / (v2 * l * l + noise_var),
655            })
656            .collect()
657    }
658}
659
660/// The de-correlated estimator $W = N \hat{W} / \mathrm{tr}(\hat{W} H)$ by its singular
661/// values $g_{i}$, and the variance $\tau^{2}$ of eq. 31 for an input error $v^{2}$.
662fn decorrelated(lambda: &[f64], ghat: &[f64], n: usize, v2: f64, noise_var: f64) -> (Vec<f64>, f64) {
663    let trace: f64 = ghat.iter().zip(lambda).map(|(g, l)| g * l).sum();
664    let c = if trace > 0.0 { n as f64 / trace } else { 0.0 };
665    let g: Vec<f64> = ghat.iter().map(|x| x * c).collect();
666    let bb = g.iter().zip(lambda).map(|(g, l)| (1.0 - g * l).powi(2)).sum::<f64>() + (n - lambda.len()) as f64;
667    let ww = g.iter().map(|g| g * g).sum::<f64>();
668    (g, (bb * v2 + ww * noise_var) / n as f64)
669}
670
671/// OAMP (Ma & Ping, eqs. 19, 25 and 38, Lemma 1) on $y = H x + w$, $w$ of variance
672/// `noise_var` per entry, with the prior, domain and iterations of `config`, the linear
673/// estimator `linear` and the optimal divergence-free denoiser.
674///
675/// # Errors
676/// As [`lama`].
677pub fn oamp(
678    h: &ArrayView2<'_, C64>,
679    y: &[C64],
680    noise_var: f64,
681    config: &MessagePassing,
682    linear: OampLinear,
683) -> Result<Trace> {
684    let MessagePassing { prior, domain, iterations } = config;
685    let (domain, iterations) = (*domain, *iterations);
686    check(h, y, noise_var, iterations)?;
687    let (m, n) = h.dim();
688    // H = U Λ V^H (thin: k = min(m, n) singular values)
689    let (u, lambda, v) = svd(h);
690    let frob: f64 = lambda.iter().map(|l| l * l).sum();
691    ensure(frob > 0.0, || "the channel is zero".into())?;
692    let floor = 1e-12 * prior.variance().max(FLOOR);
693    let mut s = vec![prior.mean(); n];
694    let mut trace =
695        Trace { z: Vec::new(), noise_var: 0.0, estimates: Vec::with_capacity(iterations), noise_vars: Vec::new() };
696    for t in 0..iterations {
697        let res = residual(h, y, &s);
698        let energy: f64 = res.iter().map(C64::norm_sqr).sum();
699        let v2 = ((energy - m as f64 * noise_var) / frob).max(floor);
700        let (g, tau2) = decorrelated(&lambda, &linear.gains(&lambda, v2, noise_var), n, v2, noise_var);
701        let tau2 = tau2.max(FLOOR);
702        // r = s + V diag(g) U^H res
703        let proj: Vec<C64> = (0..lambda.len())
704            .map(|i| u.column(i).iter().zip(&res).map(|(a, b)| a.conj() * b).sum::<C64>() * g[i])
705            .collect();
706        let r: Vec<C64> = (0..n).map(|j| s[j] + (0..proj.len()).map(|i| v[[j, i]] * proj[i]).sum::<C64>()).collect();
707        let (f, gv): (Vec<C64>, Vec<f64>) = r.iter().map(|ri| prior.denoise(*ri, tau2, domain)).unzip();
708        let mmse = gv.iter().sum::<f64>() / n as f64;
709        trace.noise_vars.push(tau2);
710        trace.estimates.push(f.clone());
711        trace.z.clone_from(&r);
712        trace.noise_var = tau2;
713        if t + 1 < iterations {
714            let c = tau2 / (tau2 - mmse).max(1e-12 * tau2);
715            s = f.iter().zip(&r).map(|(fi, ri)| (fi - ri * (mmse / tau2)) * c).collect();
716        }
717    }
718    Ok(trace)
719}
720
721/// The empirical state evolution of OAMP with the optimal denoiser (Ma & Ping, eqs. 32
722/// and 39): for the `singular_values` of $H$ ($N$ columns), from $v^{2}_{0} = \mathrm{Var}\[S\]$,
723/// $\tau^{2}_{t} = \Phi_{t}(v^{2}_{t})$ (eq. 32), the MSE of $F$ is
724/// $\mathrm{mmse}_{B}(\tau^{2}_{t})$ and $v^{2}_{t+1} = (1/\mathrm{mmse}_{B} - 1/\tau^{2}_{t})^{-1}$,
725/// for the prior, domain and iterations of `config` and the linear estimator `linear`.
726pub fn oamp_state_evolution(
727    config: &MessagePassing,
728    singular_values: &[f64],
729    n: usize,
730    noise_var: f64,
731    linear: OampLinear,
732) -> Vec<StateEvolutionStep> {
733    let MessagePassing { prior, domain, iterations } = config;
734    let (domain, iterations) = (*domain, *iterations);
735    let lambda: Vec<f64> = singular_values.iter().copied().take(n).collect();
736    let mut v2 = prior.variance();
737    let mut out = Vec::with_capacity(iterations);
738    for _ in 0..iterations {
739        let (_, tau2) = decorrelated(&lambda, &linear.gains(&lambda, v2, noise_var), n, v2, noise_var);
740        let mmse = prior.mse(tau2, tau2, domain).0;
741        out.push(StateEvolutionStep { noise_var: tau2, mse: mmse });
742        v2 = if mmse < tau2 { 1.0 / (1.0 / mmse.max(FLOOR) - 1.0 / tau2) } else { tau2 };
743    }
744    out
745}
746
747/// The MIMO detector of LAMA: the hard decisions of the decoupled channel, its bit
748/// LLRs, and its estimates $z$ with the variance of the decoupled noise.
749#[derive(Clone, Debug, PartialEq)]
750pub struct Amp {
751    /// The alphabet of every stream.
752    constellation: Constellation,
753    /// Iterations (at least 1).
754    iterations: usize,
755    /// The variance estimate of the decoupled channel.
756    pub variance: AmpVariance,
757}
758
759impl Amp {
760    /// LAMA with `iterations` iterations and the postulated variance.
761    ///
762    /// # Errors
763    /// [`Error::Config`] for zero iterations.
764    pub fn new(constellation: Constellation, iterations: usize) -> Result<Amp> {
765        ensure(iterations >= 1, || "at least one iteration is needed".into())?;
766        Ok(Amp { constellation, iterations, variance: AmpVariance::Postulated })
767    }
768
769    /// Iterations.
770    pub fn iterations(&self) -> usize {
771        self.iterations
772    }
773
774    fn trace(&self, h: &ArrayView2<'_, C64>, y: &[C64], noise_var: f64) -> Result<Trace> {
775        check_shape(h, y)?;
776        let config = MessagePassing {
777            prior: Prior::uniform(&self.constellation),
778            domain: Domain::Complex,
779            iterations: self.iterations,
780        };
781        lama(h, y, noise_var, &config, self.variance)
782    }
783}
784
785/// The MIMO detector of OAMP with the LMMSE estimator (the outputs of [`Amp`]).
786#[derive(Clone, Debug, PartialEq)]
787pub struct Oamp {
788    /// The alphabet of every stream.
789    constellation: Constellation,
790    /// Iterations (at least 1).
791    iterations: usize,
792    /// The linear estimator.
793    pub linear: OampLinear,
794}
795
796impl Oamp {
797    /// OAMP-LMMSE with `iterations` iterations.
798    ///
799    /// # Errors
800    /// [`Error::Config`] for zero iterations.
801    pub fn new(constellation: Constellation, iterations: usize) -> Result<Oamp> {
802        ensure(iterations >= 1, || "at least one iteration is needed".into())?;
803        Ok(Oamp { constellation, iterations, linear: OampLinear::Lmmse })
804    }
805
806    /// Iterations.
807    pub fn iterations(&self) -> usize {
808        self.iterations
809    }
810
811    fn trace(&self, h: &ArrayView2<'_, C64>, y: &[C64], noise_var: f64) -> Result<Trace> {
812        check_shape(h, y)?;
813        let config = MessagePassing {
814            prior: Prior::uniform(&self.constellation),
815            domain: Domain::Complex,
816            iterations: self.iterations,
817        };
818        oamp(h, y, noise_var, &config, self.linear)
819    }
820}
821
822/// The noise variance of the decoupled channel $z = x + n$ of a trace, floored: the state
823/// evolution reaches 0 without noise.
824fn decoupled_var(trace: &Trace) -> f64 {
825    trace.noise_var.max(FLOOR)
826}
827
828/// The [`MimoDetector`] of a message-passing detector with a `trace` method: the
829/// decisions, the LLRs and the estimates of its decoupled channel.
830macro_rules! decoupled_detector {
831    ($t:ty) => {
832        impl MimoDetector for $t {
833            fn constellation(&self) -> &Constellation {
834                &self.constellation
835            }
836
837            fn has_soft_output(&self) -> bool {
838                true
839            }
840
841            fn soft_estimate(&self, h: &ArrayView2<'_, C64>, y: &[C64], noise_var: f64) -> Result<Option<Estimates>> {
842                let t = self.trace(h, y, noise_var)?;
843                let v = decoupled_var(&t);
844                Ok(Some(Estimates { noise_var: vec![v; t.z.len()], symbols: t.z }))
845            }
846
847            fn detect(&self, h: &ArrayView2<'_, C64>, y: &[C64], noise_var: f64) -> Result<Vec<usize>> {
848                Ok(self.constellation.decide(&self.trace(h, y, noise_var)?.z))
849            }
850
851            fn llr(&self, h: &ArrayView2<'_, C64>, y: &[C64], noise_var: f64) -> Result<Vec<f64>> {
852                let t = self.trace(h, y, noise_var)?;
853                self.constellation.llr(&t.z, decoupled_var(&t), None, Jacobian::Exact)
854            }
855
856            fn detect_llr(&self, h: &ArrayView2<'_, C64>, y: &[C64], noise_var: f64) -> Result<(Vec<usize>, Vec<f64>)> {
857                let t = self.trace(h, y, noise_var)?;
858                Ok((
859                    self.constellation.decide(&t.z),
860                    self.constellation.llr(&t.z, decoupled_var(&t), None, Jacobian::Exact)?,
861                ))
862            }
863        }
864    };
865}
866
867decoupled_detector!(Amp);
868decoupled_detector!(Oamp);
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    use comnum_core::rng::Rng;
874    use ndarray::Array2;
875
876    fn qpsk() -> Constellation {
877        Constellation::qam(4).unwrap().normalized()
878    }
879
880    #[test]
881    fn the_denoiser_is_the_posterior_of_the_alphabet() {
882        let p = Prior::uniform(&qpsk());
883        // far from the noise: the nearest point, no variance
884        let a = qpsk().points()[2];
885        let (f, g) = p.denoise(a * 1.01, 1e-4, Domain::Complex);
886        assert!((f - a).norm() < 1e-9 && g < 1e-9);
887        // no information: the mean and the energy of the alphabet
888        let (f, g) = p.denoise(C64::new(0.0, 0.0), 1e9, Domain::Complex);
889        assert!(f.norm() < 1e-9 && (g - 1.0).abs() < 1e-6);
890        // BPSK in real noise: tanh(r / τ)
891        let bpsk = Prior::Discrete { points: vec![C64::new(-1.0, 0.0), C64::new(1.0, 0.0)], probs: vec![0.5, 0.5] };
892        let (f, g) = bpsk.denoise(C64::new(0.3, 7.0), 0.5, Domain::Real);
893        assert!((f.re - (0.3f64 / 0.5).tanh()).abs() < 1e-12 && (g - (1.0 - f.re * f.re)).abs() < 1e-12);
894    }
895
896    #[test]
897    fn the_mse_of_separable_qam_is_that_of_its_pam() {
898        let p = Prior::uniform(&Constellation::qam(16).unwrap().normalized());
899        let Prior::Discrete { points, probs } = &p else { unreachable!() };
900        // the same prior written as a non-separable one: perturb nothing, compute in 2D
901        let direct = |s2: f64| {
902            let q = Quadrature::new(Domain::Complex);
903            let mut psi = 0.0;
904            for (a, pa) in points.iter().zip(probs) {
905                q.for_each(|z, w| psi += pa * w * (p.denoise(a + z * s2.sqrt(), s2, Domain::Complex).0 - a).norm_sqr());
906            }
907            psi
908        };
909        for s2 in [0.01, 0.1, 1.0] {
910            let fast = p.mse(s2, s2, Domain::Complex).0;
911            assert!((fast / direct(s2) - 1.0).abs() < 1e-6, "{s2}: {fast} {}", direct(s2));
912        }
913    }
914
915    #[test]
916    fn lama_and_oamp_recover_a_noiseless_large_system() {
917        let c = qpsk();
918        let mut rng = Rng::new(4);
919        let (m, n) = (96, 64);
920        let h = Array2::from_shape_fn((m, n), |_| rng.complex_gaussian());
921        let idx: Vec<usize> = (0..n).map(|_| rng.below(4)).collect();
922        let x: Vec<C64> = idx.iter().map(|&i| c.points()[i]).collect();
923        let y = comnum_math::linalg::matvec(&h.view(), &x);
924        assert_eq!(Amp::new(c.clone(), 20).unwrap().detect(&h.view(), &y, 1e-6).unwrap(), idx);
925        assert_eq!(Oamp::new(c.clone(), 10).unwrap().detect(&h.view(), &y, 1e-6).unwrap(), idx);
926        assert!(Amp::new(c.clone(), 0).is_err() && Oamp::new(c.clone(), 0).is_err());
927        // a length mismatch is a Shape error, as MimoDetector::detect documents
928        for d in [&Amp::new(c.clone(), 5).unwrap() as &dyn MimoDetector, &Oamp::new(c.clone(), 5).unwrap()] {
929            assert!(matches!(d.detect(&h.view(), &y[1..], 1e-6), Err(Error::Shape(_))));
930            // the soft outputs are those of the decoupled channel
931            let e = d.soft_estimate(&h.view(), &y, 1e-6).unwrap().unwrap();
932            let (labels, llr) = d.detect_llr(&h.view(), &y, 1e-6).unwrap();
933            assert_eq!(c.decide(&e.symbols), labels);
934            let from = c.llr_with_variances(&e.symbols, &e.noise_var, None, Jacobian::Exact).unwrap();
935            assert!(from.iter().zip(&llr).all(|(a, b)| a.to_bits() == b.to_bits()));
936        }
937    }
938
939    #[test]
940    fn the_state_evolution_of_lama_decreases_to_its_fixed_point() {
941        let config = MessagePassing { prior: Prior::uniform(&qpsk()), domain: Domain::Complex, iterations: 30 };
942        let se = amp_state_evolution(&config, 0.5, 0.05);
943        assert!(se.windows(2).all(|w| w[1].noise_var <= w[0].noise_var + 1e-15));
944        let last = se[se.len() - 1];
945        assert!((last.noise_var - (0.05 + 0.5 * last.mse)).abs() < 1e-9);
946    }
947}