Skip to main content

sicada_decode/
occupancy.rs

1//! The alignment chain again, in the log semiring: what every alignment says,
2//! not just the best one.
3//!
4//! [`align`](crate::align::align) walks the chain of
5//! [`AlignChain`] in the tropical semiring, so its ⊕
6//! is `min` and one path survives. Walking the same chain with the log
7//! semiring's ⊕, which is `-log(e^-a + e^-b)` and so adds probabilities,
8//! discards nothing: the forward pass ends holding the total probability of the
9//! reference over *all* its alignments, and pairing it with a backward pass
10//! gives the posterior of every transition, frame by frame.
11//!
12//! The graph is not restated here. Both passes read
13//! `transitions_into` and its dual, which is
14//! the single description of the chain's shape, so there is no way for the two
15//! semirings to end up walking different graphs.
16//!
17//! What it is for:
18//!
19//! - **label priors.** A CTC model trained with them needs an estimate of how
20//!   often each column is the right answer, and [`Occupancy::label_prior`] is
21//!   that estimate taken over the alignment rather than over a hard decision.
22//! - **diagnosis.** [`Occupancy::skip_posteriors`] gives, per phone, how much
23//!   of the probability mass gave it up. That is the instrument the tropical
24//!   answer cannot supply: a skip in [`Alignment::skipped`] is a decision, and
25//!   a decision does not report its confidence.
26//!
27//! # What it costs
28//!
29//! The backward pass needs the forward scores, so where
30//! [`align`](crate::align::align) keeps two rows and a packed traceback, this
31//! keeps the whole `(T + 1) × (N + 1)` score plane. Its memory use is therefore
32//! proportional to the product of the frame and reference lengths.
33//!
34//! [`Alignment::skipped`]: crate::align::Alignment::skipped
35
36use sicada::arc::Arc;
37use sicada::error::OpenFstError;
38
39use crate::align::{AlignChain, column_read};
40use crate::dense::{DenseFst, FromScore};
41use crate::trellis::posteriors;
42
43/// How the reference's probability is spread over the frames.
44#[derive(Debug, Clone, PartialEq)]
45pub struct Occupancy {
46    /// `T × C`, row-major: the posterior of each column in each frame.
47    posteriors: Vec<f32>,
48    /// Per position, the expected number of frames sounding it.
49    durations: Vec<f32>,
50    /// Per position, the posterior that the alignment gave it up.
51    skips: Vec<f32>,
52    num_frames: usize,
53    num_symbols: usize,
54    cost: f32,
55}
56
57impl Occupancy {
58    /// The number of frames.
59    #[inline(always)]
60    pub fn num_frames(&self) -> usize {
61        self.num_frames
62    }
63
64    /// The number of columns the acoustic model scores.
65    #[inline(always)]
66    pub fn num_symbols(&self) -> usize {
67        self.num_symbols
68    }
69
70    /// The posterior of each column in `frame`, which sums to one.
71    ///
72    /// # Panics
73    ///
74    /// If `frame` is past the last one.
75    #[inline(always)]
76    pub fn frame(&self, frame: usize) -> &[f32] {
77        &self.posteriors[frame * self.num_symbols..(frame + 1) * self.num_symbols]
78    }
79
80    /// `-log P(reference | frames)`: the cost of the whole chain, over every
81    /// alignment of it at once.
82    ///
83    /// Always at most [`Alignment::cost`](crate::align::Alignment::cost), which
84    /// is the cost of the single best alignment. The gap measures probability
85    /// mass carried by alternative alignments.
86    #[inline(always)]
87    pub fn cost(&self) -> f32 {
88        self.cost
89    }
90
91    /// How often each column is the right answer, averaged over the frames.
92    ///
93    /// Sums to one. This is the estimate a CTC model trained with label priors
94    /// wants: the count is taken over the whole alignment posterior rather than
95    /// over a hard decision, so a frame the model is unsure about contributes
96    /// to both readings in proportion.
97    pub fn label_prior(&self) -> Vec<f32> {
98        let mut prior = vec![0f64; self.num_symbols];
99        for frame in self.posteriors.chunks_exact(self.num_symbols.max(1)) {
100            for (total, &value) in prior.iter_mut().zip(frame) {
101                *total += value as f64;
102            }
103        }
104        let frames = self.num_frames.max(1) as f64;
105        prior
106            .into_iter()
107            .map(|total| (total / frames) as f32)
108            .collect()
109    }
110
111    /// The expected number of frames sounding each position.
112    ///
113    /// The soft counterpart of [`Alignment::spans`](crate::align::Alignment::spans),
114    /// and it answers a question spans cannot: a phone whose span is one frame
115    /// but whose expected duration is four is a boundary the acoustics did not
116    /// decide, not a short phone.
117    #[inline(always)]
118    pub fn expected_durations(&self) -> &[f32] {
119        &self.durations
120    }
121
122    /// Per position, how much of the probability took the skip transition into
123    /// it.
124    ///
125    /// Zero everywhere unless the chain allows skipping.
126    /// [`Alignment::skipped`](crate::align::Alignment::skipped) reports the
127    /// best-path decision; this value reports its posterior confidence.
128    ///
129    /// It is an upper bound on the posterior that a phone got no frames at all,
130    /// and not quite the same thing. Skipping into `s_i` and then *holding*
131    /// phone `i` is a path the chain allows, and it sounds a phone the skip gave
132    /// up. It never wins in the tropical semiring, because waiting at `s_{i-1}`
133    /// and committing a frame later reaches the same cell having sounded the
134    /// same phone and costs exactly `skip(i)` less, so an [`Alignment`] never
135    /// contains one. In the log semiring nothing is discarded, so those paths
136    /// keep their share, which at a skip cost of several nats is a small one.
137    ///
138    /// [`Alignment`]: crate::align::Alignment
139    #[inline(always)]
140    pub fn skip_posteriors(&self) -> &[f32] {
141        &self.skips
142    }
143}
144
145/// Forward-backward over `chain` against `dense`, in the log semiring.
146///
147/// Returns `None` in the same cases [`align`](crate::align::align) does: a
148/// reference too long for the frames, or one no path can complete.
149///
150/// # Errors
151///
152/// A phone naming a column the acoustic model does not have, or a matrix so
153/// large that the forward plane does not fit in memory; see
154/// [the module docs](self#what-it-costs) for how large that is.
155pub fn occupancy<A>(
156    chain: &AlignChain,
157    dense: &DenseFst<'_, A>,
158) -> Result<Option<Occupancy>, OpenFstError>
159where
160    A: Arc,
161    A::Weight: FromScore,
162{
163    let num_symbols = dense.num_symbols();
164    let num_frames = dense.num_frames();
165    let num_phones = chain.num_phones();
166    let trellis = chain.against(dense)?;
167
168    let cells = num_frames.checked_mul(num_symbols).ok_or_else(|| {
169        OpenFstError::InvalidOperation(format!(
170            "occupancy: posteriors for {num_frames} frames of {num_symbols} symbols do not fit"
171        ))
172    })?;
173    // Summed in double precision: a frame's mass arrives in up to `4N` pieces,
174    // and the per-position totals in up to `4T` of them.
175    let mut posterior = vec![0f64; cells];
176    let mut durations = vec![0f64; num_phones];
177    let mut skips = vec![0f64; num_phones];
178
179    // The whole of what this module adds to the solver: which column each
180    // transition read, and what it meant for the phone it landed on.
181    let total = posteriors(&trellis, |taken, mass| {
182        let column = column_read(chain, taken.code, taken.position);
183        posterior[taken.frame * num_symbols + column as usize] += mass;
184        if AlignChain::sounds(taken.code) {
185            durations[taken.position - 1] += mass;
186        } else if taken.code == AlignChain::SKIP {
187            skips[taken.position - 1] += mass;
188        }
189    })?;
190    let Some(cost) = total else {
191        return Ok(None);
192    };
193
194    Ok(Some(Occupancy {
195        posteriors: posterior.into_iter().map(|mass| mass as f32).collect(),
196        durations: durations.into_iter().map(|value| value as f32).collect(),
197        skips: skips.into_iter().map(|value| value as f32).collect(),
198        num_frames,
199        num_symbols,
200        cost,
201    }))
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use sicada::arc::StdArc;
208
209    use crate::align::{Alignment, align};
210
211    // Blank plus three phones.
212    const SYMBOLS: usize = 4;
213
214    // One frame of a path: the column it read, the position it sounded, and
215    // the position it gave up.
216    #[derive(Clone, Copy)]
217    struct Step {
218        column: usize,
219        sounded: Option<usize>,
220        skipped: Option<usize>,
221    }
222
223    // Every complete alignment, weighed by its probability.
224    //
225    // The definition of a forward-backward, written out: enumerate the paths,
226    // give each `e^-cost`, and normalise. Exponential, so only for the smallest
227    // cases, but it shares nothing with the recurrences under test, not even
228    // the idea of a recurrence.
229    #[derive(Debug)]
230    struct Enumerated {
231        cost: f32,
232        posteriors: Vec<f64>,
233        durations: Vec<f64>,
234        skips: Vec<f64>,
235    }
236
237    fn by_enumeration(
238        chain: &AlignChain,
239        dense: &DenseFst<'_, StdArc>,
240        num_frames: usize,
241    ) -> Option<Enumerated> {
242        #[allow(clippy::too_many_arguments)]
243        fn walk(
244            chain: &AlignChain,
245            dense: &DenseFst<'_, StdArc>,
246            num_frames: usize,
247            frame: usize,
248            position: usize,
249            cost: f32,
250            path: &mut Vec<Step>,
251            paths: &mut Vec<(f64, Vec<Step>)>,
252        ) {
253            if frame == num_frames {
254                if position == chain.num_phones() {
255                    paths.push(((-cost as f64).exp(), path.clone()));
256                }
257                return;
258            }
259            let scores = dense.frame(frame);
260            let blank = chain.blank() as usize;
261            let mut take = |step: Step, extra: f32, to: usize| {
262                path.push(step);
263                walk(
264                    chain,
265                    dense,
266                    num_frames,
267                    frame + 1,
268                    to,
269                    cost + extra,
270                    path,
271                    paths,
272                );
273                path.pop();
274            };
275
276            let silent = Step {
277                column: blank,
278                sounded: None,
279                skipped: None,
280            };
281            take(silent, scores[blank], position);
282            if position > 0 {
283                let column = chain.phones()[position - 1] as usize;
284                let step = Step {
285                    column,
286                    sounded: Some(position - 1),
287                    skipped: None,
288                };
289                take(step, scores[column], position);
290            }
291            if position < chain.num_phones() {
292                let column = chain.phones()[position] as usize;
293                let step = Step {
294                    column,
295                    sounded: Some(position),
296                    skipped: None,
297                };
298                take(step, scores[column], position + 1);
299
300                let skip = chain.skip_costs()[position];
301                if skip.is_finite() {
302                    let step = Step {
303                        skipped: Some(position),
304                        ..silent
305                    };
306                    take(step, skip + scores[blank], position + 1);
307                }
308            }
309        }
310
311        let mut paths = Vec::new();
312        walk(
313            chain,
314            dense,
315            num_frames,
316            0,
317            0,
318            0.0,
319            &mut Vec::new(),
320            &mut paths,
321        );
322        if paths.is_empty() {
323            return None;
324        }
325
326        let total: f64 = paths.iter().map(|(weight, _)| weight).sum();
327        let num_phones = chain.num_phones();
328        let mut posteriors = vec![0f64; num_frames * SYMBOLS];
329        let mut durations = vec![0f64; num_phones];
330        let mut skips = vec![0f64; num_phones];
331        for (weight, path) in &paths {
332            let share = weight / total;
333            for (frame, step) in path.iter().enumerate() {
334                posteriors[frame * SYMBOLS + step.column] += share;
335                if let Some(position) = step.sounded {
336                    durations[position] += share;
337                }
338                if let Some(position) = step.skipped {
339                    skips[position] += share;
340                }
341            }
342        }
343        Some(Enumerated {
344            cost: -(total.ln() as f32),
345            posteriors,
346            durations,
347            skips,
348        })
349    }
350
351    // A small xorshift, so the random cases below are the same every run.
352    struct Rng(u64);
353
354    impl Rng {
355        fn next(&mut self) -> u64 {
356            self.0 ^= self.0 << 13;
357            self.0 ^= self.0 >> 7;
358            self.0 ^= self.0 << 17;
359            self.0
360        }
361
362        fn below(&mut self, n: usize) -> usize {
363            (self.next() % n as u64) as usize
364        }
365
366        // Costs in a range where several alignments have real mass, so the
367        // posteriors under test are not all zero and one.
368        fn cost(&mut self) -> f32 {
369            self.below(1 << 14) as f32 / 4096.0
370        }
371    }
372
373    #[test]
374    fn it_agrees_with_enumerating_every_alignment() {
375        let mut rng = Rng(0x0CC0_9E37_79B9_7C15);
376        let mut compared = 0;
377
378        for round in 0..200 {
379            let num_frames = 1 + rng.below(6);
380            let num_phones = rng.below(num_frames.min(3) + 1);
381            let phones: Vec<u32> = (0..num_phones)
382                .map(|_| 1 + rng.below(SYMBOLS - 1) as u32)
383                .collect();
384            let chain = AlignChain::new(phones);
385            let chain = if rng.below(2) == 0 {
386                chain.with_uniform_skip_cost(rng.cost()).unwrap()
387            } else {
388                chain
389            };
390
391            let scores: Vec<f32> = (0..num_frames * SYMBOLS).map(|_| rng.cost()).collect();
392            let dense = DenseFst::<StdArc>::new(&scores, num_frames, SYMBOLS).unwrap();
393
394            let expected = by_enumeration(&chain, &dense, num_frames);
395            let measured = occupancy(&chain, &dense).unwrap();
396
397            match (expected, measured) {
398                (None, None) => {}
399                (Some(expected), Some(measured)) => {
400                    compared += 1;
401                    assert!(
402                        (measured.cost() - expected.cost).abs() < 1e-3,
403                        "round {round}: total {} against every path's {}",
404                        measured.cost(),
405                        expected.cost
406                    );
407                    for frame in 0..num_frames {
408                        for column in 0..SYMBOLS {
409                            let want = expected.posteriors[frame * SYMBOLS + column];
410                            let got = measured.frame(frame)[column] as f64;
411                            assert!(
412                                (got - want).abs() < 1e-4,
413                                "round {round}: frame {frame} column {column}, {got} against {want}"
414                            );
415                        }
416                    }
417                    for position in 0..chain.num_phones() {
418                        assert!(
419                            (measured.expected_durations()[position] as f64
420                                - expected.durations[position])
421                                .abs()
422                                < 1e-4,
423                            "round {round}: duration of position {position}"
424                        );
425                        assert!(
426                            (measured.skip_posteriors()[position] as f64
427                                - expected.skips[position])
428                                .abs()
429                                < 1e-4,
430                            "round {round}: skip of position {position}"
431                        );
432                    }
433                }
434                (expected, measured) => {
435                    panic!("round {round}: enumeration {expected:?}, occupancy {measured:?}")
436                }
437            }
438        }
439
440        assert!(compared > 150, "only {compared} rounds had an alignment");
441    }
442
443    #[test]
444    fn every_frame_is_a_distribution() {
445        let mut rng = Rng(0xABCD_1234_5678_9EF1);
446        for _ in 0..50 {
447            let num_frames = 2 + rng.below(20);
448            let num_phones = rng.below(num_frames.min(8) + 1);
449            let phones: Vec<u32> = (0..num_phones)
450                .map(|_| 1 + rng.below(SYMBOLS - 1) as u32)
451                .collect();
452            let chain = AlignChain::new(phones).with_uniform_skip_cost(2.0).unwrap();
453            let scores: Vec<f32> = (0..num_frames * SYMBOLS).map(|_| rng.cost()).collect();
454            let dense = DenseFst::<StdArc>::new(&scores, num_frames, SYMBOLS).unwrap();
455
456            let measured = occupancy(&chain, &dense).unwrap().expect("an occupancy");
457            for frame in 0..num_frames {
458                let mass: f32 = measured.frame(frame).iter().sum();
459                assert!((mass - 1.0).abs() < 1e-4, "frame {frame} carries {mass}");
460            }
461            // Every frame either sounds a phone or does not, so the expected
462            // durations and the blank's mass share out the frames between them.
463            let sounded: f32 = measured.expected_durations().iter().sum();
464            let silent: f32 = (0..num_frames)
465                .map(|frame| measured.frame(frame)[chain.blank() as usize])
466                .sum();
467            assert!(
468                (sounded + silent - num_frames as f32).abs() < 1e-2,
469                "{sounded} sounding and {silent} silent, of {num_frames}"
470            );
471        }
472    }
473
474    // The two semirings answer different questions, and the difference is the
475    // point of having both.
476    #[test]
477    fn the_total_is_over_every_alignment_not_the_best_one() {
478        // Three frames, one phone: the phone can sound in any non-empty run, so
479        // there are several alignments and the sum beats the best of them.
480        let scores = [
481            1.0, 0.5, 9.0, 9.0, //
482            1.0, 0.5, 9.0, 9.0, //
483            1.0, 0.5, 9.0, 9.0,
484        ];
485        let dense = DenseFst::<StdArc>::new(&scores, 3, SYMBOLS).unwrap();
486        let chain = AlignChain::new(vec![1]);
487
488        let best = align(&chain, &dense).unwrap().expect("an alignment");
489        let all = occupancy(&chain, &dense).unwrap().expect("an occupancy");
490        assert!(
491            all.cost() < best.cost() - 0.1,
492            "sum {} against best {}",
493            all.cost(),
494            best.cost()
495        );
496
497        // With a reference as long as the audio there is exactly one alignment,
498        // and then the two agree.
499        let chain = AlignChain::new(vec![1, 1, 1]);
500        let best = align(&chain, &dense).unwrap().expect("an alignment");
501        let all = occupancy(&chain, &dense).unwrap().expect("an occupancy");
502        assert!(
503            (all.cost() - best.cost()).abs() < 1e-5,
504            "sum {} against best {}",
505            all.cost(),
506            best.cost()
507        );
508    }
509
510    #[test]
511    fn a_confident_model_puts_the_mass_on_the_alignment() {
512        let mut scores = vec![20.0; 5 * SYMBOLS];
513        for (frame, column) in [1usize, 1, 0, 2, 0].into_iter().enumerate() {
514            scores[frame * SYMBOLS + column] = 0.0;
515        }
516        let dense = DenseFst::<StdArc>::new(&scores, 5, SYMBOLS).unwrap();
517        let chain = AlignChain::new(vec![1, 2]);
518
519        let alignment = align(&chain, &dense).unwrap().expect("an alignment");
520        let measured = occupancy(&chain, &dense).unwrap().expect("an occupancy");
521
522        for frame in 0..5 {
523            let column = match alignment.sounding(frame) {
524                Some(position) => chain.phones()[position] as usize,
525                None => chain.blank() as usize,
526            };
527            assert!(
528                measured.frame(frame)[column] > 0.99,
529                "frame {frame}: {:?}",
530                measured.frame(frame)
531            );
532        }
533        assert!((measured.expected_durations()[0] - 2.0).abs() < 0.01);
534        assert!((measured.expected_durations()[1] - 1.0).abs() < 0.01);
535        assert!(measured.skip_posteriors().iter().all(|&mass| mass == 0.0));
536    }
537
538    // The instrument the tropical answer cannot supply: how close the skip was.
539    #[test]
540    fn a_skip_that_is_a_coin_toss_shows_as_one() {
541        // Sounding phone 2 in frame 1 costs exactly what giving it up costs.
542        let scores = [
543            10.0, 0.0, 10.0, 10.0, //
544            0.0, 10.0, 4.0, 10.0,
545        ];
546        let dense = DenseFst::<StdArc>::new(&scores, 2, SYMBOLS).unwrap();
547        let chain = AlignChain::new(vec![1, 2])
548            .with_skip_costs(&[9.0, 4.0])
549            .unwrap();
550
551        let measured = occupancy(&chain, &dense).unwrap().expect("an occupancy");
552        assert!(
553            (measured.skip_posteriors()[1] - 0.5).abs() < 1e-3,
554            "{:?}",
555            measured.skip_posteriors()
556        );
557        assert!(measured.skip_posteriors()[0] < 1e-6, "no reason to skip it");
558
559        // And the aligner, which has to decide, keeps the phone, so the
560        // decision alone would not have shown the toss.
561        let alignment = align(&chain, &dense).unwrap().expect("an alignment");
562        assert!(alignment.skipped().is_empty());
563    }
564
565    #[test]
566    fn the_label_prior_is_the_posterior_averaged_over_the_frames() {
567        let scores = [
568            1.0, 0.5, 9.0, 9.0, //
569            1.0, 0.5, 9.0, 9.0, //
570            1.0, 0.5, 9.0, 9.0,
571        ];
572        let dense = DenseFst::<StdArc>::new(&scores, 3, SYMBOLS).unwrap();
573        let chain = AlignChain::new(vec![1]);
574        let measured = occupancy(&chain, &dense).unwrap().expect("an occupancy");
575
576        let prior = measured.label_prior();
577        assert_eq!(prior.len(), SYMBOLS);
578        assert!((prior.iter().sum::<f32>() - 1.0).abs() < 1e-5);
579        for (column, &averaged) in prior.iter().enumerate() {
580            let by_hand: f32 = (0..3)
581                .map(|frame| measured.frame(frame)[column])
582                .sum::<f32>()
583                / 3.0;
584            assert!((averaged - by_hand).abs() < 1e-6);
585        }
586        // Columns the reference never names take none of it.
587        assert_eq!(prior[2], 0.0);
588        assert_eq!(prior[3], 0.0);
589    }
590
591    #[test]
592    fn a_reference_longer_than_the_audio_has_no_occupancy() {
593        let scores = vec![1.0; 2 * SYMBOLS];
594        let dense = DenseFst::<StdArc>::new(&scores, 2, SYMBOLS).unwrap();
595        assert_eq!(
596            occupancy(&AlignChain::new(vec![1, 2, 3]), &dense).unwrap(),
597            None
598        );
599
600        let err = occupancy(&AlignChain::new(vec![9]), &dense).unwrap_err();
601        assert!(format!("{err}").contains("does not have"), "{err}");
602    }
603
604    #[test]
605    fn an_empty_reference_is_all_blank() {
606        let scores = [0.25, 9.0, 9.0, 9.0].repeat(3);
607        let dense = DenseFst::<StdArc>::new(&scores, 3, SYMBOLS).unwrap();
608        let measured = occupancy(&AlignChain::new(vec![]), &dense)
609            .unwrap()
610            .expect("an occupancy");
611
612        assert!((measured.cost() - 0.75).abs() < 1e-5, "{}", measured.cost());
613        assert!(measured.expected_durations().is_empty());
614        for frame in 0..3 {
615            assert!((measured.frame(frame)[0] - 1.0).abs() < 1e-6);
616        }
617    }
618
619    // The alignment and the occupancy have to be talking about the same
620    // frames, since a caller reads them side by side.
621    #[test]
622    fn it_lines_up_with_the_alignment_frame_for_frame() {
623        let mut rng = Rng(0x5151_2727_3939_4B4B);
624        for _ in 0..40 {
625            let num_frames = 2 + rng.below(12);
626            let num_phones = rng.below(num_frames.min(5) + 1);
627            let phones: Vec<u32> = (0..num_phones)
628                .map(|_| 1 + rng.below(SYMBOLS - 1) as u32)
629                .collect();
630            let chain = AlignChain::new(phones);
631            let scores: Vec<f32> = (0..num_frames * SYMBOLS).map(|_| rng.cost()).collect();
632            let dense = DenseFst::<StdArc>::new(&scores, num_frames, SYMBOLS).unwrap();
633
634            let alignment: Alignment = align(&chain, &dense).unwrap().expect("an alignment");
635            let measured = occupancy(&chain, &dense).unwrap().expect("an occupancy");
636            assert_eq!(measured.num_frames(), alignment.num_frames());
637            assert_eq!(measured.num_symbols(), SYMBOLS);
638            assert_eq!(measured.expected_durations().len(), alignment.num_phones());
639            // The best path is one of the paths, so it can never cost less than
640            // all of them together.
641            assert!(measured.cost() <= alignment.cost() + 1e-4);
642        }
643    }
644}