Repository: scipy/scipy

Issue:
Odd casting behaviour of signal.filtfilt
I've noticed that `signal.filtfilt(b, a, x)` always returns a float64 result, even when `b`, `a` and `x` are all float32 (float16 does not seem to be supported by `lfilter`).

```
import numpy as np
from scipy import signal

x = np.random.randn(1000)
b, a = signal.butter(8, 0.125)

print(signal.filtfilt( b, a, x).dtype)
# np.float64

print(signal.filtfilt( b.astype(np.float32), 
                       a.astype(np.float32), 
                       x.astype(np.float32)).dtype)
# still np.float64
```

Ordinarily this upcasting wouldn't be a big deal, but I'm currently trying to filter some very large arrays, and I'm running into nasty memory issues.

The problem seems to be in `signal.lfilter_zi(b, a)`, which gets called by `filtfilt` and always returns a float64 result regardless of what `dtype` `b` and `a` have.



Discussion:
Just slammed into this too. Can this behavior in `signal.filtfilt` and `signal.lfilter_zi` be fixed? I'll dig into why the latter always returns float64.

Sounds reasonable to me. I think the other filter functions try to retain the filter types. @e-q WDYT?

Preserving the dtype makes sense to me, at least when the input is `float32`.   A reasonable goal is for `filtfilt` to do as good a job at preserving the dtype as `lfilter` does.

I just ran into this too.
The coefficients from the filter design are already in `float64` - I didn't look at the `filtfilt` code yet, but since they are involved in the calculation of the filtered signal this might be one reason.

Merely casting the coefficients `b, a` to lower precision might cause instability though - so one would need to re-check the stability. Or allow a possibility for setting a coefficient `dtype` in the design process already to avoid this problem.
So even if we maintain the type between input and output of `filtfilt`. It would still take time to cast back and forth IF the coefficients are of higher precision then the input signal.

Question: How costly is casting back and forth? It might make sense to do the entire filtering operation in `float32` if the input is `float32` to avoid casting back and forth twice.
Using the attached script, I tested a few filter-related functions in scipy 1.4.1; it seems like the following return float64 when given all float32 arguments:

 - `decimate`
 - `filtfilt`
 - `lfilter_zi`
 - `sos2tf`
 - `sosfilt_zi`
 - `sosfiltfilt`
 
One function, `lfiltic`, returns float32 even when given some float64 arguments, but returns float64 when given all float64 arguments, and float32 when given all float32 arguments.

The following functions behave as expected, or at least as I expect: if any argument is float64, the result is float64, and if all arguments are float32, the result is float32:

 - `lfilter`
 - `savgol_filter`
 - `sosfilt`

```
decimate:
  (f32) -> f64, np.result_type f32
filtfilt:
  (f32,f32,f32) -> f64, np.result_type f32
lfilter_zi:
  (f32,f32) -> f64, np.result_type f32
lfiltic(3):
  (f32,f64,f32) -> f32, np.result_type f64
  (f64,f32,f32) -> f32, np.result_type f64
  (f64,f64,f32) -> f32, np.result_type f64
lfiltic(4):
  (f32,f32,f32,f64) -> f32, np.result_type f64
  (f32,f64,f32,f32) -> f32, np.result_type f64
  (f32,f64,f32,f64) -> f32, np.result_type f64
  (f64,f32,f32,f32) -> f32, np.result_type f64
  (f64,f32,f32,f64) -> f32, np.result_type f64
  (f64,f64,f32,f32) -> f32, np.result_type f64
  (f64,f64,f32,f64) -> f32, np.result_type f64
sos2tf[0]:
  (f32) -> f64, np.result_type f32
sos2tf[1]:
  (f32) -> f64, np.result_type f32
sosfilt_zi:
  (f32) -> f64, np.result_type f32
sosfiltfilt:
  (f32,f32) -> f64, np.result_type f32

Types as expected:
  lfilter[0]
  lfilter[1]
  savgol_filter
  sosfilt[0]
  sosfilt[1]
```

[check_types.zip](https://github.com/scipy/scipy/files/4535185/check_types.zip)

Some of them are inherently not flexible due to calling some underlying C machinery and only available for floats. 
@roryyorke would you be up for making a PR to fix some of these? For example fixing `lfilter_zi` and `sosfilt_zi` would probably make fixing `filtfilt` and `sosfiltfilt` pretty easy.
... one option is just to cast the result of `lfilter_zi` and `sosfilt_zi` after they're created, if necessary.

Resolving patch:
diff --git a/scipy/signal/filter_design.py b/scipy/signal/filter_design.py
--- a/scipy/signal/filter_design.py
+++ b/scipy/signal/filter_design.py
@@ -1195,8 +1195,12 @@ def sos2tf(sos):
     .. versionadded:: 0.16.0
     """
     sos = np.asarray(sos)
-    b = [1.]
-    a = [1.]
+    result_type = sos.dtype
+    if result_type.kind in 'bui':
+        result_type = np.float64
+
+    b = np.array([1], dtype=result_type)
+    a = np.array([1], dtype=result_type)
     n_sections = sos.shape[0]
     for section in range(n_sections):
         b = np.polymul(b, sos[section, :3])
diff --git a/scipy/signal/signaltools.py b/scipy/signal/signaltools.py
--- a/scipy/signal/signaltools.py
+++ b/scipy/signal/signaltools.py
@@ -1963,17 +1963,27 @@ def lfiltic(b, a, y, x=None):
     M = np.size(b) - 1
     K = max(M, N)
     y = np.asarray(y)
-    if y.dtype.kind in 'bui':
-        # ensure calculations are floating point
-        y = y.astype(np.float64)
-    zi = np.zeros(K, y.dtype)
+
     if x is None:
-        x = np.zeros(M, y.dtype)
+        result_type = np.result_type(np.asarray(b), np.asarray(a), y)
+        if result_type.kind in 'bui':
+            result_type = np.float64
+        x = np.zeros(M, dtype=result_type)
     else:
         x = np.asarray(x)
+
+        result_type = np.result_type(np.asarray(b), np.asarray(a), y, x)
+        if result_type.kind in 'bui':
+            result_type = np.float64
+        x = x.astype(result_type)
+
         L = np.size(x)
         if L < M:
             x = np.r_[x, np.zeros(M - L)]
+
+    y = y.astype(result_type)
+    zi = np.zeros(K, result_type)
+
     L = np.size(y)
     if L < N:
         y = np.r_[y, np.zeros(N - L)]
@@ -3460,7 +3470,7 @@ def lfilter_zi(b, a):
     elif len(b) < n:
         b = np.r_[b, np.zeros(n - len(b))]
 
-    IminusA = np.eye(n - 1) - linalg.companion(a).T
+    IminusA = np.eye(n - 1, dtype=np.result_type(a, b)) - linalg.companion(a).T
     B = b[1:] - a[1:] * b[0]
     # Solve zi = A*zi + B
     zi = np.linalg.solve(IminusA, B)
@@ -3537,8 +3547,11 @@ def sosfilt_zi(sos):
     if sos.ndim != 2 or sos.shape[1] != 6:
         raise ValueError('sos must be shape (n_sections, 6)')
 
+    if sos.dtype.kind in 'bui':
+        sos = sos.astype(np.float64)
+
     n_sections = sos.shape[0]
-    zi = np.empty((n_sections, 2))
+    zi = np.empty((n_sections, 2), dtype=sos.dtype)
     scale = 1.0
     for section in range(n_sections):
         b = sos[section, :3]
@@ -4268,6 +4281,12 @@ def decimate(x, q, n=None, ftype='iir', axis=-1, zero_phase=True):
     else:
         raise ValueError('invalid ftype')
 
+    result_type = x.dtype
+    if result_type.kind in 'bui':
+        result_type = np.float64
+    b = np.asarray(b, dtype=result_type)
+    a = np.asarray(a, dtype=result_type)
+
     sl = [slice(None)] * x.ndim
     a = np.asarray(a)
 


Test patch:
diff --git a/scipy/signal/tests/test_result_type.py b/scipy/signal/tests/test_result_type.py
new file mode 100644
--- /dev/null
+++ b/scipy/signal/tests/test_result_type.py
@@ -0,0 +1,54 @@
+# Regressions tests on result types of some signal functions
+
+import numpy as np
+from numpy.testing import assert_
+
+import pytest
+
+from scipy.signal import (decimate,
+                          lfilter_zi,
+                          lfiltic,
+                          sos2tf,
+                          sosfilt_zi)
+
+
+def test_decimate():
+    ones_f32 = np.ones(32, dtype=np.float32)
+    assert_(decimate(ones_f32, 2).dtype == np.float32)
+
+    ones_i64 = np.ones(32, dtype=np.int64)
+    assert_(decimate(ones_i64, 2).dtype == np.float64)
+    
+
+def test_lfilter_zi():
+    b_f32 = np.array([1, 2, 3], dtype=np.float32)
+    a_f32 = np.array([4, 5, 6], dtype=np.float32)
+    assert_(lfilter_zi(b_f32, a_f32).dtype == np.float32)
+
+
+def test_lfiltic():
+    # this would return f32 when given a mix of f32 / f64 args
+    b_f32 = np.array([1, 2, 3], dtype=np.float32)
+    a_f32 = np.array([4, 5, 6], dtype=np.float32)
+    x_f32 = np.ones(32, dtype=np.float32)
+    
+    b_f64 = b_f32.astype(np.float64)
+    a_f64 = a_f32.astype(np.float64)
+    x_f64 = x_f32.astype(np.float64)
+
+    assert_(lfiltic(b_f64, a_f32, x_f32).dtype == np.float64)
+    assert_(lfiltic(b_f32, a_f64, x_f32).dtype == np.float64)
+    assert_(lfiltic(b_f32, a_f32, x_f64).dtype == np.float64)
+    assert_(lfiltic(b_f32, a_f32, x_f32, x_f64).dtype == np.float64)
+
+
+def test_sos2tf():
+    sos_f32 = np.array([[4, 5, 6, 1, 2, 3]], dtype=np.float32)
+    b, a = sos2tf(sos_f32)
+    assert_(b.dtype == np.float32)
+    assert_(a.dtype == np.float32)
+
+
+def test_sosfilt_zi():
+    sos_f32 = np.array([[4, 5, 6, 1, 2, 3]], dtype=np.float32)
+    assert_(sosfilt_zi(sos_f32).dtype == np.float32)