Skip to main content

scirs2_autograd/tensor_ops/
matrix_trig_functions.rs

1use crate::op::{ComputeContext, GradientContext, Op, OpError};
2use crate::tensor::Tensor;
3use crate::tensor_ops::matrix_calculus::{
4    self, MatrixFnKind, MatrixFnVjpOp, ScalarFnSymmetricVjpOp,
5};
6use crate::Float;
7use scirs2_core::ndarray::{Array1, Array2, Ix2};
8use scirs2_core::numeric::FromPrimitive;
9
10/// Builds the backward node of a matrix function: `MatrixFnVjp(A, gy)`.
11///
12/// Every op in this module is a *matrix* function `f(A)`, not an element-wise one, so its
13/// VJP is the adjoint of the Fréchet derivative `L_f(A, .)` applied to the cotangent —
14/// evaluated as the top-right block of `f([[Aᵀ, gy], [0, Aᵀ]])`.
15///
16/// The rules replaced here were element-wise: `sin(A)` used `cos(A) * gy` entry by entry,
17/// which is the chain rule for `mapv(sin)`, a completely different function. `sign(A)` and
18/// `funm` simply returned the cotangent unchanged.
19fn append_matrix_fn_grad<F: Float>(ctx: &mut GradientContext<F>, kind: MatrixFnKind) {
20    let a = *ctx.input(0);
21    let gy = *ctx.output_grad();
22    let g = ctx.graph();
23    let gx = Tensor::builder(g)
24        .append_input(a, false)
25        .append_input(gy, false)
26        .build(MatrixFnVjpOp { kind });
27    ctx.append_input_grad(0, Some(gx));
28}
29
30/// Matrix sine function
31pub struct MatrixSineOp;
32
33impl<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive> Op<F> for MatrixSineOp {
34    fn name(&self) -> &'static str {
35        "MatrixSine"
36    }
37
38    fn compute(&self, ctx: &mut ComputeContext<F>) -> Result<(), OpError> {
39        let input = ctx.input(0);
40        let shape = input.shape();
41
42        if shape.len() != 2 || shape[0] != shape[1] {
43            return Err(OpError::IncompatibleShape(
44                "Matrix sine requires square matrix".into(),
45            ));
46        }
47
48        let input_2d = input
49            .view()
50            .into_dimensionality::<Ix2>()
51            .map_err(|_| OpError::IncompatibleShape("Failed to convert to 2D".into()))?;
52
53        let result = compute_matrix_sine(&input_2d)?;
54        ctx.append_output(result.into_dyn());
55        Ok(())
56    }
57
58    fn grad(&self, ctx: &mut GradientContext<F>) {
59        append_matrix_fn_grad(ctx, MatrixFnKind::Sin);
60    }
61}
62
63/// Matrix cosine function
64pub struct MatrixCosineOp;
65
66impl<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive> Op<F> for MatrixCosineOp {
67    fn name(&self) -> &'static str {
68        "MatrixCosine"
69    }
70
71    fn compute(&self, ctx: &mut ComputeContext<F>) -> Result<(), OpError> {
72        let input = ctx.input(0);
73        let shape = input.shape();
74
75        if shape.len() != 2 || shape[0] != shape[1] {
76            return Err(OpError::IncompatibleShape(
77                "Matrix cosine requires square matrix".into(),
78            ));
79        }
80
81        let input_2d = input
82            .view()
83            .into_dimensionality::<Ix2>()
84            .map_err(|_| OpError::IncompatibleShape("Failed to convert to 2D".into()))?;
85
86        let result = compute_matrix_cosine(&input_2d)?;
87        ctx.append_output(result.into_dyn());
88        Ok(())
89    }
90
91    fn grad(&self, ctx: &mut GradientContext<F>) {
92        append_matrix_fn_grad(ctx, MatrixFnKind::Cos);
93    }
94}
95
96/// Matrix sign function
97pub struct MatrixSignOp;
98
99impl<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive> Op<F> for MatrixSignOp {
100    fn name(&self) -> &'static str {
101        "MatrixSign"
102    }
103
104    fn compute(&self, ctx: &mut ComputeContext<F>) -> Result<(), OpError> {
105        let input = ctx.input(0);
106        let shape = input.shape();
107
108        if shape.len() != 2 || shape[0] != shape[1] {
109            return Err(OpError::IncompatibleShape(
110                "Matrix sign requires square matrix".into(),
111            ));
112        }
113
114        let input_2d = input
115            .view()
116            .into_dimensionality::<Ix2>()
117            .map_err(|_| OpError::IncompatibleShape("Failed to convert to 2D".into()))?;
118
119        let result = compute_matrix_sign(&input_2d)?;
120        ctx.append_output(result.into_dyn());
121        Ok(())
122    }
123
124    fn grad(&self, ctx: &mut GradientContext<F>) {
125        // `sign` is piecewise constant *along the real axis*, but sign(A) is not a
126        // constant function of the matrix A: rotating the invariant subspaces moves it.
127        // The Fréchet derivative is the solution of the Sylvester equation
128        // `S L + L S = E - S E S`, obtained here through the same block-matrix trick
129        // (the Newton iteration preserves block-triangular structure).
130        append_matrix_fn_grad(ctx, MatrixFnKind::Sign);
131    }
132}
133
134/// Matrix hyperbolic sine function
135pub struct MatrixSinhOp;
136
137impl<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive> Op<F> for MatrixSinhOp {
138    fn name(&self) -> &'static str {
139        "MatrixSinh"
140    }
141
142    fn compute(&self, ctx: &mut ComputeContext<F>) -> Result<(), OpError> {
143        let input = ctx.input(0);
144        let shape = input.shape();
145
146        if shape.len() != 2 || shape[0] != shape[1] {
147            return Err(OpError::IncompatibleShape(
148                "Matrix sinh requires square matrix".into(),
149            ));
150        }
151
152        let input_2d = input
153            .view()
154            .into_dimensionality::<Ix2>()
155            .map_err(|_| OpError::IncompatibleShape("Failed to convert to 2D".into()))?;
156
157        let result = compute_matrix_sinh(&input_2d)?;
158        ctx.append_output(result.into_dyn());
159        Ok(())
160    }
161
162    fn grad(&self, ctx: &mut GradientContext<F>) {
163        append_matrix_fn_grad(ctx, MatrixFnKind::Sinh);
164    }
165}
166
167/// Matrix hyperbolic cosine function
168pub struct MatrixCoshOp;
169
170impl<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive> Op<F> for MatrixCoshOp {
171    fn name(&self) -> &'static str {
172        "MatrixCosh"
173    }
174
175    fn compute(&self, ctx: &mut ComputeContext<F>) -> Result<(), OpError> {
176        let input = ctx.input(0);
177        let shape = input.shape();
178
179        if shape.len() != 2 || shape[0] != shape[1] {
180            return Err(OpError::IncompatibleShape(
181                "Matrix cosh requires square matrix".into(),
182            ));
183        }
184
185        let input_2d = input
186            .view()
187            .into_dimensionality::<Ix2>()
188            .map_err(|_| OpError::IncompatibleShape("Failed to convert to 2D".into()))?;
189
190        let result = compute_matrix_cosh(&input_2d)?;
191        ctx.append_output(result.into_dyn());
192        Ok(())
193    }
194
195    fn grad(&self, ctx: &mut GradientContext<F>) {
196        append_matrix_fn_grad(ctx, MatrixFnKind::Cosh);
197    }
198}
199
200/// General matrix function using eigendecomposition
201pub struct MatrixFunctionOp<F: Float> {
202    function: fn(F) -> F,
203    name: &'static str,
204}
205
206impl<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive> Op<F> for MatrixFunctionOp<F> {
207    fn name(&self) -> &'static str {
208        self.name
209    }
210
211    fn compute(&self, ctx: &mut ComputeContext<F>) -> Result<(), OpError> {
212        let input = ctx.input(0);
213        let shape = input.shape();
214
215        if shape.len() != 2 || shape[0] != shape[1] {
216            return Err(OpError::IncompatibleShape(
217                "Matrix function requires square matrix".into(),
218            ));
219        }
220
221        let input_2d = input
222            .view()
223            .into_dimensionality::<Ix2>()
224            .map_err(|_| OpError::IncompatibleShape("Failed to convert to 2D".into()))?;
225
226        let result = compute_matrix_function(&input_2d, self.function)?;
227        ctx.append_output(result.into_dyn());
228        Ok(())
229    }
230
231    fn grad(&self, ctx: &mut GradientContext<F>) {
232        // `funm` applies an arbitrary scalar `fn(F) -> F` through the spectrum, so the
233        // Fréchet derivative is the Daleckii-Krein formula built from divided differences
234        // of that scalar function.  See `matrix_calculus::scalar_fn_symmetric_vjp`.
235        let a = *ctx.input(0);
236        let gy = *ctx.output_grad();
237        let g = ctx.graph();
238        let gx = Tensor::builder(g)
239            .append_input(a, false)
240            .append_input(gy, false)
241            .build(ScalarFnSymmetricVjpOp {
242                function: self.function,
243            });
244        ctx.append_input_grad(0, Some(gx));
245    }
246}
247
248// Helper functions
249
250/// Matrix sine `sin(A)`.
251///
252/// Scaling plus double-angle recovery, shared with the backward pass so that forward and
253/// gradient are consistent by construction (see
254/// [`crate::tensor_ops::matrix_calculus::sin_cos_m`]).
255#[allow(dead_code)]
256fn compute_matrix_sine<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
257    matrix: &scirs2_core::ndarray::ArrayView2<F>,
258) -> Result<Array2<F>, OpError> {
259    matrix_calculus::sin_cos_m(matrix).map(|(sin, _)| sin)
260}
261
262/// Matrix cosine `cos(A)`.
263#[allow(dead_code)]
264fn compute_matrix_cosine<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
265    matrix: &scirs2_core::ndarray::ArrayView2<F>,
266) -> Result<Array2<F>, OpError> {
267    matrix_calculus::sin_cos_m(matrix).map(|(_, cos)| cos)
268}
269
270/// Matrix sign function, by the Newton iteration `X <- (X + X^-1) / 2`.
271#[allow(dead_code)]
272fn compute_matrix_sign<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
273    matrix: &scirs2_core::ndarray::ArrayView2<F>,
274) -> Result<Array2<F>, OpError> {
275    matrix_calculus::signm(matrix)
276}
277
278/// Matrix hyperbolic sine `sinh(A) = (exp(A) - exp(-A)) / 2`.
279#[allow(dead_code)]
280fn compute_matrix_sinh<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
281    matrix: &scirs2_core::ndarray::ArrayView2<F>,
282) -> Result<Array2<F>, OpError> {
283    matrix_calculus::sinh_cosh_m(matrix).map(|(sinh, _)| sinh)
284}
285
286/// Matrix hyperbolic cosine `cosh(A) = (exp(A) + exp(-A)) / 2`.
287#[allow(dead_code)]
288fn compute_matrix_cosh<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
289    matrix: &scirs2_core::ndarray::ArrayView2<F>,
290) -> Result<Array2<F>, OpError> {
291    matrix_calculus::sinh_cosh_m(matrix).map(|(_, cosh)| cosh)
292}
293
294/// Applies a scalar function through the spectrum: `f(A) = V diag(f(lambda)) Vᵀ`.
295///
296/// # Errors
297///
298/// Returns an error for a non-symmetric argument. A real non-symmetric matrix has no real
299/// orthogonal eigendecomposition, so `f` cannot be pushed through its spectrum this way;
300/// the correct general algorithm is Schur-Parlett, which this crate does not implement.
301/// The previous code returned the *input matrix unchanged* in that case, which silently
302/// reported `f(A) = A` for every non-symmetric input.
303#[allow(dead_code)]
304fn compute_matrix_function<F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
305    matrix: &scirs2_core::ndarray::ArrayView2<F>,
306    func: fn(F) -> F,
307) -> Result<Array2<F>, OpError> {
308    let n = matrix.shape()[0];
309
310    if !matrix_calculus::is_symmetric(matrix) {
311        return Err(OpError::Other(
312            "funm: applying a scalar function through the spectrum is only supported for \
313             symmetric matrices (a non-symmetric argument needs a Schur-Parlett \
314             evaluation, which this crate does not provide)"
315                .into(),
316        ));
317    }
318
319    let (eigenvalues, eigenvectors) = matrix_calculus::symmetric_eigen(matrix)?;
320
321    let mut temp = Array2::<F>::zeros((n, n));
322    for i in 0..n {
323        for j in 0..n {
324            temp[[i, j]] = eigenvectors[[i, j]] * func(eigenvalues[j]);
325        }
326    }
327    Ok(temp.dot(&eigenvectors.t()))
328}
329
330// Public API functions
331
332/// Compute matrix sine
333#[allow(dead_code)]
334pub fn sinm<'g, F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
335    matrix: &Tensor<'g, F>,
336) -> Tensor<'g, F> {
337    let g = matrix.graph();
338    let matrixshape = crate::tensor_ops::shape(matrix);
339
340    Tensor::builder(g)
341        .append_input(matrix, false)
342        .setshape(&matrixshape)
343        .build(MatrixSineOp)
344}
345
346/// Compute matrix cosine
347#[allow(dead_code)]
348pub fn cosm<'g, F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
349    matrix: &Tensor<'g, F>,
350) -> Tensor<'g, F> {
351    let g = matrix.graph();
352    let matrixshape = crate::tensor_ops::shape(matrix);
353
354    Tensor::builder(g)
355        .append_input(matrix, false)
356        .setshape(&matrixshape)
357        .build(MatrixCosineOp)
358}
359
360/// Compute matrix sign function
361#[allow(dead_code)]
362pub fn signm<'g, F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
363    matrix: &Tensor<'g, F>,
364) -> Tensor<'g, F> {
365    let g = matrix.graph();
366    let matrixshape = crate::tensor_ops::shape(matrix);
367
368    Tensor::builder(g)
369        .append_input(matrix, false)
370        .setshape(&matrixshape)
371        .build(MatrixSignOp)
372}
373
374/// Compute matrix hyperbolic sine
375#[allow(dead_code)]
376pub fn sinhm<'g, F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
377    matrix: &Tensor<'g, F>,
378) -> Tensor<'g, F> {
379    let g = matrix.graph();
380    let matrixshape = crate::tensor_ops::shape(matrix);
381
382    Tensor::builder(g)
383        .append_input(matrix, false)
384        .setshape(&matrixshape)
385        .build(MatrixSinhOp)
386}
387
388/// Compute matrix hyperbolic cosine
389#[allow(dead_code)]
390pub fn coshm<'g, F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
391    matrix: &Tensor<'g, F>,
392) -> Tensor<'g, F> {
393    let g = matrix.graph();
394    let matrixshape = crate::tensor_ops::shape(matrix);
395
396    Tensor::builder(g)
397        .append_input(matrix, false)
398        .setshape(&matrixshape)
399        .build(MatrixCoshOp)
400}
401
402/// Compute general matrix function
403#[allow(dead_code)]
404pub fn funm<'g, F: Float + scirs2_core::ndarray::ScalarOperand + FromPrimitive>(
405    matrix: &Tensor<'g, F>,
406    func: fn(F) -> F,
407    name: &'static str,
408) -> Tensor<'g, F> {
409    let g = matrix.graph();
410    let matrixshape = crate::tensor_ops::shape(matrix);
411
412    Tensor::builder(g)
413        .append_input(matrix, false)
414        .setshape(&matrixshape)
415        .build(MatrixFunctionOp {
416            function: func,
417            name,
418        })
419}