Skip to main content

gam_terms/decoders/
interchange_decoder.rs

1//! Per-feature scalar-gate decoder with masked interchange-swap variant.
2//!
3//! This primitive is not specific to any one front-end. It is callable from
4//! the `gam` Rust library directly, from the CLI (whenever a decoder
5//! interchange-intervention probe is needed), and from PyTorch via the
6//! `gam-pyffi` bindings. The intended use is *Distributed Alignment Search*
7//! (DAS, Geiger et al. CLeaR 2024): given two inputs `a` and `b`, transplant
8//! the latent atoms hypothesized to encode a causal variable from `a` into
9//! `b`, decode with shared reconstruction weights and a shared per-feature
10//! scalar gate, and back-propagate a swap-reconstruction error against a
11//! target. The closed-form forward and analytic gradients live here so the
12//! exact same arithmetic is used by every caller.
13//!
14//! Forward
15//! -------
16//! With latent `Z ∈ ℝ^{B×F}`, scalar gate `g ∈ ℝ^F`, decoder weights
17//! `W ∈ ℝ^{D×F}`, and optional bias `b ∈ ℝ^D`,
18//!
19//! ```text
20//! X̂[i, d] = Σ_f g[f] · Z[i, f] · W[d, f] + b[d]
21//! ```
22//!
23//! Masked interchange-swap forward composes the latent first,
24//!
25//! ```text
26//! Z_eff[i, f] = mask[f] ? Z_a[i, f] : Z_b[i, f],
27//! ```
28//!
29//! then runs the plain decode on `Z_eff`. The gate `g` and the weights `W`
30//! are SHARED between the two source decodings — only the latent activations
31//! are interchanged. The scalar gate is decoupled from the reconstruction
32//! matrix on purpose: that decoupling is what gives DAS a parameter to
33//! transplant.
34//!
35//! Backward
36//! --------
37//! From upstream `Ȳ = ∂L/∂X̂ ∈ ℝ^{B×D}`,
38//!
39//! ```text
40//! ∂L/∂Z[i, f] = g[f] · Σ_d Ȳ[i, d] · W[d, f]
41//! ∂L/∂g[f]   = Σ_i Z[i, f] · Σ_d Ȳ[i, d] · W[d, f]
42//! ∂L/∂W[d, f] = g[f] · Σ_i Ȳ[i, d] · Z[i, f]
43//! ∂L/∂b[d]   = Σ_i Ȳ[i, d]
44//! ```
45//!
46//! For the masked-swap path, `∂L/∂Z_a` keeps the columns where `mask[f]`
47//! is true (the rest are zero) and `∂L/∂Z_b` keeps the columns where
48//! `mask[f]` is false. All other adjoints (`∂L/∂g`, `∂L/∂W`, `∂L/∂b`)
49//! are computed from the composed `Z_eff` exactly as in the plain case.
50
51use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
52
53/// Inputs to the plain (non-swap) gated decode forward.
54#[derive(Debug, Clone, Copy)]
55pub struct InterchangeDecodeForward<'a> {
56    pub z: ArrayView2<'a, f64>,
57    pub weights: ArrayView2<'a, f64>,
58    pub gate: ArrayView1<'a, f64>,
59    pub bias: Option<ArrayView1<'a, f64>>,
60}
61
62/// Inputs to the masked-swap forward.
63#[derive(Debug, Clone, Copy)]
64pub struct InterchangeSwapForward<'a> {
65    pub z_a: ArrayView2<'a, f64>,
66    pub z_b: ArrayView2<'a, f64>,
67    pub mask: ArrayView1<'a, bool>,
68    pub weights: ArrayView2<'a, f64>,
69    pub gate: ArrayView1<'a, f64>,
70    pub bias: Option<ArrayView1<'a, f64>>,
71}
72
73/// Adjoints returned by the plain backward.
74#[derive(Debug, Clone)]
75pub struct InterchangeDecodeBackward {
76    pub grad_z: Array2<f64>,
77    pub grad_weights: Array2<f64>,
78    pub grad_gate: Array1<f64>,
79    pub grad_bias: Option<Array1<f64>>,
80}
81
82/// Adjoints returned by the masked-swap backward.
83#[derive(Debug, Clone)]
84pub struct InterchangeSwapBackward {
85    pub grad_z_a: Array2<f64>,
86    pub grad_z_b: Array2<f64>,
87    pub grad_weights: Array2<f64>,
88    pub grad_gate: Array1<f64>,
89    pub grad_bias: Option<Array1<f64>>,
90}
91
92fn check_shapes_forward(
93    z_rows: usize,
94    z_cols: usize,
95    weights: ArrayView2<'_, f64>,
96    gate: ArrayView1<'_, f64>,
97    bias: Option<ArrayView1<'_, f64>>,
98) -> Result<(), String> {
99    let (d, f_weights) = weights.dim();
100    if f_weights != z_cols {
101        return Err(format!(
102            "interchange_decode: weights has F={f_weights}, expected {z_cols}"
103        ));
104    }
105    if gate.len() != z_cols {
106        return Err(format!(
107            "interchange_decode: gate has length {}, expected {z_cols}",
108            gate.len()
109        ));
110    }
111    if let Some(b) = bias
112        && b.len() != d
113    {
114        return Err(format!(
115            "interchange_decode: bias has length {}, expected D={d}",
116            b.len()
117        ));
118    }
119    if z_rows == 0 || z_cols == 0 {
120        return Err("interchange_decode: latent must be non-empty".to_string());
121    }
122    if !weights.iter().all(|v| v.is_finite()) {
123        return Err("interchange_decode: weights must be finite".to_string());
124    }
125    if !gate.iter().all(|v| v.is_finite()) {
126        return Err("interchange_decode: gate must be finite".to_string());
127    }
128    if let Some(b) = bias
129        && !b.iter().all(|v| v.is_finite())
130    {
131        return Err("interchange_decode: bias must be finite".to_string());
132    }
133    Ok(())
134}
135
136/// Plain gated decode: `X̂[i, d] = Σ_f g[f] · Z[i, f] · W[d, f] + b[d]`.
137pub fn interchange_decode_forward(
138    inputs: InterchangeDecodeForward<'_>,
139) -> Result<Array2<f64>, String> {
140    let (b_rows, f) = inputs.z.dim();
141    check_shapes_forward(b_rows, f, inputs.weights, inputs.gate, inputs.bias)?;
142    if !inputs.z.iter().all(|v| v.is_finite()) {
143        return Err("interchange_decode: latent must be finite".to_string());
144    }
145
146    let d = inputs.weights.nrows();
147    let mut z_gated = Array2::<f64>::zeros((b_rows, f));
148    for i in 0..b_rows {
149        for j in 0..f {
150            z_gated[[i, j]] = inputs.z[[i, j]] * inputs.gate[j];
151        }
152    }
153    // out = z_gated · Wᵀ
154    let mut out = z_gated.dot(&inputs.weights.t());
155    if let Some(bias) = inputs.bias {
156        for i in 0..b_rows {
157            for k in 0..d {
158                out[[i, k]] += bias[k];
159            }
160        }
161    }
162    Ok(out)
163}
164
165/// Masked-swap forward.
166pub fn interchange_swap_forward(inputs: InterchangeSwapForward<'_>) -> Result<Array2<f64>, String> {
167    if inputs.z_a.dim() != inputs.z_b.dim() {
168        return Err(format!(
169            "interchange_swap: z_a {:?} and z_b {:?} must have the same shape",
170            inputs.z_a.dim(),
171            inputs.z_b.dim()
172        ));
173    }
174    let (b_rows, f) = inputs.z_a.dim();
175    if inputs.mask.len() != f {
176        return Err(format!(
177            "interchange_swap: mask length {} must equal F={f}",
178            inputs.mask.len()
179        ));
180    }
181    if !inputs.z_a.iter().all(|v| v.is_finite()) || !inputs.z_b.iter().all(|v| v.is_finite()) {
182        return Err("interchange_swap: latents must be finite".to_string());
183    }
184    let mut z_eff = Array2::<f64>::zeros((b_rows, f));
185    for j in 0..f {
186        let take_a = inputs.mask[j];
187        if take_a {
188            for i in 0..b_rows {
189                z_eff[[i, j]] = inputs.z_a[[i, j]];
190            }
191        } else {
192            for i in 0..b_rows {
193                z_eff[[i, j]] = inputs.z_b[[i, j]];
194            }
195        }
196    }
197    interchange_decode_forward(InterchangeDecodeForward {
198        z: z_eff.view(),
199        weights: inputs.weights,
200        gate: inputs.gate,
201        bias: inputs.bias,
202    })
203}
204
205/// Backward for the plain decode. `grad_out` is `∂L/∂X̂`.
206pub fn interchange_decode_backward(
207    z: ArrayView2<'_, f64>,
208    weights: ArrayView2<'_, f64>,
209    gate: ArrayView1<'_, f64>,
210    grad_out: ArrayView2<'_, f64>,
211    with_bias: bool,
212) -> Result<InterchangeDecodeBackward, String> {
213    let (b_rows, f) = z.dim();
214    let (d, f_w) = weights.dim();
215    if f_w != f {
216        return Err(format!(
217            "interchange_decode_backward: weights has F={f_w}, expected {f}"
218        ));
219    }
220    if gate.len() != f {
221        return Err(format!(
222            "interchange_decode_backward: gate length {} != F={f}",
223            gate.len()
224        ));
225    }
226    if grad_out.dim() != (b_rows, d) {
227        return Err(format!(
228            "interchange_decode_backward: grad_out shape {:?} != ({b_rows}, {d})",
229            grad_out.dim()
230        ));
231    }
232
233    // Working term: G[i, f] = Σ_d grad_out[i, d] · W[d, f]   ( = grad_out · W )
234    let g_mat = grad_out.dot(&weights); // (B, F)
235
236    // ∂L/∂Z[i, f] = g[f] · G[i, f]
237    let mut grad_z = Array2::<f64>::zeros((b_rows, f));
238    for i in 0..b_rows {
239        for j in 0..f {
240            grad_z[[i, j]] = gate[j] * g_mat[[i, j]];
241        }
242    }
243
244    // ∂L/∂g[f] = Σ_i Z[i, f] · G[i, f]
245    let mut grad_gate = Array1::<f64>::zeros(f);
246    for j in 0..f {
247        let mut acc = 0.0;
248        for i in 0..b_rows {
249            acc += z[[i, j]] * g_mat[[i, j]];
250        }
251        grad_gate[j] = acc;
252    }
253
254    // ∂L/∂W[d, f] = g[f] · Σ_i grad_out[i, d] · Z[i, f]
255    //             = g[f] · (grad_outᵀ · Z)[d, f]
256    let mut grad_weights = grad_out.t().dot(&z); // (D, F)
257    for j in 0..f {
258        let scale = gate[j];
259        for k in 0..d {
260            grad_weights[[k, j]] *= scale;
261        }
262    }
263
264    let grad_bias = if with_bias {
265        let mut gb = Array1::<f64>::zeros(d);
266        for i in 0..b_rows {
267            for k in 0..d {
268                gb[k] += grad_out[[i, k]];
269            }
270        }
271        Some(gb)
272    } else {
273        None
274    };
275
276    Ok(InterchangeDecodeBackward {
277        grad_z,
278        grad_weights,
279        grad_gate,
280        grad_bias,
281    })
282}
283
284/// Backward for the masked-swap variant.
285pub fn interchange_swap_backward(
286    z_a: ArrayView2<'_, f64>,
287    z_b: ArrayView2<'_, f64>,
288    mask: ArrayView1<'_, bool>,
289    weights: ArrayView2<'_, f64>,
290    gate: ArrayView1<'_, f64>,
291    grad_out: ArrayView2<'_, f64>,
292    with_bias: bool,
293) -> Result<InterchangeSwapBackward, String> {
294    if z_a.dim() != z_b.dim() {
295        return Err(format!(
296            "interchange_swap_backward: z_a {:?} and z_b {:?} must have the same shape",
297            z_a.dim(),
298            z_b.dim()
299        ));
300    }
301    let (b_rows, f) = z_a.dim();
302    if mask.len() != f {
303        return Err(format!(
304            "interchange_swap_backward: mask length {} != F={f}",
305            mask.len()
306        ));
307    }
308
309    // Build z_eff and reuse the plain backward.
310    let mut z_eff = Array2::<f64>::zeros((b_rows, f));
311    for j in 0..f {
312        let take_a = mask[j];
313        if take_a {
314            for i in 0..b_rows {
315                z_eff[[i, j]] = z_a[[i, j]];
316            }
317        } else {
318            for i in 0..b_rows {
319                z_eff[[i, j]] = z_b[[i, j]];
320            }
321        }
322    }
323    let inner = interchange_decode_backward(z_eff.view(), weights, gate, grad_out, with_bias)?;
324
325    // Distribute ∂L/∂Z_eff to ∂L/∂Z_a / ∂L/∂Z_b along the mask.
326    let mut grad_z_a = Array2::<f64>::zeros((b_rows, f));
327    let mut grad_z_b = Array2::<f64>::zeros((b_rows, f));
328    for j in 0..f {
329        let take_a = mask[j];
330        if take_a {
331            for i in 0..b_rows {
332                grad_z_a[[i, j]] = inner.grad_z[[i, j]];
333            }
334        } else {
335            for i in 0..b_rows {
336                grad_z_b[[i, j]] = inner.grad_z[[i, j]];
337            }
338        }
339    }
340
341    Ok(InterchangeSwapBackward {
342        grad_z_a,
343        grad_z_b,
344        grad_weights: inner.grad_weights,
345        grad_gate: inner.grad_gate,
346        grad_bias: inner.grad_bias,
347    })
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use ndarray::{Array1, Array2, array};
354
355    fn approx_eq(a: &Array2<f64>, b: &Array2<f64>, tol: f64) -> bool {
356        if a.dim() != b.dim() {
357            return false;
358        }
359        a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < tol)
360    }
361
362    #[test]
363    fn forward_matches_hand_recomputation() {
364        let z = array![[1.0, -2.0, 0.5], [0.0, 3.0, -1.0]];
365        let w = array![[0.1, 0.2, 0.3], [-0.4, 0.5, 0.6]];
366        let g = array![1.0, 0.5, -1.0];
367        let bias = array![0.01, -0.02];
368        let out = interchange_decode_forward(InterchangeDecodeForward {
369            z: z.view(),
370            weights: w.view(),
371            gate: g.view(),
372            bias: Some(bias.view()),
373        })
374        .unwrap();
375        // expected row i, col k: Σ_f g[f] z[i,f] w[k,f] + bias[k]
376        let mut expected = Array2::<f64>::zeros((2, 2));
377        for i in 0..2 {
378            for k in 0..2 {
379                let mut acc = bias[k];
380                for j in 0..3 {
381                    acc += g[j] * z[[i, j]] * w[[k, j]];
382                }
383                expected[[i, k]] = acc;
384            }
385        }
386        assert!(approx_eq(&out, &expected, 1e-12));
387    }
388
389    #[test]
390    fn swap_all_true_matches_z_a_forward() {
391        let z_a = array![[1.0, -2.0], [3.0, 0.5]];
392        let z_b = array![[10.0, 20.0], [-30.0, 40.0]];
393        let w = array![[0.1, 0.2], [0.3, -0.4], [0.5, 0.6]];
394        let g = array![0.7, -0.3];
395        let mask = Array1::from(vec![true, true]);
396        let swapped = interchange_swap_forward(InterchangeSwapForward {
397            z_a: z_a.view(),
398            z_b: z_b.view(),
399            mask: mask.view(),
400            weights: w.view(),
401            gate: g.view(),
402            bias: None,
403        })
404        .unwrap();
405        let plain = interchange_decode_forward(InterchangeDecodeForward {
406            z: z_a.view(),
407            weights: w.view(),
408            gate: g.view(),
409            bias: None,
410        })
411        .unwrap();
412        assert!(approx_eq(&swapped, &plain, 1e-12));
413    }
414
415    #[test]
416    fn swap_all_false_matches_z_b_forward() {
417        let z_a = array![[1.0, -2.0], [3.0, 0.5]];
418        let z_b = array![[10.0, 20.0], [-30.0, 40.0]];
419        let w = array![[0.1, 0.2], [0.3, -0.4]];
420        let g = array![0.7, -0.3];
421        let mask = Array1::from(vec![false, false]);
422        let swapped = interchange_swap_forward(InterchangeSwapForward {
423            z_a: z_a.view(),
424            z_b: z_b.view(),
425            mask: mask.view(),
426            weights: w.view(),
427            gate: g.view(),
428            bias: None,
429        })
430        .unwrap();
431        let plain = interchange_decode_forward(InterchangeDecodeForward {
432            z: z_b.view(),
433            weights: w.view(),
434            gate: g.view(),
435            bias: None,
436        })
437        .unwrap();
438        assert!(approx_eq(&swapped, &plain, 1e-12));
439    }
440
441    #[test]
442    fn backward_matches_finite_differences() {
443        let z = array![[0.4, -0.7, 1.1], [0.2, 0.8, -0.3]];
444        let w = array![[0.1, 0.2, 0.3], [-0.4, 0.5, 0.6]];
445        let g = array![0.6, -0.2, 1.3];
446        let bias = array![0.05, -0.01];
447        let grad_out = array![[1.0, -0.5], [0.3, 0.8]];
448
449        let an = interchange_decode_backward(z.view(), w.view(), g.view(), grad_out.view(), true)
450            .unwrap();
451
452        // L = sum(grad_out * forward(z, w, g, bias))
453        // ∂L/∂z[i,j] via finite differences
454        let eps = 1e-6;
455        for i in 0..z.nrows() {
456            for j in 0..z.ncols() {
457                let mut zp = z.clone();
458                let mut zm = z.clone();
459                zp[[i, j]] += eps;
460                zm[[i, j]] -= eps;
461                let fp = interchange_decode_forward(InterchangeDecodeForward {
462                    z: zp.view(),
463                    weights: w.view(),
464                    gate: g.view(),
465                    bias: Some(bias.view()),
466                })
467                .unwrap();
468                let fm = interchange_decode_forward(InterchangeDecodeForward {
469                    z: zm.view(),
470                    weights: w.view(),
471                    gate: g.view(),
472                    bias: Some(bias.view()),
473                })
474                .unwrap();
475                let lp: f64 = fp.iter().zip(grad_out.iter()).map(|(a, b)| a * b).sum();
476                let lm: f64 = fm.iter().zip(grad_out.iter()).map(|(a, b)| a * b).sum();
477                let fd = (lp - lm) / (2.0 * eps);
478                assert!(
479                    (an.grad_z[[i, j]] - fd).abs() < 1e-7,
480                    "grad_z mismatch at ({i},{j}): analytic {} vs fd {}",
481                    an.grad_z[[i, j]],
482                    fd
483                );
484            }
485        }
486        // ∂L/∂g[j]
487        for j in 0..g.len() {
488            let mut gp = g.clone();
489            let mut gm = g.clone();
490            gp[j] += eps;
491            gm[j] -= eps;
492            let fp = interchange_decode_forward(InterchangeDecodeForward {
493                z: z.view(),
494                weights: w.view(),
495                gate: gp.view(),
496                bias: Some(bias.view()),
497            })
498            .unwrap();
499            let fm = interchange_decode_forward(InterchangeDecodeForward {
500                z: z.view(),
501                weights: w.view(),
502                gate: gm.view(),
503                bias: Some(bias.view()),
504            })
505            .unwrap();
506            let lp: f64 = fp.iter().zip(grad_out.iter()).map(|(a, b)| a * b).sum();
507            let lm: f64 = fm.iter().zip(grad_out.iter()).map(|(a, b)| a * b).sum();
508            let fd = (lp - lm) / (2.0 * eps);
509            assert!(
510                (an.grad_gate[j] - fd).abs() < 1e-7,
511                "grad_gate mismatch at {j}: analytic {} vs fd {}",
512                an.grad_gate[j],
513                fd
514            );
515        }
516        // ∂L/∂W[d, j]
517        for d in 0..w.nrows() {
518            for j in 0..w.ncols() {
519                let mut wp = w.clone();
520                let mut wm = w.clone();
521                wp[[d, j]] += eps;
522                wm[[d, j]] -= eps;
523                let fp = interchange_decode_forward(InterchangeDecodeForward {
524                    z: z.view(),
525                    weights: wp.view(),
526                    gate: g.view(),
527                    bias: Some(bias.view()),
528                })
529                .unwrap();
530                let fm = interchange_decode_forward(InterchangeDecodeForward {
531                    z: z.view(),
532                    weights: wm.view(),
533                    gate: g.view(),
534                    bias: Some(bias.view()),
535                })
536                .unwrap();
537                let lp: f64 = fp.iter().zip(grad_out.iter()).map(|(a, b)| a * b).sum();
538                let lm: f64 = fm.iter().zip(grad_out.iter()).map(|(a, b)| a * b).sum();
539                let fd = (lp - lm) / (2.0 * eps);
540                assert!(
541                    (an.grad_weights[[d, j]] - fd).abs() < 1e-7,
542                    "grad_W mismatch at ({d},{j}): analytic {} vs fd {}",
543                    an.grad_weights[[d, j]],
544                    fd
545                );
546            }
547        }
548        // ∂L/∂bias[d]
549        let bias_grad = an.grad_bias.as_ref().unwrap();
550        for d in 0..bias.len() {
551            let mut bp = bias.clone();
552            let mut bm = bias.clone();
553            bp[d] += eps;
554            bm[d] -= eps;
555            let fp = interchange_decode_forward(InterchangeDecodeForward {
556                z: z.view(),
557                weights: w.view(),
558                gate: g.view(),
559                bias: Some(bp.view()),
560            })
561            .unwrap();
562            let fm = interchange_decode_forward(InterchangeDecodeForward {
563                z: z.view(),
564                weights: w.view(),
565                gate: g.view(),
566                bias: Some(bm.view()),
567            })
568            .unwrap();
569            let lp: f64 = fp.iter().zip(grad_out.iter()).map(|(a, b)| a * b).sum();
570            let lm: f64 = fm.iter().zip(grad_out.iter()).map(|(a, b)| a * b).sum();
571            let fd = (lp - lm) / (2.0 * eps);
572            assert!(
573                (bias_grad[d] - fd).abs() < 1e-7,
574                "grad_bias mismatch at {d}: analytic {} vs fd {}",
575                bias_grad[d],
576                fd
577            );
578        }
579    }
580
581    #[test]
582    fn swap_backward_routes_grad_through_mask() {
583        let z_a = array![[1.0, 2.0, 3.0]];
584        let z_b = array![[-1.0, -2.0, -3.0]];
585        let w = array![[0.5, 0.25, -0.1]];
586        let g = array![1.0, 0.5, -1.0];
587        let mask = Array1::from(vec![true, false, true]);
588        let grad_out = array![[1.0]];
589        let bk = interchange_swap_backward(
590            z_a.view(),
591            z_b.view(),
592            mask.view(),
593            w.view(),
594            g.view(),
595            grad_out.view(),
596            false,
597        )
598        .unwrap();
599        // For j in {0, 2} (mask true): grad_z_a[0, j] = g[j] * w[0, j]; grad_z_b[0, j] = 0
600        // For j=1 (mask false): grad_z_b[0, 1] = g[1] * w[0, 1]; grad_z_a[0, 1] = 0
601        assert!((bk.grad_z_a[[0, 0]] - 1.0 * 0.5).abs() < 1e-12);
602        assert!((bk.grad_z_a[[0, 1]] - 0.0).abs() < 1e-12);
603        assert!((bk.grad_z_a[[0, 2]] - (-1.0) * (-0.1)).abs() < 1e-12);
604        assert!((bk.grad_z_b[[0, 0]] - 0.0).abs() < 1e-12);
605        assert!((bk.grad_z_b[[0, 1]] - 0.5 * 0.25).abs() < 1e-12);
606        assert!((bk.grad_z_b[[0, 2]] - 0.0).abs() < 1e-12);
607    }
608}