Repository: kymatio/kymatio

Issue:
BUG JTFS filterbank_fr construction produces incorrect `j` for spinned wavelets
When constructing a frequential filterbank with `scattering_filter_factory` we pass `(filterbank_fn, filterbank_kwargs)`. This `filterbank_fn` is spinned with `filter_bank.spin`

However, `get_max_dyadic_subsampling` is not aware of the sign of `xi`, so treats negative `xi` as further dyadic subsamplings, resulting in `j` beyond `J_fr`. This has knock on effects for how many `phi_fr` resolutions we create, since this is a function of `previous_J`. For a spinned filterbank, we reach `j = 11` although `J_fr = 6`. 

This was only caught when experimenting with `Q_fr > 1`

```
from kymatio.torch import TimeFrequencyScattering

jtfs = TimeFrequencyScattering(
    shape=(2**13,), Q=(8, 1), J=13, J_fr=6, F=0, Q_fr=2, format="time"
)
jtfs(torch.randn(2**13))
```
```
  File "/homes/cv300/Documents/kymatio/kymatio/scattering1d/filter_bank.py", line 401, in scattering_filter_factory
    phi_level_reshaped = phi_levels[0].reshape(2**level, -1)
ValueError: cannot reshape array of size 576 into shape (128,newaxis)
```

Clearly in the above case, the value `level` has gone beyond `J_fr`.

What results is incorrect values for `j` and therefore incorrect subsampling:

```
xi: -0.003866990209613928, j: 6, 
xi: -0.35, j: 5
```

The largest and smallest spinned `xi` differ by just one factor of two in subsampling.

This can be fixed in the call to `get_max_dyadic_subsampling` in `scattering_filter_factory` by ensuring we pass the absolute value of `xi`:

`j = get_max_dyadic_subsampling(abs(xi), sigma, **filterbank_kwargs)`

Now, `j` is in line with what we expect:

```
xi: -0.003866990209613928, j: 5, 
xi: -0.35, j: 0
```

I propose that we make a PR for this fix with tests. Do we implement this in `scattering_filter_factory` or `get_max_dyadic_subsampling`? Perhaps it makes sense that `get_max_dyadic_subsampling` always behaves as expected, regardless of the sign of `xi`.

@lostanlen 


Discussion:
Thank you for raising this issue. ~~I'll make a PR later today.~~ seems like you're beating me to it!

> I propose that we make a PR for this fix with tests. Do we implement this in scattering_filter_factory or get_max_dyadic_subsampling? Perhaps it makes sense that get_max_dyadic_subsampling always behaves as expected, regardless of the sign of xi.

Good point

Resolving patch:
diff --git a/kymatio/scattering1d/filter_bank.py b/kymatio/scattering1d/filter_bank.py
--- a/kymatio/scattering1d/filter_bank.py
+++ b/kymatio/scattering1d/filter_bank.py
@@ -204,7 +204,7 @@ def get_max_dyadic_subsampling(xi, sigma, alpha, **unused_kwargs):
     Finds the maximal integer j such that:
     omega_0 < 2^{-(j + 1)}
     where omega_0 is the boundary of the filter, defined as
-    omega_0 = xi + alpha * sigma
+    omega_0 = abs(xi) + alpha * sigma
 
     This ensures that the filter can be subsampled by a factor 2^j without
     aliasing.
@@ -214,7 +214,7 @@ def get_max_dyadic_subsampling(xi, sigma, alpha, **unused_kwargs):
     Parameters
     ----------
     xi : float
-        frequency of the filter in [0, 1]
+        frequency of the filter in [-0.5, 0.5]
     sigma : float
         frequential width of the filter
     alpha : float, optional
@@ -227,7 +227,7 @@ def get_max_dyadic_subsampling(xi, sigma, alpha, **unused_kwargs):
         integer such that 2^j is the maximal subsampling accepted by the
         Gabor filter without aliasing.
     """
-    upper_bound = min(xi + alpha * sigma, 0.5)
+    upper_bound = min(abs(xi) + alpha * sigma, 0.5)
     j = math.floor(-math.log2(upper_bound)) - 1
     j = int(j)
     return j


Test patch:
diff --git a/tests/scattering1d/test_timefrequency_scattering.py b/tests/scattering1d/test_timefrequency_scattering.py
--- a/tests/scattering1d/test_timefrequency_scattering.py
+++ b/tests/scattering1d/test_timefrequency_scattering.py
@@ -619,4 +619,25 @@ def test_F(frontend):
     """
     jtfs2 = TimeFrequencyScattering(frontend="torch", format="joint", F=2**kwargs['J_fr'] // 4, **kwargs)
     Sx2 = jtfs2(x)
-    assert Sx0.shape[0] == Sx2.shape[0] and Sx0.shape[2] == Sx2.shape[2] and Sx0.shape[1] < Sx2.shape[1]
\ No newline at end of file
+    assert Sx0.shape[0] == Sx2.shape[0] and Sx0.shape[2] == Sx2.shape[2] and Sx0.shape[1] < Sx2.shape[1]
+
+
+backends = ["numpy", "torch", "tensorflow", "jax", "sklearn"]
+@pytest.mark.parametrize("backend", backends)
+def test_986(backend):
+    J_fr = 6
+    jtfs = TimeFrequencyScatteringBase(
+        J=8, J_fr=J_fr, shape=4096, Q=8, backend=backend
+    )
+    jtfs.build()
+    jtfs.create_filters()
+
+    psis = jtfs.filters_fr[1]
+    positive_spins = sorted([psi for psi in psis[1:] if np.sign(psi['xi']) > 0], key=lambda x: x["xi"], reverse=True)
+    negative_spins = sorted([psi for psi in psis[1:] if np.sign(psi['xi']) < 0], key=lambda x: x["xi"])
+    assert all(
+        [
+            (psi_pos["j"] == psi_neg["j"])
+            for psi_pos, psi_neg in zip(positive_spins, negative_spins)
+        ]
+    )
\ No newline at end of file