1use faer::Side;
2use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh};
3use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis, s};
4use std::fmt;
5
6const DEFAULT_MAX_ITER: usize = 30;
7const DEFAULT_TOP_K: usize = 1;
8const DEFAULT_TEMPERATURE: f64 = 0.25;
9const DEFAULT_CODE_RIDGE: f64 = 1.0e-8;
10const DEFAULT_TOLERANCE: f64 = 1.0e-7;
11const INACTIVE_LAMBDA: f64 = 1.0e30;
12const MIN_NORM2: f64 = 1.0e-24;
13
14const GEOM_R_MIN: f64 = 0.1;
28const GEOM_R_EPS: f64 = 1.0e-12;
29const GEOM_COS_MIN: f64 = 0.9;
30const GEOM_FACTOR_CAP: f64 = 1.0e6;
31const GEOM_LADDER_BASE: f64 = 1.3;
35const GEOM_LADDER_STEP: f64 = 1.3;
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum LinearDictionaryAssignment {
39 TopK,
40 Softmax,
41}
42
43impl LinearDictionaryAssignment {
44 pub fn parse(value: &str) -> Result<Self, String> {
45 match value.trim().to_ascii_lowercase().as_str() {
46 "top_k" | "topk" | "hard" => Ok(Self::TopK),
47 "softmax" | "soft" => Ok(Self::Softmax),
48 other => Err(format!(
49 "linear dictionary assignment must be 'top_k' or 'softmax'; got {other:?}"
50 )),
51 }
52 }
53
54 pub const fn as_str(self) -> &'static str {
55 match self {
56 Self::TopK => "top_k",
57 Self::Softmax => "softmax",
58 }
59 }
60}
61
62#[derive(Clone, Debug, PartialEq)]
68pub enum LinearDictionaryError {
69 InvalidInput {
70 reason: String,
71 },
72 NumericalFailure {
73 reason: String,
74 },
75 NonConvergence {
76 iterations: usize,
77 explained_variance: f64,
78 ev_residual: f64,
79 routing_residual: f64,
80 accepted_births: usize,
81 tolerance: f64,
82 },
83}
84
85impl LinearDictionaryError {
86 fn invalid_input(reason: impl Into<String>) -> Self {
87 Self::InvalidInput {
88 reason: reason.into(),
89 }
90 }
91}
92
93impl From<String> for LinearDictionaryError {
94 fn from(reason: String) -> Self {
95 Self::NumericalFailure { reason }
96 }
97}
98
99impl fmt::Display for LinearDictionaryError {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 match self {
102 Self::InvalidInput { reason } | Self::NumericalFailure { reason } => {
103 f.write_str(reason)
104 }
105 Self::NonConvergence {
106 iterations,
107 explained_variance,
108 ev_residual,
109 routing_residual,
110 accepted_births,
111 tolerance,
112 } => write!(
113 f,
114 "linear_dictionary_fit did not converge: {iterations} coordinate-descent sweeps \
115 ended at EV {explained_variance:.6} with canonical EV residual \
116 {ev_residual:.3e}, reroute residual {routing_residual:.3e}, and \
117 {accepted_births} accepted dead-atom births (tolerance {tolerance:.3e}); a \
118 non-converged iterate is not a model"
119 ),
120 }
121 }
122}
123
124impl std::error::Error for LinearDictionaryError {}
125
126#[derive(Clone, Debug)]
127pub struct LinearDictionaryConfig {
128 pub n_atoms: usize,
129 pub max_iter: usize,
130 pub top_k: usize,
131 pub assignment: LinearDictionaryAssignment,
132 pub temperature: f64,
133 pub code_ridge: f64,
134 pub tolerance: f64,
135 pub center_rank_one: bool,
146}
147
148impl LinearDictionaryConfig {
149 pub fn new(n_atoms: usize) -> Self {
150 Self {
151 n_atoms,
152 ..Self::default()
153 }
154 }
155}
156
157impl Default for LinearDictionaryConfig {
158 fn default() -> Self {
159 Self {
160 n_atoms: 1,
161 max_iter: DEFAULT_MAX_ITER,
162 top_k: DEFAULT_TOP_K,
163 assignment: LinearDictionaryAssignment::TopK,
164 temperature: DEFAULT_TEMPERATURE,
165 code_ridge: DEFAULT_CODE_RIDGE,
166 tolerance: DEFAULT_TOLERANCE,
167 center_rank_one: false,
168 }
169 }
170}
171
172#[derive(Clone, Debug)]
179pub struct LinearDictionaryFit {
180 pub atoms: Array2<f64>,
181 pub assignments: Array2<f64>,
182 pub fitted: Array2<f64>,
183 pub lambdas: Array1<f64>,
184 pub reml_scores: Array1<f64>,
185 pub explained_variance: f64,
186 pub iterations: usize,
187 pub convergence: LinearDictionaryConvergence,
188 pub assignment: LinearDictionaryAssignment,
189 pub top_k: usize,
190}
191
192#[derive(Clone, Copy, Debug, PartialEq)]
200pub struct LinearDictionaryConvergence {
201 pub ev_residual: f64,
202 pub routing_residual: f64,
203 pub accepted_births: usize,
204 pub tolerance: f64,
205}
206
207pub fn fit_linear_dictionary(
220 x: ArrayView2<'_, f64>,
221 config: &LinearDictionaryConfig,
222) -> Result<LinearDictionaryFit, LinearDictionaryError> {
223 validate_inputs(x, config)?;
224 if config.n_atoms == 1 {
225 return fit_rank_one_pca_lane(x, config);
226 }
227 fit_multi_atom_dictionary(x, config)
228}
229
230fn plain_atom_step(
239 x: ArrayView2<'_, f64>,
240 atoms: &mut Array2<f64>,
241 assignments: &mut Array2<f64>,
242 fitted: &mut Array2<f64>,
243 lambdas: &mut Array1<f64>,
244 reml_scores: &mut Array1<f64>,
245 top_k: usize,
246 config: &LinearDictionaryConfig,
247) -> Result<(f64, f64, usize), LinearDictionaryError> {
248 let n_atoms = atoms.nrows();
249 let mut reseeded = vec![false; n_atoms];
250 for atom_idx in 0..n_atoms {
251 reseeded[atom_idx] = fit_one_atom_penalized_ls(
252 x,
253 atoms,
254 assignments,
255 fitted,
256 lambdas,
257 reml_scores,
258 atom_idx,
259 config.code_ridge,
260 )?;
261 }
262
263 let sweep_ev = explained_variance(x, fitted.view());
264 let rerouted = reroute_against_atoms(x, atoms.view(), top_k, config)?;
265 let rerouted_fitted = rerouted.dot(&*atoms);
266 let rerouted_ev = explained_variance(x, rerouted_fitted.view());
267
268 let mut accepted_births = 0usize;
269 for atom_idx in 0..n_atoms {
270 if !reseeded[atom_idx] {
271 continue;
272 }
273 let accepted = rerouted
274 .column(atom_idx)
275 .iter()
276 .any(|coefficient| *coefficient != 0.0);
277 if accepted {
278 accepted_births += 1;
279 } else {
280 atoms.row_mut(atom_idx).fill(0.0);
281 lambdas[atom_idx] = INACTIVE_LAMBDA;
282 reml_scores[atom_idx] = 0.0;
283 }
284 }
285
286 *assignments = rerouted;
287 *fitted = rerouted_fitted;
288 Ok((sweep_ev, rerouted_ev, accepted_births))
289}
290
291fn fit_multi_atom_dictionary(
292 x: ArrayView2<'_, f64>,
293 config: &LinearDictionaryConfig,
294) -> Result<LinearDictionaryFit, LinearDictionaryError> {
295 let top_k = config.top_k.min(config.n_atoms).max(1);
296 let mut atoms = initialize_atoms(x, config.n_atoms);
297 let mut assignments = reroute_against_atoms(x, atoms.view(), top_k, config)?;
298 let mut fitted = assignments.dot(&atoms);
299 let mut lambdas = Array1::<f64>::from_elem(config.n_atoms, INACTIVE_LAMBDA);
300 let mut reml_scores = Array1::<f64>::zeros(config.n_atoms);
301 let mut previous_ev = explained_variance(x, fitted.view());
302 let mut completed_iterations = 0usize;
303 let mut last_ev = previous_ev;
304 let mut ev_residual = f64::INFINITY;
305 let mut routing_residual = f64::INFINITY;
306 let mut accepted_births = 0usize;
307 let mut prev_atoms: Option<Array2<f64>> = None;
312 let mut prev_delta: Option<Array2<f64>> = None;
313
314 for iteration in 0..config.max_iter {
315 let (mut sweep_ev, mut rerouted_ev, mut births) = plain_atom_step(
316 x,
317 &mut atoms,
318 &mut assignments,
319 &mut fitted,
320 &mut lambdas,
321 &mut reml_scores,
322 top_k,
323 config,
324 )?;
325
326 let this_delta = prev_atoms.as_ref().map(|previous| &atoms - previous);
339 let mut jumped = false;
340 if births == 0 {
341 if let (Some(delta), Some(previous_delta)) = (this_delta.as_ref(), prev_delta.as_ref())
342 {
343 if let Some((cand_atoms, cand_route, cand_fitted)) = try_geometric_extrapolation(
344 atoms.view(),
345 delta.view(),
346 previous_delta.view(),
347 x,
348 top_k,
349 config,
350 rerouted_ev,
351 ) {
352 atoms = cand_atoms;
353 assignments = cand_route;
354 fitted = cand_fitted;
355 jumped = true;
356 let (_, _, rebuild_births_1) = plain_atom_step(
359 x,
360 &mut atoms,
361 &mut assignments,
362 &mut fitted,
363 &mut lambdas,
364 &mut reml_scores,
365 top_k,
366 config,
367 )?;
368 let rebuilt_prev = atoms.clone();
369 let (rebuild_sweep_ev, rebuild_ev, rebuild_births_2) = plain_atom_step(
370 x,
371 &mut atoms,
372 &mut assignments,
373 &mut fitted,
374 &mut lambdas,
375 &mut reml_scores,
376 top_k,
377 config,
378 )?;
379 prev_delta = Some(&atoms - &rebuilt_prev);
380 prev_atoms = Some(atoms.clone());
381 sweep_ev = rebuild_sweep_ev;
382 rerouted_ev = rebuild_ev;
383 births = rebuild_births_1.max(rebuild_births_2);
384 }
385 }
386 }
387 if !jumped {
388 prev_atoms = Some(atoms.clone());
389 prev_delta = this_delta;
390 }
391 accepted_births = births;
392
393 completed_iterations = iteration + 1;
394 ev_residual = (rerouted_ev - previous_ev).abs();
395 routing_residual = (rerouted_ev - sweep_ev).abs();
396 last_ev = rerouted_ev;
397
398 if accepted_births == 0
399 && ev_residual <= config.tolerance
400 && routing_residual <= config.tolerance
401 && iteration >= 1
408 {
409 let final_score =
413 penalized_reconstruction_loss(x, fitted.view(), config.code_ridge, atoms.view());
414 for atom_idx in 0..config.n_atoms {
415 if atoms.row(atom_idx).dot(&atoms.row(atom_idx)) > MIN_NORM2 {
416 reml_scores[atom_idx] = final_score;
417 }
418 }
419 return Ok(LinearDictionaryFit {
420 atoms,
421 assignments,
422 fitted,
423 lambdas,
424 reml_scores,
425 explained_variance: last_ev,
426 iterations: completed_iterations,
427 convergence: LinearDictionaryConvergence {
428 ev_residual,
429 routing_residual,
430 accepted_births,
431 tolerance: config.tolerance,
432 },
433 assignment: config.assignment,
434 top_k,
435 });
436 }
437 previous_ev = rerouted_ev;
438 }
439
440 Err(LinearDictionaryError::NonConvergence {
444 iterations: completed_iterations,
445 explained_variance: last_ev,
446 ev_residual,
447 routing_residual,
448 accepted_births,
449 tolerance: config.tolerance,
450 })
451}
452
453fn try_geometric_extrapolation(
471 atoms: ArrayView2<'_, f64>,
472 delta: ArrayView2<'_, f64>,
473 prev_delta: ArrayView2<'_, f64>,
474 x: ArrayView2<'_, f64>,
475 top_k: usize,
476 config: &LinearDictionaryConfig,
477 current_ev: f64,
478) -> Option<(Array2<f64>, Array2<f64>, Array2<f64>)> {
479 let cross: f64 = delta
480 .iter()
481 .zip(prev_delta.iter())
482 .map(|(a, b)| a * b)
483 .sum();
484 let prev_norm2: f64 = prev_delta.iter().map(|v| v * v).sum();
485 let delta_norm2: f64 = delta.iter().map(|v| v * v).sum();
486 if !(prev_norm2 > 0.0 && delta_norm2 > 0.0) {
487 return None;
488 }
489 let ratio = cross / prev_norm2;
490 if !(ratio > GEOM_R_MIN && ratio < 1.0 - GEOM_R_EPS) {
491 return None;
492 }
493 let cosine = cross / (delta_norm2.sqrt() * prev_norm2.sqrt());
496 if !(cosine > GEOM_COS_MIN) {
497 return None;
498 }
499 let max_factor = (ratio / (1.0 - ratio)).min(GEOM_FACTOR_CAP);
500 if !(max_factor.is_finite() && max_factor > 1.0) {
501 return None;
502 }
503 let delta_owned = delta.to_owned();
504 let atoms_owned = atoms.to_owned();
505 let mut best: Option<(Array2<f64>, Array2<f64>, Array2<f64>)> = None;
506 let mut best_ev = current_ev;
507 let mut factor = GEOM_LADDER_BASE;
508 loop {
509 let mut candidate = &atoms_owned + &(factor * &delta_owned);
510 for atom_idx in 0..candidate.nrows() {
511 normalize_row(candidate.slice_mut(s![atom_idx, ..]));
512 }
513 if let Ok(route) = reroute_against_atoms(x, candidate.view(), top_k, config) {
514 let fitted = route.dot(&candidate);
515 let ev = explained_variance(x, fitted.view());
516 if ev > best_ev {
517 best_ev = ev;
518 best = Some((candidate, route, fitted));
519 }
520 }
521 if factor >= max_factor {
522 break;
523 }
524 factor = (factor * GEOM_LADDER_STEP).min(max_factor);
525 }
526 best
527}
528
529fn reroute_against_atoms(
535 x: ArrayView2<'_, f64>,
536 atoms: ArrayView2<'_, f64>,
537 top_k: usize,
538 config: &LinearDictionaryConfig,
539) -> Result<Array2<f64>, String> {
540 match config.assignment {
541 LinearDictionaryAssignment::TopK => top_k_assignments(x, atoms, top_k, config.code_ridge),
542 LinearDictionaryAssignment::Softmax => {
543 softmax_assignments(x, atoms, top_k, config.temperature, config.code_ridge)
544 }
545 }
546}
547
548fn validate_inputs(
549 x: ArrayView2<'_, f64>,
550 config: &LinearDictionaryConfig,
551) -> Result<(), LinearDictionaryError> {
552 if x.nrows() == 0 || x.ncols() == 0 {
553 return Err(LinearDictionaryError::invalid_input(
554 "linear_dictionary_fit requires a non-empty 2-D matrix",
555 ));
556 }
557 if !x.iter().all(|value| value.is_finite()) {
558 return Err(LinearDictionaryError::invalid_input(
559 "linear_dictionary_fit input must be finite",
560 ));
561 }
562 if config.n_atoms == 0 {
563 return Err(LinearDictionaryError::invalid_input(
564 "linear_dictionary_fit requires K >= 1",
565 ));
566 }
567 if config.max_iter == 0 {
568 return Err(LinearDictionaryError::invalid_input(
569 "linear_dictionary_fit requires max_iter >= 1",
570 ));
571 }
572 if config.top_k == 0 || config.top_k > config.n_atoms {
573 return Err(LinearDictionaryError::invalid_input(format!(
574 "linear_dictionary_fit top_k must be in [1, K={}]; got {}",
575 config.n_atoms, config.top_k
576 )));
577 }
578 if !(config.temperature.is_finite() && config.temperature > 0.0) {
579 return Err(LinearDictionaryError::invalid_input(format!(
580 "linear_dictionary_fit temperature must be finite and positive; got {}",
581 config.temperature
582 )));
583 }
584 if !(config.code_ridge.is_finite() && config.code_ridge > 0.0) {
585 return Err(LinearDictionaryError::invalid_input(format!(
586 "linear_dictionary_fit code_ridge must be finite and positive; got {}",
587 config.code_ridge
588 )));
589 }
590 if !(config.tolerance.is_finite() && config.tolerance >= 0.0) {
591 return Err(LinearDictionaryError::invalid_input(format!(
592 "linear_dictionary_fit tolerance must be finite and non-negative; got {}",
593 config.tolerance
594 )));
595 }
596 Ok(())
597}
598
599fn fit_rank_one_pca_lane(
615 x: ArrayView2<'_, f64>,
616 config: &LinearDictionaryConfig,
617) -> Result<LinearDictionaryFit, LinearDictionaryError> {
618 if config.center_rank_one {
619 return fit_rank_one_centered_lane(x, config);
620 }
621 let covariance = x.t().dot(&x);
622 let (evals, evecs) = covariance
623 .eigh(Side::Lower)
624 .map_err(|err| format!("linear_dictionary_fit PCA eigensolve failed: {err}"))?;
625 let last = evals.len() - 1;
626 let mut atom = evecs.column(last).to_owned();
627 orient_vector(&mut atom);
628 let mut assignments = Array2::<f64>::zeros((x.nrows(), 1));
629 for row in 0..x.nrows() {
630 assignments[[row, 0]] = x.row(row).dot(&atom) / (1.0 + config.code_ridge);
631 }
632 let mut atoms = atom.insert_axis(Axis(0)).to_owned();
633 normalize_atom_and_assignments(&mut atoms, &mut assignments, 0);
634 let fitted = assignments.dot(&atoms);
635 let score = penalized_reconstruction_loss(x, fitted.view(), config.code_ridge, atoms.view());
636 Ok(LinearDictionaryFit {
637 atoms,
638 assignments,
639 fitted: fitted.clone(),
640 lambdas: Array1::from_elem(1, config.code_ridge),
641 reml_scores: Array1::from_elem(1, score),
642 explained_variance: explained_variance(x, fitted.view()),
643 iterations: 1.min(config.max_iter),
644 convergence: LinearDictionaryConvergence {
645 ev_residual: 0.0,
646 routing_residual: 0.0,
647 accepted_births: 0,
648 tolerance: config.tolerance,
649 },
650 assignment: config.assignment,
651 top_k: 1,
652 })
653}
654
655fn fit_rank_one_centered_lane(
663 x: ArrayView2<'_, f64>,
664 config: &LinearDictionaryConfig,
665) -> Result<LinearDictionaryFit, LinearDictionaryError> {
666 let CenteredRankOne {
667 atom,
668 codes,
669 fitted,
670 explained_variance: ev,
671 } = centered_rank_one_components(x, config.code_ridge)?;
672 let atoms = atom.insert_axis(Axis(0)).to_owned();
673 let assignments = codes.insert_axis(Axis(1)).to_owned();
674 let score = penalized_reconstruction_loss(x, fitted.view(), config.code_ridge, atoms.view());
675 Ok(LinearDictionaryFit {
676 atoms,
677 assignments,
678 fitted,
679 lambdas: Array1::from_elem(1, config.code_ridge),
680 reml_scores: Array1::from_elem(1, score),
681 explained_variance: ev,
682 iterations: 1.min(config.max_iter),
683 convergence: LinearDictionaryConvergence {
684 ev_residual: 0.0,
685 routing_residual: 0.0,
686 accepted_births: 0,
687 tolerance: config.tolerance,
688 },
689 assignment: config.assignment,
690 top_k: 1,
691 })
692}
693
694struct CenteredRankOne {
697 atom: Array1<f64>,
699 codes: Array1<f64>,
701 fitted: Array2<f64>,
703 explained_variance: f64,
705}
706
707fn centered_rank_one_components(
708 x: ArrayView2<'_, f64>,
709 code_ridge: f64,
710) -> Result<CenteredRankOne, String> {
711 if x.nrows() == 0 || x.ncols() == 0 {
712 return Err("rank_one_centered_pca_ceiling requires a non-empty 2-D matrix".to_string());
713 }
714 if !(code_ridge.is_finite() && code_ridge > 0.0) {
715 return Err(format!(
716 "rank_one_centered_pca_ceiling code_ridge must be finite and positive; got {code_ridge}"
717 ));
718 }
719 let means = x.mean_axis(Axis(0)).expect("non-empty input has means");
720 let centered = &x.to_owned() - &means;
721 let covariance = centered.t().dot(¢ered);
722 let (evals, evecs) = covariance
723 .eigh(Side::Lower)
724 .map_err(|err| format!("rank_one_centered_pca_ceiling eigensolve failed: {err}"))?;
725 let last = evals.len() - 1;
726 let mut atom = evecs.column(last).to_owned();
727 orient_vector(&mut atom);
728 let shrink = 1.0 / (1.0 + code_ridge);
729 let mut codes = Array1::<f64>::zeros(x.nrows());
730 let mut fitted = Array2::<f64>::zeros(x.dim());
731 for row in 0..x.nrows() {
732 let code = centered.row(row).dot(&atom) * shrink;
733 codes[row] = code;
734 for col in 0..x.ncols() {
735 fitted[[row, col]] = means[col] + code * atom[col];
736 }
737 }
738 let ev = explained_variance(x, fitted.view());
739 Ok(CenteredRankOne {
740 atom,
741 codes,
742 fitted,
743 explained_variance: ev,
744 })
745}
746
747pub fn rank_one_centered_pca_ceiling(
758 x: ArrayView2<'_, f64>,
759 code_ridge: f64,
760) -> Result<(Array2<f64>, f64), String> {
761 let components = centered_rank_one_components(x, code_ridge)?;
762 Ok((components.fitted, components.explained_variance))
763}
764
765fn initialize_atoms(x: ArrayView2<'_, f64>, n_atoms: usize) -> Array2<f64> {
766 let mut atoms = Array2::<f64>::zeros((n_atoms, x.ncols()));
767 let first = max_norm_row(x);
768 atoms.row_mut(0).assign(&x.row(first));
769 normalize_row(atoms.slice_mut(s![0, ..]));
770 let mut min_dist2 = Array1::<f64>::from_elem(x.nrows(), f64::INFINITY);
771
772 for atom_idx in 1..n_atoms {
773 let prev = atoms.row(atom_idx - 1);
774 for row in 0..x.nrows() {
775 let dist2 = squared_distance(x.row(row), prev);
776 if dist2 < min_dist2[row] {
777 min_dist2[row] = dist2;
778 }
779 }
780 let chosen = if atom_idx < x.nrows() {
781 max_index(min_dist2.view())
782 } else {
783 atom_idx % x.nrows()
784 };
785 atoms.row_mut(atom_idx).assign(&x.row(chosen));
786 normalize_row(atoms.slice_mut(s![atom_idx, ..]));
787 }
788 atoms
789}
790
791fn fit_one_atom_penalized_ls(
792 x: ArrayView2<'_, f64>,
793 atoms: &mut Array2<f64>,
794 assignments: &mut Array2<f64>,
795 fitted: &mut Array2<f64>,
796 lambdas: &mut Array1<f64>,
797 reml_scores: &mut Array1<f64>,
798 atom_idx: usize,
799 atom_ridge: f64,
800) -> Result<bool, String> {
801 let code = assignments.column(atom_idx).to_owned();
802 let code_norm2 = code.dot(&code);
803 if code_norm2 <= MIN_NORM2 {
804 let mut worst_row = 0usize;
817 let mut worst_res2 = -1.0_f64;
818 for row in 0..x.nrows() {
819 let mut res2 = 0.0_f64;
820 for col in 0..x.ncols() {
821 let d = x[[row, col]] - fitted[[row, col]];
822 res2 += d * d;
823 }
824 if res2 > worst_res2 {
825 worst_res2 = res2;
826 worst_row = row;
827 }
828 }
829 if worst_res2 <= MIN_NORM2 {
830 atoms.row_mut(atom_idx).fill(0.0);
834 lambdas[atom_idx] = INACTIVE_LAMBDA;
835 reml_scores[atom_idx] = 0.0;
836 return Ok(false);
837 }
838 for col in 0..x.ncols() {
839 atoms[[atom_idx, col]] = x[[worst_row, col]] - fitted[[worst_row, col]];
840 }
841 normalize_row(atoms.slice_mut(s![atom_idx, ..]));
842 lambdas[atom_idx] = atom_ridge;
843 reml_scores[atom_idx] =
844 penalized_reconstruction_loss(x, fitted.view(), atom_ridge, atoms.view());
845 return Ok(true);
846 }
847
848 let old_atom = atoms.row(atom_idx).to_owned();
849 let mut residual = x.to_owned() - fitted.view();
850 residual += &code
851 .view()
852 .insert_axis(Axis(1))
853 .dot(&old_atom.view().insert_axis(Axis(0)));
854
855 let denominator = code_norm2 + atom_ridge;
856 for col in 0..x.ncols() {
857 atoms[[atom_idx, col]] = code.dot(&residual.column(col)) / denominator;
858 }
859 lambdas[atom_idx] = atom_ridge;
860 normalize_atom_and_assignments(atoms, assignments, atom_idx);
861 let updated_code = assignments.column(atom_idx).to_owned();
862 fitted.assign(&x);
863 *fitted -= &residual;
864 *fitted += &updated_code
865 .view()
866 .insert_axis(Axis(1))
867 .dot(&atoms.row(atom_idx).insert_axis(Axis(0)));
868 reml_scores[atom_idx] =
869 penalized_reconstruction_loss(x, fitted.view(), atom_ridge, atoms.view());
870 Ok(false)
871}
872
873fn top_k_assignments(
874 x: ArrayView2<'_, f64>,
875 atoms: ArrayView2<'_, f64>,
876 top_k: usize,
877 code_ridge: f64,
878) -> Result<Array2<f64>, String> {
879 let cross = x.dot(&atoms.t());
880 let mut assignments = Array2::<f64>::zeros((x.nrows(), atoms.nrows()));
881 for row in 0..x.nrows() {
882 let active = top_indices_by_abs(cross.row(row), top_k);
883 let coeffs = solve_active_coefficients(atoms, cross.row(row), &active, code_ridge)?;
884 for pos in 0..active.len() {
885 assignments[[row, active[pos]]] = coeffs[pos];
886 }
887 }
888 Ok(assignments)
889}
890
891pub fn linear_dictionary_transform(
899 x: ArrayView2<'_, f64>,
900 atoms: ArrayView2<'_, f64>,
901 top_k: usize,
902 code_ridge: f64,
903) -> Result<Array2<f64>, String> {
904 let k = atoms.nrows();
905 if k == 0 {
906 return Err("linear_dictionary_transform: dictionary has no atoms".to_string());
907 }
908 if x.ncols() != atoms.ncols() {
909 return Err(format!(
910 "linear_dictionary_transform: X has P={} columns but atoms have P={}",
911 x.ncols(),
912 atoms.ncols()
913 ));
914 }
915 let effective_k = top_k.min(k).max(1);
916 top_k_assignments(x, atoms, effective_k, code_ridge)
917}
918
919fn softmax_assignments(
920 x: ArrayView2<'_, f64>,
921 atoms: ArrayView2<'_, f64>,
922 top_k: usize,
923 temperature: f64,
924 code_ridge: f64,
925) -> Result<Array2<f64>, String> {
926 let cross = x.dot(&atoms.t());
927 let atom_norm2 = atoms.map_axis(Axis(1), |row| row.dot(&row).max(MIN_NORM2));
928 let mut assignments = Array2::<f64>::zeros((x.nrows(), atoms.nrows()));
929 for row in 0..x.nrows() {
930 let active = top_indices_by_abs(cross.row(row), top_k);
931 let mut max_score = f64::NEG_INFINITY;
932 for &atom_idx in &active {
933 let score = cross[[row, atom_idx]].abs() / (atom_norm2[atom_idx].sqrt() * temperature);
934 if score > max_score {
935 max_score = score;
936 }
937 }
938 let mut denom = 0.0;
939 for &atom_idx in &active {
940 let score = cross[[row, atom_idx]].abs() / (atom_norm2[atom_idx].sqrt() * temperature);
941 let mass = (score - max_score).exp();
942 assignments[[row, atom_idx]] = mass;
943 denom += mass;
944 }
945 if denom <= 0.0 || !denom.is_finite() {
946 return Err("linear_dictionary_fit softmax assignment underflowed".to_string());
947 }
948 for &atom_idx in &active {
949 let projection = cross[[row, atom_idx]] / (atom_norm2[atom_idx] + code_ridge);
950 assignments[[row, atom_idx]] = assignments[[row, atom_idx]] * projection / denom;
951 }
952 }
953 Ok(assignments)
954}
955
956fn solve_active_coefficients(
957 atoms: ArrayView2<'_, f64>,
958 cross_row: ArrayView1<'_, f64>,
959 active: &[usize],
960 code_ridge: f64,
961) -> Result<Array1<f64>, String> {
962 let m = active.len();
963 let mut system = Array2::<f64>::zeros((m, m));
964 let mut rhs = Array2::<f64>::zeros((m, 1));
965 for i in 0..m {
966 rhs[[i, 0]] = cross_row[active[i]];
967 for j in 0..m {
968 system[[i, j]] = atoms.row(active[i]).dot(&atoms.row(active[j]));
969 }
970 system[[i, i]] += code_ridge;
971 }
972 let factor = system
973 .cholesky(Side::Lower)
974 .map_err(|err| format!("linear_dictionary_fit sparse-code solve failed: {err}"))?;
975 let mut solution = rhs;
976 factor.solve_mat_in_place(&mut solution);
977 Ok(solution.column(0).to_owned())
978}
979
980fn top_indices_by_abs(row: ArrayView1<'_, f64>, top_k: usize) -> Vec<usize> {
981 let mut selected: Vec<(usize, f64)> = Vec::with_capacity(top_k);
982 for idx in 0..row.len() {
983 let score = row[idx].abs();
984 if selected.len() < top_k {
985 selected.push((idx, score));
986 continue;
987 }
988 let mut worst_pos = 0usize;
989 for pos in 1..selected.len() {
990 if selected[pos].1 < selected[worst_pos].1
991 || (selected[pos].1 == selected[worst_pos].1
992 && selected[pos].0 > selected[worst_pos].0)
993 {
994 worst_pos = pos;
995 }
996 }
997 let worst = selected[worst_pos];
998 if score > worst.1 || (score == worst.1 && idx < worst.0) {
999 selected[worst_pos] = (idx, score);
1000 }
1001 }
1002 selected.sort_by(|a, b| {
1003 b.1.partial_cmp(&a.1)
1004 .unwrap_or(std::cmp::Ordering::Equal)
1005 .then_with(|| a.0.cmp(&b.0))
1006 });
1007 selected.into_iter().map(|(idx, _)| idx).collect()
1008}
1009
1010fn normalize_atom_and_assignments(
1011 atoms: &mut Array2<f64>,
1012 assignments: &mut Array2<f64>,
1013 atom_idx: usize,
1014) {
1015 let norm = atoms.row(atom_idx).dot(&atoms.row(atom_idx)).sqrt();
1016 if norm > MIN_NORM2.sqrt() {
1017 atoms.row_mut(atom_idx).mapv_inplace(|value| value / norm);
1018 assignments
1019 .column_mut(atom_idx)
1020 .mapv_inplace(|value| value * norm);
1021 }
1022 orient_atom_and_code(atoms, assignments, atom_idx);
1023}
1024
1025fn orient_atom_and_code(atoms: &mut Array2<f64>, assignments: &mut Array2<f64>, atom_idx: usize) {
1026 let sign = first_nonzero_sign(atoms.row(atom_idx));
1027 if sign < 0.0 {
1028 atoms.row_mut(atom_idx).mapv_inplace(|value| -value);
1029 assignments
1030 .column_mut(atom_idx)
1031 .mapv_inplace(|value| -value);
1032 }
1033}
1034
1035fn orient_vector(vector: &mut Array1<f64>) {
1036 if first_nonzero_sign(vector.view()) < 0.0 {
1037 vector.mapv_inplace(|value| -value);
1038 }
1039}
1040
1041fn first_nonzero_sign(row: ndarray::ArrayView1<'_, f64>) -> f64 {
1042 for &value in row {
1043 if value.abs() > 1.0e-12 {
1044 return value.signum();
1045 }
1046 }
1047 1.0
1048}
1049
1050fn normalize_row(mut row: ndarray::ArrayViewMut1<'_, f64>) {
1051 let norm = row.dot(&row).sqrt();
1052 if norm > MIN_NORM2.sqrt() {
1053 row.mapv_inplace(|value| value / norm);
1054 }
1055}
1056
1057fn max_norm_row(x: ArrayView2<'_, f64>) -> usize {
1058 let mut best = 0usize;
1059 let mut best_norm = f64::NEG_INFINITY;
1060 for row in 0..x.nrows() {
1061 let norm = x.row(row).dot(&x.row(row));
1062 if norm > best_norm {
1063 best = row;
1064 best_norm = norm;
1065 }
1066 }
1067 best
1068}
1069
1070fn max_index(values: ndarray::ArrayView1<'_, f64>) -> usize {
1071 let mut best = 0usize;
1072 let mut best_value = f64::NEG_INFINITY;
1073 for idx in 0..values.len() {
1074 if values[idx] > best_value {
1075 best = idx;
1076 best_value = values[idx];
1077 }
1078 }
1079 best
1080}
1081
1082fn squared_distance(a: ndarray::ArrayView1<'_, f64>, b: ndarray::ArrayView1<'_, f64>) -> f64 {
1083 a.iter()
1084 .zip(b.iter())
1085 .map(|(av, bv)| {
1086 let diff = av - bv;
1087 diff * diff
1088 })
1089 .sum()
1090}
1091
1092fn explained_variance(x: ArrayView2<'_, f64>, fitted: ArrayView2<'_, f64>) -> f64 {
1093 let mut rss = 0.0;
1094 for row in 0..x.nrows() {
1095 for col in 0..x.ncols() {
1096 let residual = x[[row, col]] - fitted[[row, col]];
1097 rss += residual * residual;
1098 }
1099 }
1100 let means = x.mean_axis(Axis(0)).expect("non-empty input has means");
1101 let mut tss = 0.0;
1102 for row in 0..x.nrows() {
1103 for col in 0..x.ncols() {
1104 let centered = x[[row, col]] - means[col];
1105 tss += centered * centered;
1106 }
1107 }
1108 if tss <= MIN_NORM2 {
1109 if rss <= MIN_NORM2 { 1.0 } else { 0.0 }
1110 } else {
1111 1.0 - rss / tss
1112 }
1113}
1114
1115fn penalized_reconstruction_loss(
1116 x: ArrayView2<'_, f64>,
1117 fitted: ArrayView2<'_, f64>,
1118 ridge: f64,
1119 atoms: ArrayView2<'_, f64>,
1120) -> f64 {
1121 let mut loss = 0.0;
1122 for row in 0..x.nrows() {
1123 for col in 0..x.ncols() {
1124 let residual = x[[row, col]] - fitted[[row, col]];
1125 loss += residual * residual;
1126 }
1127 }
1128 loss + ridge * atoms.iter().map(|value| value * value).sum::<f64>()
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133 use super::*;
1134 use approx::assert_abs_diff_eq;
1135 use ndarray::{Array2, array};
1136
1137 #[test]
1138 fn planted_sparse_linear_dictionary_reaches_high_explained_variance() {
1139 let truth = array![
1140 [1.0, 0.0, 0.0, 0.0],
1141 [0.0, 1.0, 0.0, 0.0],
1142 [0.0, 0.0, 1.0, 0.0],
1143 [0.0, 0.0, 0.0, 1.0],
1144 ];
1145 let mut assignments = Array2::<f64>::zeros((160, 4));
1146 for row in 0..160 {
1147 let atom = row % 4;
1148 assignments[[row, atom]] = 0.7 + 0.01 * ((row / 4) as f64);
1149 assignments[[row, (atom + 1) % 4]] = 0.2;
1150 }
1151 let x = assignments.dot(&truth);
1152 let config = LinearDictionaryConfig {
1153 n_atoms: 4,
1154 max_iter: 40,
1155 top_k: 2,
1156 assignment: LinearDictionaryAssignment::TopK,
1157 temperature: DEFAULT_TEMPERATURE,
1158 code_ridge: DEFAULT_CODE_RIDGE,
1159 tolerance: 1.0e-9,
1160 center_rank_one: false,
1161 };
1162
1163 let fit = fit_linear_dictionary(x.view(), &config).expect("linear dictionary fit");
1164
1165 assert!(
1166 fit.explained_variance > 0.95,
1167 "expected EV > 0.95, got {}",
1168 fit.explained_variance
1169 );
1170 }
1171
1172 #[test]
1173 fn coupled_topk_dictionary_reaches_fixed_point_under_small_budget_2372() {
1174 let truth = array![
1186 [
1187 std::f64::consts::FRAC_1_SQRT_2,
1188 std::f64::consts::FRAC_1_SQRT_2,
1189 0.0,
1190 0.0,
1191 0.0,
1192 0.0
1193 ],
1194 [
1195 std::f64::consts::FRAC_1_SQRT_2,
1196 -std::f64::consts::FRAC_1_SQRT_2,
1197 0.0,
1198 0.0,
1199 0.0,
1200 0.0
1201 ],
1202 [
1203 0.0,
1204 0.0,
1205 std::f64::consts::FRAC_1_SQRT_2,
1206 std::f64::consts::FRAC_1_SQRT_2,
1207 0.0,
1208 0.0
1209 ],
1210 ];
1211 let mut codes = Array2::<f64>::zeros((120, 3));
1212 for row in 0..120 {
1213 let atom = row % 3;
1214 codes[[row, atom]] = 0.6 + 0.02 * ((row / 3) as f64);
1215 codes[[row, (atom + 1) % 3]] = 0.3;
1216 }
1217 let x = codes.dot(&truth);
1218 let config = LinearDictionaryConfig {
1219 n_atoms: 3,
1220 max_iter: 80,
1221 top_k: 2,
1222 assignment: LinearDictionaryAssignment::TopK,
1223 temperature: DEFAULT_TEMPERATURE,
1224 code_ridge: DEFAULT_CODE_RIDGE,
1225 tolerance: 1.0e-9,
1226 center_rank_one: false,
1227 };
1228
1229 let fit = fit_linear_dictionary(x.view(), &config)
1230 .expect("acceleration must reach the fixed point within the budget");
1231 assert!(
1232 fit.explained_variance > 0.999,
1233 "coupled data must reconstruct well at the converged fixed point, got EV {}",
1234 fit.explained_variance
1235 );
1236 assert!(
1237 fit.convergence.ev_residual <= fit.convergence.tolerance,
1238 "ev_residual {} must close the {} contract",
1239 fit.convergence.ev_residual,
1240 fit.convergence.tolerance
1241 );
1242 assert!(
1243 fit.convergence.routing_residual <= fit.convergence.tolerance,
1244 "routing_residual {} must close the {} contract",
1245 fit.convergence.routing_residual,
1246 fit.convergence.tolerance
1247 );
1248 assert_eq!(fit.convergence.accepted_births, 0);
1249 let canonical = reroute_against_atoms(x.view(), fit.atoms.view(), fit.top_k, &config)
1252 .expect("canonical reroute");
1253 for (returned, rerouted) in fit.assignments.iter().zip(canonical.iter()) {
1254 assert_abs_diff_eq!(*returned, *rerouted, epsilon = 1.0e-12);
1255 }
1256 }
1257
1258 #[test]
1259 fn single_atom_matches_penalized_pca_oracle() {
1260 let mut x = Array2::<f64>::zeros((80, 3));
1261 for row in 0..80 {
1262 let t = (row as f64 - 39.5) / 20.0;
1263 x[[row, 0]] = 2.0 * t;
1264 x[[row, 1]] = -t;
1265 x[[row, 2]] = 0.05 * (row as f64).sin();
1266 }
1267 let config = LinearDictionaryConfig {
1268 n_atoms: 1,
1269 max_iter: 5,
1270 top_k: 1,
1271 assignment: LinearDictionaryAssignment::TopK,
1272 temperature: DEFAULT_TEMPERATURE,
1273 code_ridge: DEFAULT_CODE_RIDGE,
1274 tolerance: DEFAULT_TOLERANCE,
1275 center_rank_one: false,
1276 };
1277
1278 let fit = fit_linear_dictionary(x.view(), &config).expect("rank-one fit");
1279 let covariance = x.t().dot(&x);
1280 let (evals, _) = covariance.eigh(Side::Lower).expect("PCA eigensolve");
1281 let shrink = 1.0 / (1.0 + DEFAULT_CODE_RIDGE);
1282 let oracle_ev = 1.0
1283 - ((1.0 - shrink) * (1.0 - shrink) * evals[evals.len() - 1]
1284 + evals.slice(s![..evals.len() - 1]).sum())
1285 / evals.sum();
1286
1287 assert!(fit.explained_variance > 0.99);
1288 assert_abs_diff_eq!(fit.explained_variance, oracle_ev, epsilon = 2.0e-4);
1289 }
1290
1291 #[test]
1292 fn orthonormal_rank_one_atoms_all_revived_no_dead_collapse_1500() {
1293 let (k, p, n) = (4usize, 8usize, 400usize);
1299 let mut a = Array2::<f64>::zeros((p, p));
1302 for i in 0..p {
1303 for j in 0..p {
1304 a[[i, j]] = ((i * 7 + j * 3 + 1) % 11) as f64 - 5.0;
1305 }
1306 }
1307 let sym = &a + &a.t();
1308 let (_evals, evecs) = sym.eigh(Side::Lower).expect("orthonormal directions");
1309 let dirs = evecs.slice(s![.., ..k]).t().to_owned(); let mut x = Array2::<f64>::zeros((n, p));
1311 for row in 0..n {
1312 let atom = row % k;
1313 let scale = if row % 2 == 0 { 2.0 } else { -1.5 } + 0.01 * (row / k) as f64;
1314 for col in 0..p {
1315 let noise = 1.0e-3 * (((row * p + col) % 13) as f64 - 6.0);
1316 x[[row, col]] = scale * dirs[[atom, col]] + noise;
1317 }
1318 }
1319 let config = LinearDictionaryConfig {
1320 n_atoms: k,
1321 max_iter: 40,
1322 top_k: 1,
1323 assignment: LinearDictionaryAssignment::TopK,
1324 temperature: DEFAULT_TEMPERATURE,
1325 code_ridge: DEFAULT_CODE_RIDGE,
1326 tolerance: 1.0e-9,
1327 center_rank_one: false,
1328 };
1329 let fit = fit_linear_dictionary(x.view(), &config).expect("orthonormal dictionary fit");
1330 let live = fit
1331 .atoms
1332 .axis_iter(Axis(0))
1333 .filter(|atom| atom.iter().any(|value| value.abs() > 1.0e-12))
1334 .count();
1335 assert_eq!(
1336 live, k,
1337 "all {k} atoms must stay live (no dead-atom collapse); got {live} live"
1338 );
1339 assert!(
1340 fit.explained_variance > 0.99,
1341 "K orthonormal rank-1 atoms must be reconstructed at EV > 0.99; got {}",
1342 fit.explained_variance
1343 );
1344 }
1345
1346 #[test]
1347 fn returned_state_is_the_certified_canonical_routing() {
1348 let truth = array![
1352 [1.0, 0.0, 0.0, 0.0],
1353 [0.0, 1.0, 0.0, 0.0],
1354 [0.0, 0.0, 1.0, 0.0],
1355 [0.0, 0.0, 0.0, 1.0],
1356 ];
1357 let mut assignments = Array2::<f64>::zeros((160, 4));
1358 for row in 0..160 {
1359 let atom = row % 4;
1360 assignments[[row, atom]] = 0.7 + 0.01 * ((row / 4) as f64);
1361 assignments[[row, (atom + 1) % 4]] = 0.2;
1362 }
1363 let x = assignments.dot(&truth);
1364 let config = LinearDictionaryConfig {
1365 n_atoms: 4,
1366 max_iter: 40,
1367 top_k: 2,
1368 assignment: LinearDictionaryAssignment::TopK,
1369 temperature: DEFAULT_TEMPERATURE,
1370 code_ridge: DEFAULT_CODE_RIDGE,
1371 tolerance: 1.0e-9,
1372 center_rank_one: false,
1373 };
1374
1375 let fit = fit_linear_dictionary(x.view(), &config).expect("linear dictionary fit");
1376 assert!(fit.convergence.ev_residual <= fit.convergence.tolerance);
1377 assert!(fit.convergence.routing_residual <= fit.convergence.tolerance);
1378 assert_eq!(fit.convergence.accepted_births, 0);
1379
1380 let canonical = reroute_against_atoms(x.view(), fit.atoms.view(), fit.top_k, &config)
1383 .expect("canonical reroute");
1384 for (returned, rerouted) in fit.assignments.iter().zip(canonical.iter()) {
1385 assert_abs_diff_eq!(*returned, *rerouted, epsilon = 1.0e-12);
1386 }
1387 let recomputed_fitted = fit.assignments.dot(&fit.atoms);
1388 for (a, b) in fit.fitted.iter().zip(recomputed_fitted.iter()) {
1389 assert_abs_diff_eq!(*a, *b, epsilon = 1.0e-10);
1390 }
1391 assert_abs_diff_eq!(
1392 fit.explained_variance,
1393 explained_variance(x.view(), fit.fitted.view()),
1394 epsilon = 1.0e-10
1395 );
1396 }
1397
1398 #[test]
1399 fn centered_rank_one_ceiling_agrees_when_data_already_centered() {
1400 let mut x = Array2::<f64>::zeros((90, 3));
1404 for row in 0..90 {
1405 let t = (row as f64 - 44.5) / 25.0;
1406 x[[row, 0]] = 1.5 * t;
1407 x[[row, 1]] = -0.8 * t + 0.02 * (row as f64).cos();
1408 x[[row, 2]] = 0.6 * t;
1409 }
1410 let means = x.mean_axis(Axis(0)).unwrap();
1411 let centered = &x - &means;
1412
1413 let config = LinearDictionaryConfig::new(1);
1414 let uncentered = fit_linear_dictionary(centered.view(), &config).expect("rank-one fit");
1415 let (_fitted, centered_ev) =
1416 rank_one_centered_pca_ceiling(centered.view(), DEFAULT_CODE_RIDGE)
1417 .expect("centered ceiling");
1418
1419 assert_abs_diff_eq!(uncentered.explained_variance, centered_ev, epsilon = 1.0e-9);
1420 }
1421
1422 #[test]
1423 fn centered_rank_one_ceiling_beats_uncentered_with_strong_mean() {
1424 let mut x = Array2::<f64>::zeros((120, 2));
1429 for row in 0..120 {
1430 let t = (row as f64 - 59.5) / 60.0; x[[row, 0]] = 50.0 + 0.3 * t;
1432 x[[row, 1]] = 50.0 - 0.3 * t;
1433 }
1434 let config = LinearDictionaryConfig::new(1);
1435 let uncentered = fit_linear_dictionary(x.view(), &config).expect("rank-one fit");
1436 let (fitted, centered_ev) =
1437 rank_one_centered_pca_ceiling(x.view(), DEFAULT_CODE_RIDGE).expect("centered ceiling");
1438
1439 assert!(
1440 centered_ev > uncentered.explained_variance + 1.0e-6,
1441 "centered ceiling ({centered_ev}) should beat uncentered lane ({}) on strong-mean data",
1442 uncentered.explained_variance
1443 );
1444 assert_abs_diff_eq!(
1446 centered_ev,
1447 explained_variance(x.view(), fitted.view()),
1448 epsilon = 1.0e-10
1449 );
1450 }
1451
1452 #[test]
1453 fn center_rank_one_config_flag_routes_k1_lane_to_centered_ceiling() {
1454 let mut x = Array2::<f64>::zeros((100, 3));
1459 for row in 0..100 {
1460 let t = (row as f64 - 49.5) / 50.0;
1461 x[[row, 0]] = 30.0 + 0.2 * t;
1462 x[[row, 1]] = 30.0 - 0.2 * t;
1463 x[[row, 2]] = 30.0 + 0.05 * t;
1464 }
1465
1466 let default_config = LinearDictionaryConfig::new(1);
1467 assert!(
1468 !default_config.center_rank_one,
1469 "flag must default to false"
1470 );
1471 let uncentered = fit_linear_dictionary(x.view(), &default_config).expect("uncentered lane");
1472
1473 let mut centered_config = LinearDictionaryConfig::new(1);
1474 centered_config.center_rank_one = true;
1475 let centered = fit_linear_dictionary(x.view(), ¢ered_config).expect("centered lane");
1476
1477 let (_fitted, helper_ev) =
1480 rank_one_centered_pca_ceiling(x.view(), DEFAULT_CODE_RIDGE).expect("helper ceiling");
1481 assert_abs_diff_eq!(centered.explained_variance, helper_ev, epsilon = 1.0e-10);
1482 assert!(
1483 centered.explained_variance > uncentered.explained_variance + 1.0e-6,
1484 "center_rank_one=true ({}) must beat default ({}) on strong-mean data",
1485 centered.explained_variance,
1486 uncentered.explained_variance
1487 );
1488 assert_abs_diff_eq!(
1492 centered.explained_variance,
1493 explained_variance(x.view(), centered.fitted.view()),
1494 epsilon = 1.0e-10
1495 );
1496 }
1497
1498 #[test]
1499 fn nonconverged_multi_atom_fit_is_an_error_not_a_model() {
1500 let truth = array![
1519 [
1520 std::f64::consts::FRAC_1_SQRT_2,
1521 std::f64::consts::FRAC_1_SQRT_2,
1522 0.0,
1523 0.0,
1524 0.0,
1525 0.0
1526 ],
1527 [
1528 std::f64::consts::FRAC_1_SQRT_2,
1529 -std::f64::consts::FRAC_1_SQRT_2,
1530 0.0,
1531 0.0,
1532 0.0,
1533 0.0
1534 ],
1535 [
1536 0.0,
1537 0.0,
1538 std::f64::consts::FRAC_1_SQRT_2,
1539 std::f64::consts::FRAC_1_SQRT_2,
1540 0.0,
1541 0.0
1542 ],
1543 ];
1544 let mut codes = Array2::<f64>::zeros((120, 3));
1545 for row in 0..120 {
1546 let atom = row % 3;
1547 codes[[row, atom]] = 0.6 + 0.02 * ((row / 3) as f64);
1548 codes[[row, (atom + 1) % 3]] = 0.3;
1549 }
1550 let x = codes.dot(&truth);
1551 let config = LinearDictionaryConfig {
1552 n_atoms: 3,
1553 max_iter: 2,
1554 top_k: 2,
1555 assignment: LinearDictionaryAssignment::TopK,
1556 temperature: DEFAULT_TEMPERATURE,
1557 code_ridge: DEFAULT_CODE_RIDGE,
1558 tolerance: DEFAULT_TOLERANCE,
1559 center_rank_one: false,
1560 };
1561 let err = fit_linear_dictionary(x.view(), &config)
1562 .expect_err("a still-moving iterate cannot certify an EV plateau");
1563 match err {
1564 LinearDictionaryError::NonConvergence {
1565 iterations,
1566 explained_variance,
1567 ev_residual,
1568 routing_residual,
1569 accepted_births,
1570 tolerance,
1571 } => {
1572 assert_eq!(iterations, 2);
1573 assert!(explained_variance.is_finite());
1574 assert!(ev_residual.is_finite());
1575 assert!(routing_residual.is_finite());
1576 assert!(
1580 ev_residual > tolerance || routing_residual > tolerance || accepted_births > 0,
1581 "fixture must still be moving: ev_residual {ev_residual:.3e}, \
1582 routing_residual {routing_residual:.3e}, births {accepted_births} \
1583 against tolerance {tolerance:.3e}"
1584 );
1585 assert_eq!(tolerance, DEFAULT_TOLERANCE);
1586 }
1587 other => panic!("expected typed non-convergence evidence, got: {other}"),
1588 }
1589 }
1590
1591 #[test]
1592 fn single_sweep_cannot_certify_an_initialization_already_at_the_fixed_point() {
1593 let mut x = Array2::<f64>::zeros((24, 3));
1608 for row in 0..24 {
1609 x[[row, row % 3]] = 1.0 + 0.01 * row as f64;
1610 }
1611 let mut config = LinearDictionaryConfig {
1612 n_atoms: 2,
1613 max_iter: 1,
1614 top_k: 1,
1615 assignment: LinearDictionaryAssignment::TopK,
1616 temperature: DEFAULT_TEMPERATURE,
1617 code_ridge: DEFAULT_CODE_RIDGE,
1618 tolerance: DEFAULT_TOLERANCE,
1619 center_rank_one: false,
1620 };
1621 let err = fit_linear_dictionary(x.view(), &config)
1622 .expect_err("a single sweep is one data point, not a plateau");
1623 match err {
1624 LinearDictionaryError::NonConvergence {
1625 iterations,
1626 explained_variance,
1627 ev_residual,
1628 routing_residual,
1629 accepted_births,
1630 tolerance,
1631 } => {
1632 assert_eq!(iterations, 1);
1633 assert!(explained_variance.is_finite());
1634 assert!(
1640 ev_residual <= tolerance,
1641 "seeded fixed point must agree on the first sweep, got ev_residual \
1642 {ev_residual:.3e} against tolerance {tolerance:.3e}"
1643 );
1644 assert!(
1645 routing_residual <= tolerance,
1646 "seeded fixed point must survive its own reroute, got routing_residual \
1647 {routing_residual:.3e} against tolerance {tolerance:.3e}"
1648 );
1649 assert_eq!(accepted_births, 0);
1650 assert_eq!(tolerance, DEFAULT_TOLERANCE);
1651 }
1652 other => panic!("expected typed non-convergence evidence, got: {other}"),
1653 }
1654
1655 config.max_iter = 2;
1658 let fit = fit_linear_dictionary(x.view(), &config)
1659 .expect("two agreeing sweeps certify the plateau");
1660 assert_eq!(fit.iterations, 2);
1661 assert!(fit.convergence.ev_residual <= fit.convergence.tolerance);
1662 assert!(fit.convergence.routing_residual <= fit.convergence.tolerance);
1663 assert_eq!(fit.convergence.accepted_births, 0);
1664 }
1665
1666 #[test]
1667 fn negative_convergence_tolerance_is_rejected() {
1668 let x = array![[1.0, 0.0], [0.0, 1.0]];
1669 let mut config = LinearDictionaryConfig::new(2);
1670 config.tolerance = -f64::EPSILON;
1671 let error = fit_linear_dictionary(x.view(), &config)
1672 .expect_err("a negative residual tolerance has no convergence meaning");
1673 assert!(matches!(error, LinearDictionaryError::InvalidInput { .. }));
1674 }
1675
1676 #[test]
1677 fn sparse_assignment_scales_to_thousand_atom_dictionary() {
1678 let active_atoms = array![
1679 [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1680 [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1681 [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1682 [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
1683 [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
1684 [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1685 [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
1686 [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
1687 ];
1688 let mut x = Array2::<f64>::zeros((256, 8));
1689 for row in 0..x.nrows() {
1690 let atom = row % active_atoms.nrows();
1691 let scale = 0.7 + 0.003 * row as f64;
1692 x.row_mut(row).assign(&(&active_atoms.row(atom) * scale));
1693 }
1694 let config = LinearDictionaryConfig {
1695 n_atoms: 1024,
1696 max_iter: 8,
1697 top_k: 1,
1698 assignment: LinearDictionaryAssignment::TopK,
1699 temperature: DEFAULT_TEMPERATURE,
1700 code_ridge: DEFAULT_CODE_RIDGE,
1701 tolerance: 1.0e-9,
1702 center_rank_one: false,
1703 };
1704
1705 let fit = fit_linear_dictionary(x.view(), &config).expect("large-K linear dictionary fit");
1706 let max_active = fit
1707 .assignments
1708 .axis_iter(Axis(0))
1709 .map(|row| row.iter().filter(|value| value.abs() > 1.0e-10).count())
1710 .max()
1711 .unwrap();
1712
1713 assert_eq!(max_active, 1);
1714 assert!(
1715 fit.explained_variance > 0.95,
1716 "expected EV > 0.95 at K=1024, got {}",
1717 fit.explained_variance
1718 );
1719 }
1720
1721 #[test]
1727 fn zz_measure_2372_dictionary_plateau_trace() {
1728 let (x, config) = planted_fixture_for_trace();
1729 let top_k = config.top_k.min(config.n_atoms).max(1);
1730 let mut atoms = initialize_atoms(x.view(), config.n_atoms);
1731 let mut assignments =
1732 reroute_against_atoms(x.view(), atoms.view(), top_k, &config).expect("route");
1733 let mut fitted = assignments.dot(&atoms);
1734 let mut lambdas = Array1::<f64>::from_elem(config.n_atoms, INACTIVE_LAMBDA);
1735 let mut reml_scores = Array1::<f64>::zeros(config.n_atoms);
1736 let initial_ev = explained_variance(x.view(), fitted.view());
1737 let mut previous_ev = initial_ev;
1738 let mut prev_support: Option<Vec<Vec<bool>>> = None;
1739 let mut observed_sweeps = 0usize;
1740 for sweep in 0..12 {
1741 for atom_idx in 0..config.n_atoms {
1742 fit_one_atom_penalized_ls(
1743 x.view(),
1744 &mut atoms,
1745 &mut assignments,
1746 &mut fitted,
1747 &mut lambdas,
1748 &mut reml_scores,
1749 atom_idx,
1750 config.code_ridge,
1751 )
1752 .expect("atom update");
1753 }
1754 let sweep_ev = explained_variance(x.view(), fitted.view());
1755 let rerouted =
1756 reroute_against_atoms(x.view(), atoms.view(), top_k, &config).expect("route");
1757 let rerouted_fitted = rerouted.dot(&atoms);
1758 let rerouted_ev = explained_variance(x.view(), rerouted_fitted.view());
1759 let support: Vec<Vec<bool>> = (0..rerouted.nrows())
1760 .map(|i| rerouted.row(i).iter().map(|v| *v != 0.0).collect())
1761 .collect();
1762 let support_changed = prev_support.as_ref().map_or(-1_i64, |p| {
1763 p.iter()
1764 .zip(&support)
1765 .map(|(a, b)| a.iter().zip(b).filter(|(x, y)| x != y).count())
1766 .sum::<usize>() as i64
1767 });
1768 eprintln!(
1769 "[zz2372:dict] sweep={sweep} sweep_ev={sweep_ev:.15} rerouted_ev={rerouted_ev:.15} ev_res={:.3e} routing_res={:.3e} support_flips={support_changed}",
1770 (rerouted_ev - previous_ev).abs(),
1771 (rerouted_ev - sweep_ev).abs(),
1772 );
1773 assert!(
1774 sweep_ev.is_finite() && rerouted_ev.is_finite(),
1775 "[zz2372:dict] sweep={sweep} produced a non-finite explained \
1776 variance: sweep_ev={sweep_ev} rerouted_ev={rerouted_ev}"
1777 );
1778 assert!(
1783 sweep_ev <= 1.0 + 1e-12 && rerouted_ev <= 1.0 + 1e-12,
1784 "[zz2372:dict] sweep={sweep} explained variance exceeded 1: \
1785 sweep_ev={sweep_ev} rerouted_ev={rerouted_ev}"
1786 );
1787 observed_sweeps += 1;
1788 previous_ev = rerouted_ev;
1789 prev_support = Some(support);
1790 assignments = rerouted;
1791 fitted = rerouted_fitted;
1792 }
1793 assert_eq!(
1812 observed_sweeps, 12,
1813 "the trace must record all twelve sweeps; a short loop would make \
1814 the per-sweep gates vacuous"
1815 );
1816 assert!(
1817 previous_ev >= initial_ev - 1e-12,
1818 "[zz2372:dict] twelve coordinate-descent sweeps left the fit WORSE \
1819 than initialization: initial_ev={initial_ev:.15} \
1820 final_ev={previous_ev:.15}"
1821 );
1822 }
1823
1824 fn planted_fixture_for_trace() -> (ndarray::Array2<f64>, LinearDictionaryConfig) {
1828 let truth = array![
1829 [1.0, 0.0, 0.0, 0.0],
1830 [0.0, 1.0, 0.0, 0.0],
1831 [0.0, 0.0, 1.0, 0.0],
1832 [0.0, 0.0, 0.0, 1.0],
1833 ];
1834 let mut assignments = Array2::<f64>::zeros((160, 4));
1835 for row in 0..160 {
1836 let atom = row % 4;
1837 assignments[[row, atom]] = 0.7 + 0.01 * ((row / 4) as f64);
1838 assignments[[row, (atom + 1) % 4]] = 0.2;
1839 }
1840 let x = assignments.dot(&truth);
1841 let config = LinearDictionaryConfig {
1842 n_atoms: 4,
1843 max_iter: 40,
1844 top_k: 2,
1845 assignment: LinearDictionaryAssignment::TopK,
1846 temperature: DEFAULT_TEMPERATURE,
1847 code_ridge: DEFAULT_CODE_RIDGE,
1848 tolerance: 1.0e-9,
1849 center_rank_one: false,
1850 };
1851
1852 (x, config)
1853 }
1854}