Repository: kymatio/kymatio

Issue:
`ScatteringBase1D` kwargs should be passed via `dict`'s 
from the `ScatteringBase1D` frontend to `compute_meta_scattering`, `precompute_size_scattering`, and `scattering_filter_factory`

This `dict` could be named `filterbank_kwargs` and eventually also contain the `spinned` boolean (#807)

This will lead to a reduction in the number of positional arguments in these prototypes. More importantly, it will better decouple hyperparameters from the choice of wavelet shape.

For me to be able to start working on this, i need #833 #842 #848 #850 to be reviewed, rebased, and merged. 

Does anyone use `max_subsampling` in `scattering_filter_factory` 1D ?
This keyword argument defaults to `None` and is documented as:

```
    max_subsampling: int or None, optional
        maximal dyadic subsampling to compute, in order
        to save computation time if it is not required. Defaults to None, in
        which case this value is dynamically adjusted depending on the filters.
```


I don't see it tested anywhere, and it isn't properly supported by the frontends. I doubt that anyone uses it.

Rather, i believe that this is some vestigial scaffold of when we were developing for v0.1

How about we remove it? @cyrusvahidi @changhongw 


Discussion:
Update: i believe it would be good to bundle the input arguments to `scattering_filter_factory` in terms of whether they relate to the filterbank discretization (xi's and sigma's), to the design/periodization of wavelets, or to the design/periodization of the low-pass filter.

We could expose this by means of three read-only properties in the base frontend:
```python
@property
def filterbank(self):
    return (scatnet_filterbank, {"r_psi": self.r_psi, "spinned": False})

@property
def lowpass(self):
    return (gauss_1d, {"sigma0": self.sigma0})

@property
def wavelet(self):
    return (morlet_1d, {"alpha": self.alpha, "max_subsampling": None})
```
where `scatnet_filterbank` is `compute_params_filterbank` under a new name, and without the third output argument.
(the above is post- #832 but keywords `P_max`, `eps`, and `normalize` could live in these dictionaries!)

Those properties are a tuple of two elements, where the first element is callable and the second is a dictionary of kwargs for that callable.

Hence a cleaner prototype and control flow for `scattering_filter_factory`: (post #840 )

```python
def psi_factory(N, J, Q, wavelet, filterbank, n_resolutions):
    filterbank_fn, filterbank_kwargs = filterbank
    xis, sigmas = filterbank_fn(J, Q, **filterbank_kwargs)
    wavelet_fn, wavelet_kwargs = wavelet
    psis_f = []
    for (xi, sigma) in zip(xis, sigmas):
        j = get_max_dyadic_subsampling(xi, sigma, **wavelet_kwargs)
        psi_f = {
            0: wavelet_fn(N, xi, sigma, **wavelet_kwargs),
            "xi": xi,
            "sigma": sigma,
            "j": j,
        }
        for k in range(1, n_resolutions):
            psi_f[k] = periodize_filter_fourier(psi_f[0], 2**k)
        psis_f.append(psi_f)
    return psis_f


def phi_factory(N, T, filterbank, lowpass, n_resolutions):
    lowpass_fn, lowpass_kwargs = lowpass
    log2_T = math.floor(math.log2(T))
    phi_f = {
        0: lowpass_fn(N, T, **lowpass_kwargs),
        "xi": 0,
        "sigma": sigma_low,
        "j": log2_T,
    }
    for k in range(1, n_resolutions):
        phi_f[k] = periodize_filter_fourier(phi_f[0], 2**k)
    return phi_f
    
def scattering_filter_factory(N, J, Q, T, filterbank, lowpass, wavelet):
    # First order
    psi1s_f = psi_factory(N, J, Q, wavelet, filterbank, n_resolutions=1)

    # Second order
    n_psi2_resolutions = max(map(itemgetter("j"), psi1s_f))
    psi2s_f = psi_factory(N, J, 1, wavelet, filterbank, n_psi2_resolutions)

    # Low-pass filter
    n_phi_resolutions = max(n_psi1_resolutions, max(map(itemgetter("j"), psi2s_f)))
    phi_f = phi_factory(N, T, lowpass, filterbank, n_phi_resolutions)

    return phi_f, psi1s_f, psi2s_f
```

This is `spinned`-friedly (#807), is backwards compatible (v0.3!), and would open the door towards revisiting #385 (by @rleonarduzzi ) and Gammatone filters. By allowing the user to specify `self.wavelet` in the 1D constructor (long-term plan).


Update: a more concise version that should work out of the box in v0.3:

```python
def scatnet_generator(J, Q, r_psi, sigma0):
    xi = compute_xi_max(Q)
    sigma = compute_sigma_psi(xi, Q, r=r_psi)
    sigma_min = sigma0 / 2**J

    if sigma <= sigma_min:
        xi = sigma
    else:
        yield xi, sigma
        # High-frequency (constant-Q) region: geometric progression of xi
        while sigma > (sigma_min * math.pow(2, 1 / Q)):
            xi /= math.pow(2, 1 / Q)
            sigma /= math.pow(2, 1 / Q)
            yield xi, sigma

    # Low-frequency (constant-bandwidth) region: arithmetic progression of xi
    for q in range(Q):
        xi -= 1/Q
        yield xi, sigma_min


def scattering_filter_factory(N, Js, Qs, T, generators, filters):
    lowpass, wavelets = filters[0], filters[1:]
    filterbanks = []
    n_resolutions = 1
    for J, Q, generator, wavelet in zip(Js, Qs, generators, wavelets):
        generator_fn, generator_kwargs = generator
        wavelet_fn, wavelet_kwargs = wavelet
        filterbank = []
        max_j = 0
        xi_sigmas = generator_fn(J, Q, **generator_kwargs)
        for n, (xi, sigma) in enumerate(xi_sigmas):
            j = get_max_dyadic_subsampling(xi, sigma, **wavelet_kwargs)
            psi_f = {0: wavelet_fn(N, xi, sigma, **wavelet_kwargs),
                "xi": xi, "sigma": sigma, "j": j, "n": n}
            for k in range(1+n_resolutions):
                psi_f[k] = periodize_filter_fourier(psi_f, n_periods=2**k)
            filterbank.append(psi_f)
            max_j = max(max_j, j)
        n_resolutions = max(n_resolutions, max_j)
        filterbanks.append(filterbank)

    lowpass_fn, lowpass_kwargs = lowpass
    log2_T = math.floor(math.log2(T))
    phi_f = {0: lowpass_fn(N, T, **lowpass_kwargs), "j": log2_T}
    for k in range(1+n_resolutions):
        phi_f[k] = periodize_filter_fourier(phi_f, n_periods=2**k)

    return phi_f, *filterbanks


def create_filters(self):
    sigma0 = 0.1
    r_psi = sqrt(0.5)
    generators = (
        (scatnet_generator, {"r_psi"}),
        (scatnet_generator, {"r_psi"})
    )
    filters = (
        (gauss_1d, {"sigma0": sigma}),
        (morlet_1d, {"r_psi": r_psi}),
        (morlet_1d, {"r_psi": r_psi})
    )
    self.phi_f, self.psi1_f, self.psi2_f = scattering_filter_factory(
        N, J, Q, T, generators, filters)
```

Note that the last function prepares for a number of features that could happen in v0.4 if we deem it useful:
* varying Q per order (#854 )
* varying J per order
* switching wavelet types
* varying r_psi per order
* varying wavelet type per order
* having more than two orders
* replacing the `scatnet_generator` by something else

It also allows to skip filterbank construction in a very concise way:

```python
skip_filters = repeat(lambda *args, **kwargs: None, {})
phi_f, psi1_f, psi2_f = scattering_filter_factory(N, Js, Qs, T, generators, skip_filters)
```

This idiom is useful for maintaining `utils.compute_meta_scattering` and `utils.precompute_size_scattering`. I also believe it will improve our approach to padding
Looks reasonable, but why the references to `scatnet`? Do these reproduce the exact filters used in that toolbox?

Also why the choice of generators instead of lists? What do we gain from it? It seems simpler to me to use lists (which is also what's used elsewhere in the codebase AFAIK).

Finally I like the idea of skipping the filterbank construction that you show at the end, but it might be cleaner to just have two functions: one which generates the parameters of the filters (essentially coupling the generators you've defined) and another that takes the output and generates the actual filters. This means one (simpler) call if we just want the metadata and two calls if we want the filters (which is one more than before, but I think it's ok).
> why the references to scatnet? Do these reproduce the exact filters used in that toolbox?

It doesn't reproduce the value of the filters, but it's reproducing this algorithm of:
* starting from a xi_max / sigma_max pair
* updating both in a geometric sequence so that their ratio is preserved
* up until the sigma reaches a predefined sigma_low (i.e. time support ~T)
* then fill up the spectrum with equally spaced band-pass filters of bandwidth=sigma_low

This is what i'm proposing we call `scatnet_filterbank`. Part of me wanted to name it the `anden_filterbank` initially but i wasn't sure if you'd accept the shout out :D 


> Also why the choice of generators instead of lists? What do we gain from it? It seems simpler to me to use lists (which is also what's used elsewhere in the codebase AFAIK).

In this context, what we gain is code simplicity. We don't worry about initializing lists to `[]` and calling `.append` on each of them, only to `return` the fully formed lists. We just `yield` stuff as we go. In other contexts, generator can help save memory and customize/generalize the code more easily. @eickenberg would be far more eloquent than me on this matter. I don't feel very strongly about it, i just figured it was elegant (and a good exercise for myself in using the `yield` statement)


> Finally I like the idea of skipping the filterbank construction that you show at the end [...]

Thanks. This is the biggest selling point for me: not having to hack together a `compute_meta_scattering` and a `precompute_size_scattering`, because that's relatively error-prone and might have weird corner cases. Ideally this `compute_meta_scattering` would take the form of a "dry run" on the scattering transform itself. Same for `precompute_size_scattering` which would be nothing else than a counter on the metas.

> [...] but it might be cleaner to just have two functions: one which generates the parameters of the filters (essentially coupling the generators you've defined) and another that takes the output and generates the actual filters). This means one (simpler) call if we just want the metadata and two calls if we want the filters (which is one more than before, but I think it's ok).

Really interesting. What would be the output format of the first function?
What you describe sounds close to `calibrate_scattering_filters` but the issue i take with that function is that going from its output (`sigma_low, xi1, sigma1, j1, xi2, sigma2, j2`) to predict `meta` is quite involved: one has to mimick a scattering transform by running a depth-first search, check for `j1 < j2`, keep track of eight iterated variables at once (four per order), etc. That sounds benign for order-2 Scattering1D but i'm afraid that the same mimicry approach would be awfully tedious to maintain for more advanced algorithms like JTFS. So i'm looking for ways to avoid having to write all this code by hand and instead rely on core `scattering` with "empty" filters (metadata but no coefficients) so as to implement `precompute_scattering_meta` in a way that's more resilient to bugs and breaking changes.

@janden would you like to elaborate on what you're proposing?

@janden here's the "dry run" i have in mind. In `ScatteringBase1D.meta` (or its callee `compute_meta_scattering`), locally define

```python
class DryBackend:
    cdgmm = lambda *args: None
    fft = lambda *args: None
    ifft = lambda *args: None
    irfft = lambda *args: None
    subsample_fourier = lambda *args: None
```

and then call `core.scattering(None, DryBackend(), psi1, psi2, phi)` to obtain an `out_S` list (or generator!) of dicts in which all `coef`'s are `None` but their ordering and metadata is consistent with the real McCoy (namely TF/PT/NP backends).

I'm realizing that for that to happen, i actually don't need to modify the scattering filter factory. Just to make sure that `core.scattering` would accept `DryBackend()` as second argument. This is why i'm interested in removing the bells and whistles (pad, unpad, reshape) from core and reduce it to the depth-first search algorithm.

The initial topic of this issue was more: how to make scattering filter factory accept spinned wavelets in the near future, and non-Morlet wavelets in the more long-term future. But maybe that's not as much a high priority as thinning down the code base of `utils.compute_meta_scattering`

P.S. a less verbose but subtler implementation of `DryBackend` is 
```python
class DryBackend:
    __getattr__ = lambda self, attr: (lambda *args: None)
```

> Update: i believe it would be good to bundle the input arguments to `scattering_filter_factory` in terms of whether they relate to the filterbank discretization (xi's and sigma's), to the design/periodization of wavelets, or to the design/periodization of the low-pass filter.
> 
> We could expose this by means of three read-only properties in the base frontend:
> 
> ```python
> @property
> def filterbank(self):
>     return (scatnet_filterbank, {"r_psi": self.r_psi, "spinned": False})
> 
> @property
> def lowpass(self):
>     return (gauss_1d, {"sigma0": self.sigma0})
> 
> @property
> def wavelet(self):
>     return (morlet_1d, {"alpha": self.alpha, "max_subsampling": None})
> ```
> 
> where `scatnet_filterbank` is `compute_params_filterbank` under a new name, and without the third output argument. (the above is post- #832 but keywords `P_max`, `eps`, and `normalize` could live in these dictionaries!)
> 
> Those properties are a tuple of two elements, where the first element is callable and the second is a dictionary of kwargs for that callable.
> 
> Hence a cleaner prototype and control flow for `scattering_filter_factory`: (post #840 )
> 
> ```python
> def psi_factory(N, J, Q, wavelet, filterbank, n_resolutions):
>     filterbank_fn, filterbank_kwargs = filterbank
>     xis, sigmas = filterbank_fn(J, Q, **filterbank_kwargs)
>     wavelet_fn, wavelet_kwargs = wavelet
>     psis_f = []
>     for (xi, sigma) in zip(xis, sigmas):
>         j = get_max_dyadic_subsampling(xi, sigma, **wavelet_kwargs)
>         psi_f = {
>             0: wavelet_fn(N, xi, sigma, **wavelet_kwargs),
>             "xi": xi,
>             "sigma": sigma,
>             "j": j,
>         }
>         for k in range(1, n_resolutions):
>             psi_f[k] = periodize_filter_fourier(psi_f[0], 2**k)
>         psis_f.append(psi_f)
>     return psis_f
> 
> 
> def phi_factory(N, T, filterbank, lowpass, n_resolutions):
>     lowpass_fn, lowpass_kwargs = lowpass
>     log2_T = math.floor(math.log2(T))
>     phi_f = {
>         0: lowpass_fn(N, T, **lowpass_kwargs),
>         "xi": 0,
>         "sigma": sigma_low,
>         "j": log2_T,
>     }
>     for k in range(1, n_resolutions):
>         phi_f[k] = periodize_filter_fourier(phi_f[0], 2**k)
>     return phi_f
>     
> def scattering_filter_factory(N, J, Q, T, filterbank, lowpass, wavelet):
>     # First order
>     psi1s_f = psi_factory(N, J, Q, wavelet, filterbank, n_resolutions=1)
> 
>     # Second order
>     n_psi2_resolutions = max(map(itemgetter("j"), psi1s_f))
>     psi2s_f = psi_factory(N, J, 1, wavelet, filterbank, n_psi2_resolutions)
> 
>     # Low-pass filter
>     n_phi_resolutions = max(n_psi1_resolutions, max(map(itemgetter("j"), psi2s_f)))
>     phi_f = phi_factory(N, T, lowpass, filterbank, n_phi_resolutions)
> 
>     return phi_f, psi1s_f, psi2s_f
> ```
> 
> This is `spinned`-friedly (#807), is backwards compatible (v0.3!), and would open the door towards revisiting #385 (by @rleonarduzzi ) and Gammatone filters. By allowing the user to specify `self.wavelet` in the 1D constructor (long-term plan).

love the flexible choice of wavelet function here. will consider the best way to implement. perhaps wavelet function could follow some interface and be callable rather than passing around kwargs.
> Update: a more concise version that should work out of the box in v0.3:
> 
> ```python
> def scatnet_generator(J, Q, r_psi, sigma0):
>     xi = compute_xi_max(Q)
>     sigma = compute_sigma_psi(xi, Q, r=r_psi)
>     sigma_min = sigma0 / 2**J
> 
>     if sigma <= sigma_min:
>         xi = sigma
>     else:
>         yield xi, sigma
>         # High-frequency (constant-Q) region: geometric progression of xi
>         while sigma > (sigma_min * math.pow(2, 1 / Q)):
>             xi /= math.pow(2, 1 / Q)
>             sigma /= math.pow(2, 1 / Q)
>             yield xi, sigma
> 
>     # Low-frequency (constant-bandwidth) region: arithmetic progression of xi
>     for q in range(Q):
>         xi -= 1/Q
>         yield xi, sigma_min
> 
> 
> def scattering_filter_factory(N, Js, Qs, T, generators, filters):
>     lowpass, wavelets = filters[0], filters[1:]
>     filterbanks = []
>     n_resolutions = 1
>     for J, Q, generator, wavelet in zip(Js, Qs, generators, wavelets):
>         generator_fn, generator_kwargs = generator
>         wavelet_fn, wavelet_kwargs = wavelet
>         filterbank = []
>         max_j = 0
>         xi_sigmas = generator_fn(J, Q, **generator_kwargs)
>         for n, (xi, sigma) in enumerate(xi_sigmas):
>             j = get_max_dyadic_subsampling(xi, sigma, **wavelet_kwargs)
>             psi_f = {0: wavelet_fn(N, xi, sigma, **wavelet_kwargs),
>                 "xi": xi, "sigma": sigma, "j": j, "n": n}
>             for k in range(1+n_resolutions):
>                 psi_f[k] = periodize_filter_fourier(psi_f, n_periods=2**k)
>             filterbank.append(psi_f)
>             max_j = max(max_j, j)
>         n_resolutions = max(n_resolutions, max_j)
>         filterbanks.append(filterbank)
> 
>     lowpass_fn, lowpass_kwargs = lowpass
>     log2_T = math.floor(math.log2(T))
>     phi_f = {0: lowpass_fn(N, T, **lowpass_kwargs), "j": log2_T}
>     for k in range(1+n_resolutions):
>         phi_f[k] = periodize_filter_fourier(phi_f, n_periods=2**k)
> 
>     return phi_f, *filterbanks
> 
> 
> def create_filters(self):
>     sigma0 = 0.1
>     r_psi = sqrt(0.5)
>     generators = (
>         (scatnet_generator, {"r_psi"}),
>         (scatnet_generator, {"r_psi"})
>     )
>     filters = (
>         (gauss_1d, {"sigma0": sigma}),
>         (morlet_1d, {"r_psi": r_psi}),
>         (morlet_1d, {"r_psi": r_psi})
>     )
>     self.phi_f, self.psi1_f, self.psi2_f = scattering_filter_factory(
>         N, J, Q, T, generators, filters)
> ```
> 
> Note that the last function prepares for a number of features that could happen in v0.4 if we deem it useful:
> 
>     * varying Q per order ([ Make `Q2` customizable #854](https://github.com/kymatio/kymatio/pull/854) )
> 
>     * varying J per order
> 
>     * switching wavelet types
> 
>     * varying r_psi per order
> 
>     * varying wavelet type per order
> 
>     * having more than two orders
> 
>     * replacing the `scatnet_generator` by something else
> 
> 
> It also allows to skip filterbank construction in a very concise way:
> 
> ```python
> skip_filters = repeat(lambda *args, **kwargs: None, {})
> phi_f, psi1_f, psi2_f = scattering_filter_factory(N, Js, Qs, T, generators, skip_filters)
> ```
> 
> This idiom is useful for maintaining `utils.compute_meta_scattering` and `utils.precompute_size_scattering`. I also believe it will improve our approach to padding

🔥 
>  perhaps wavelet function could follow some interface and be callable rather than passing around kwargs.

@cyrusvahidi i'm trying my best (so far with success) not to introduce new object-oriented classes. So a compromise i had in mind would be a two-element tuple with the callable as first element and its kwargs as second elements.

Something like `(gauss_1d, {"sigma0": sigma})` or `(morlet_1d, {"r_psi": r_psi})`.

For a Gammatone, it would become `(gammatone_1d, {"r_psi": r_psi, "gammatone_order": 4})` or something like that. The prototype of `gammatone_1d` would much resemble `morlet_1d`:

```python
def gammatone_1d(N, xi, sigma, *, r_psi, gammatone_order):
```

 I don't think it's necessary to have a new class where that class would basically implement `__init__`, `__call__`, and maybe (maybe!) some method `T_from_sigma` which computes the temporal support as a function of the `sigma` bandwidth parameter. But we have no such method right now, as we work empirically (`compute_temporal_support`) on the `phi` so estimate our `min_to_pad`. Therefore i don't believe an OO interface is necessary ... yet.

So i see my solution as one step in the journey but not the final destination. In any case this will need to be discussed with @eickenberg, who i know has thought about this for a while now.
On it.


Resolving patch:
diff --git a/examples/1d/plot_filters.py b/examples/1d/plot_filters.py
--- a/examples/1d/plot_filters.py
+++ b/examples/1d/plot_filters.py
@@ -15,26 +15,27 @@
 # First, we import the `scattering_filter_factory` method, which we will use
 # to generate the filters.
 
-from kymatio.scattering1d.filter_bank import scattering_filter_factory
+from kymatio.scattering1d.filter_bank import anden_generator, scattering_filter_factory
 
 ###############################################################################
-# We then import `numpy` and `matplotlib` to display the filters.
+# We then import `math`, `matplotlib`, and `numpy` to display the filters.
 
-import numpy as np
+from math import sqrt
 import matplotlib.pyplot as plt
+import numpy as np
 
 ###############################################################################
 # Filter parameters and generation
 # --------------------------------
-# The filters are defined for a certain support size `T` which corresponds to
-# the size of the input signal. The only restriction is that `T` must be a
+# The filters are defined for a certain support size `N` which corresponds to
+# the size of the input signal. The only restriction is that `N` must be a
 # power of two. Since we are not computing any scattering transforms here, we
-# may pick any power of two for `T`. Here, we choose `2**13 = 8192`.
+# may pick any power of two for `N`. Here, we choose `2**13 = 8192`.
 
-T = 2**13
+N = 2**13
 
 ###############################################################################
-# The parameter `J` specifies the maximum scale of the low-pass filters as a
+# The parameter `J` specifies the maximum scale of the band-pass filters as a
 # power of two. In other words, the largest filter will be concentrated in a time
 # interval of size `2**J`.
 
@@ -52,14 +53,22 @@
 
 Q = (8, 1)
 
+###############################################################################
+# The parameter `T` controls the maximum scale of the low-pass filter. The stride
+# of the scattering transform will be proportional to T. By default, T = 2**J.
+
+T = 2**J
+
+
 ###############################################################################
 # We are now ready to create the filters. These are generated by the
-# `scattering_filter_factory` method, which takes the logarithm of `T` and
-# the `J` and `Q` parameters. It returns the lowpass filter (`phi_f`), the
+# `scattering_filter_factory` method, which takes `N`, `J`, `Q`, and `T`
+# as parameters. It returns the lowpass filter (`phi_f`), the
 # first-order wavelet filters (`psi1_f`), and the second-order filters
 # (`psi2_f`).
 
-phi_f, psi1_f, psi2_f = scattering_filter_factory(T, J, Q, T)
+filterbank = (anden_generator, {"alpha": 5, "r_psi": sqrt(1/2), "sigma0": 5})
+phi_f, psi1_f, psi2_f = scattering_filter_factory(N, J, Q, T, filterbank)
 
 ###############################################################################
 # The `phi_f` output is a dictionary where each integer key corresponds points
@@ -82,10 +91,10 @@
 
 plt.figure()
 plt.rcParams.update({"text.usetex": True})
-plt.plot(np.arange(T)/T, phi_f['levels'][0], 'r')
+plt.plot(np.arange(N)/N, phi_f['levels'][0], 'r')
 
 for psi_f in psi1_f:
-    plt.plot(np.arange(T)/T, psi_f['levels'][0], 'b')
+    plt.plot(np.arange(N)/N, psi_f['levels'][0], 'b')
 
 plt.xlim(0, 0.5)
 
@@ -100,9 +109,9 @@
 
 plt.figure()
 plt.rcParams.update({"text.usetex": True})
-plt.plot(np.arange(T)/T, phi_f['levels'][0], 'r')
+plt.plot(np.arange(N)/N, phi_f['levels'][0], 'r')
 for psi_f in psi2_f:
-    plt.plot(np.arange(T)/T, psi_f['levels'][0], 'b')
+    plt.plot(np.arange(N)/N, psi_f['levels'][0], 'b')
 plt.xlim(0, 0.5)
 plt.ylim(0, 1.2)
 plt.xlabel(r'$\omega$', fontsize=18)
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
@@ -48,29 +48,6 @@ def adaptive_choice_P(sigma, eps=1e-7):
     return P
 
 
-def periodize_filter_fourier(h_f, nperiods=1):
-    """
-    Computes a periodization of a filter provided in the Fourier domain.
-
-    Parameters
-    ----------
-    h_f : array_like
-        complex numpy array of shape (N*n_periods,)
-    n_periods: int, optional
-        Number of periods which should be used to periodize
-
-    Returns
-    -------
-    v_f : array_like
-        complex numpy array of size (N,), which is a periodization of
-        h_f as described in the formula:
-        v_f[k] = sum_{i=0}^{n_periods - 1} h_f[i * N + k]
-    """
-    N = h_f.shape[0] // nperiods
-    v_f = h_f.reshape(nperiods, N).mean(axis=0)
-    return v_f
-
-
 def morlet_1d(N, xi, sigma):
     """
     Computes the Fourier transform of a Morlet or Gauss filter.
@@ -106,12 +83,12 @@ def morlet_1d(N, xi, sigma):
     elif P > 1:
         freqs_low = freqs
     low_pass_f = np.exp(-(freqs_low**2) / (2 * sigma**2))
-    low_pass_f = periodize_filter_fourier(low_pass_f, nperiods=2 * P - 1)
+    low_pass_f = low_pass_f.reshape(2 * P - 1, -1).mean(axis=0)
     if xi:
         # define the gabor at freq xi and the low-pass, both of width sigma
         gabor_f = np.exp(-(freqs - xi)**2 / (2 * sigma**2))
         # discretize in signal <=> periodize in Fourier
-        gabor_f = periodize_filter_fourier(gabor_f, nperiods=2 * P - 1)
+        gabor_f = gabor_f.reshape(2 * P - 1, -1).mean(axis=0)
         # find the summation factor to ensure that morlet_f[0] = 0.
         kappa = gabor_f[0] / low_pass_f[0]
         filter_f = gabor_f - kappa * low_pass_f # psi (band-pass) case
@@ -219,7 +196,7 @@ def compute_temporal_support(h_f, criterion_amplitude=1e-3):
     return N
 
 
-def get_max_dyadic_subsampling(xi, sigma, alpha):
+def get_max_dyadic_subsampling(xi, sigma, alpha, **unused_kwargs):
     """
     Computes the maximal dyadic subsampling which is possible for a Gabor
     filter of frequency xi and width sigma
@@ -274,73 +251,78 @@ def compute_xi_max(Q):
     return xi_max
 
 
-def compute_params_filterbank(sigma_min, Q, r_psi=math.sqrt(0.5)):
+def anden_generator(J, Q, sigma0, r_psi, **unused_kwargs):
     """
-    Computes the parameters of a Morlet wavelet filterbank.
+    Yields the center frequencies and bandwidths of a filterbank, in compliance
+    with the ScatNet package. Center frequencies follow a geometric progression
+    of common factor 2**(1/Q) above a certain "elbow frequency" xi_elbow and
+    an arithmetic progression of common difference (1/Q) below xi_elbow.
+
+    The corresponding bandwidth sigma is proportional to center frequencies
+    for xi>=xi_elbow and are constant (sigma=sigma_min) for xi<xi_elbow.
+
+    The formula for xi_elbow is quite complicated and involves four hyperparameters
+    J, Q, r_psi, and sigma0:
+
+    xi_elbow = compute_xi_max(Q) * (sigma0/2**J)/compute_sigma_psi(xi, Q, r_psi)
+
+    where compute_xi_max and compute_sigma_psi are defined elsewhere in this module.
+
+    Intuitively, the role of xi_elbow is to make the filterbank as "wavelet-like"
+    as possible (common xi/sigma ratio) while guaranteeing a lower bound on sigma
+    (hence an upper bound on time support) and full coverage of the Fourier
+    domain between pi/2**J and pi.
 
-    This family is defined by constant ratios between the frequencies and
-    width of adjacent filters, up to a minimum frequency where the frequencies
-    are translated. sigma_min limits the smallest frequential width
-    among all filters, while preserving the coverage of the whole frequency
-    axis.
     Parameters
     ----------
-    sigma_min : float
-        This acts as a lower bound on the frequential widths of the band-pass
-        filters. The low-pass filter may be wider (if T < _N_padded), making
-        invariants over shorter time scales than longest band-pass filter.
+    J : int
+        log-scale of the scattering transform, such that wavelets of both
+        filterbanks have a maximal support that is proportional to 2**J.
+
     Q : int
-        number of wavelets per octave.
-    r_psi : float, optional
-        Should be >0 and <1. Controls the redundancy of the filters
-        (the larger r_psi, the larger the overlap between adjacent wavelets).
-        Defaults to sqrt(0.5).
-    Returns
-    -------
-    xis : list
-        central frequencies of the filters.
-    sigmas : list
-        bandwidths of the filters.
+        number of wavelets per octave in the geometric progression portion of
+        the filterbank.
+
+    r_psi : float in (0, 1)
+        Should be >0 and <1. The higher the r_psi, the greater the sigmas.
+        Adjacent wavelets peak at 1 and meet at r_psi.
+
+    sigma0 : float
+        Should be >0. The minimum bandwidth is sigma0/2**J.
     """
-    xi_max = compute_xi_max(Q)
-    sigma_max = compute_sigma_psi(xi_max, Q, r=r_psi)
+    xi = compute_xi_max(Q)
+    sigma = compute_sigma_psi(xi, Q, r=r_psi)
+    sigma_min = sigma0 / 2**J
 
-    if sigma_max <= sigma_min:
-        xis = []
-        sigmas = []
-        elbow_xi = sigma_max
+    if sigma <= sigma_min:
+        xi = sigma
     else:
-        xis =  [xi_max]
-        sigmas = [sigma_max]
-
+        yield xi, sigma
         # High-frequency (constant-Q) region: geometric progression of xi
-        while sigmas[-1] > (sigma_min * math.pow(2, 1/Q)):
-            xis.append(xis[-1] / math.pow(2, 1/Q))
-            sigmas.append(sigmas[-1] / math.pow(2, 1/Q))
-        elbow_xi = xis[-1]
+        while sigma > (sigma_min * math.pow(2, 1 / Q)):
+            xi /= math.pow(2, 1 / Q)
+            sigma /= math.pow(2, 1 / Q)
+            yield xi, sigma
 
     # Low-frequency (constant-bandwidth) region: arithmetic progression of xi
-    for q in range(1, Q):
-        xis.append(elbow_xi - q/Q * elbow_xi)
-        sigmas.append(sigma_min)
+    elbow_xi = xi
+    for q in range(Q-1):
+        xi -= 1/Q * elbow_xi
+        yield xi, sigma_min
 
-    return xis, sigmas
 
-
-def scattering_filter_factory(N, J, Q, T, r_psi=math.sqrt(0.5),
-                              max_subsampling=None, sigma0=0.1, alpha=5., **kwargs):
+def scattering_filter_factory(N, J, Q, T, filterbank):
     """
     Builds in Fourier the Morlet filters used for the scattering transform.
 
     Each single filter is provided as a dictionary with the following keys:
-    * 'xi': central frequency, defaults to 0 for low-pass filters.
-    * 'sigma': frequential width
-    * k where k is an integer bounded below by 0. The maximal value for k
-        depends on the type of filter, it is dynamically chosen depending
-        on max_subsampling and the characteristics of the filters.
-        Each value for k is an array (or tensor) of size 2**(J_support - k)
-        containing the Fourier transform of the filter after subsampling by
-        2**k
+    * 'xi': normalized center frequency, where 0.5 corresponds to Nyquist.
+    * 'sigma': normalized bandwidth in the Fourier.
+    * 'j': log2 of downsampling factor after filtering. j=0 means no downsampling,
+        j=1 means downsampling by one half, etc.
+    * 'levels': list of NumPy arrays containing the filter at various levels
+        of downsampling. levels[0] is at full resolution, levels[1] at half
+        resolution, etc.
 
     Parameters
     ----------
@@ -351,125 +333,59 @@ def scattering_filter_factory(N, J, Q, T, r_psi=math.sqrt(0.5),
         log-scale of the scattering transform, such that wavelets of both
         filterbanks have a maximal support that is proportional to 2**J.
     Q : tuple
-        number of wavelets per octave at the first and second order 
+        number of wavelets per octave at the first and second order
         Q = (Q1, Q2). Q1 and Q2 are both int >= 1.
     T : int
         temporal support of low-pass filter, controlling amount of imposed
         time-shift invariance and maximum subsampling
-    r_psi : float, optional
-        Should be >0 and <1. Controls the redundancy of the filters
-        (the larger r_psi, the larger the overlap between adjacent wavelets).
-        Defaults to sqrt(0.5).
-    max_subsampling: int or None, optional
-        maximal dyadic subsampling to compute, in order
-        to save computation time if it is not required. Defaults to None, in
-        which case this value is dynamically adjusted depending on the filters.
-    sigma0 : float, optional
-        parameter controlling the frequential width of the low-pass filter at
-        j=0; at a an absolute J, it is equal to sigma0 / 2**J. Defaults to 0.1
-    alpha : float, optional
-        tolerance factor for the aliasing after subsampling.
-        The larger alpha, the more conservative the value of maximal
-        subsampling is. Defaults to 5.
+    filterbank : tuple (callable filterbank_fn, dict filterbank_kwargs)
+        filterbank_fn should take J and Q as positional arguments and
+        **filterbank_kwargs as optional keyword arguments.
+        Corresponds to the self.filterbank property of the scattering object.
+        As of v0.3, only anden_generator is supported as filterbank_fn.
 
     Returns
     -------
-    phi_f : dictionary
-        a dictionary containing the low-pass filter at all possible
-        subsamplings. See above for a description of the dictionary structure.
-        The possible subsamplings are controlled by the inputs they can
-        receive, which correspond to the subsamplings performed on top of the
-        1st and 2nd order transforms.
-    psi1_f : dictionary
-        a dictionary containing the band-pass filters of the 1st order,
-        only for the base resolution as no subsampling is used in the
-        scattering tree.
-        Each value corresponds to a dictionary for a single filter, see above
-        for an exact description.
-        The keys of this dictionary are of the type (j, n) where n is an
-        integer counting the filters and j the maximal dyadic subsampling
-        which can be performed on top of the filter without aliasing.
-    psi2_f : dictionary
-        a dictionary containing the band-pass filters of the 2nd order
-        at all possible subsamplings. The subsamplings are determined by the
-        input they can receive, which depends on the scattering tree.
-        Each value corresponds to a dictionary for a single filter, see above
-        for an exact description.
-        The keys of this dictionary are of th etype (j, n) where n is an
-        integer counting the filters and j is the maximal dyadic subsampling
-        which can be performed on top of this filter without aliasing.
-
-    Refs
-    ----
-    Convolutional operators in the time-frequency domain, V. Lostanlen,
-    PhD Thesis, 2017
-    https://tel.archives-ouvertes.fr/tel-01559667
+    phi_f, psi1_f, psi2_f ... : dictionaries
+        phi_f corresponds to the low-pass filter and psi1_f, psi2_f, to the
+        wavelet filterbanks at layers 1 and 2 respectively.
+        See above for a description of the dictionary structure.
     """
-    # compute the spectral parameters of the filters
-    sigma_min = sigma0 / math.pow(2, J)
-    Q1, Q2 = Q
-    xi1s, sigma1s = compute_params_filterbank(sigma_min, Q1, r_psi)
-    j1s = [get_max_dyadic_subsampling(xi1, sigma1, alpha)
-        for xi1, sigma1 in zip(xi1s, sigma1s)]
-    xi2s, sigma2s = compute_params_filterbank(sigma_min, Q2, r_psi)
-    j2s = [get_max_dyadic_subsampling(xi2, sigma2, alpha)
-        for xi2, sigma2 in zip(xi2s, sigma2s)]
-
-    # width of the low-pass filter
-    sigma_low = sigma0 / T
-
-    # instantiate the dictionaries which will contain the filters
-    phi_f = {}
-    psi1_f = []
-    psi2_f = []
-
-    # compute the band-pass filters of the second order,
-    # which can take as input a subsampled
-    for (xi2, sigma2, j2) in zip(xi2s, sigma2s, j2s):
-        # compute the current value for the max_subsampling,
-        # which depends on the input it can accept.
-        if max_subsampling is None:
-            possible_subsamplings_after_order1 = [j1 for j1 in j1s if j2 > j1]
-            if len(possible_subsamplings_after_order1) > 0:
-                max_sub_psi2 = max(possible_subsamplings_after_order1)
-            else:
-                max_sub_psi2 = 0
-        else:
-            max_sub_psi2 = max_subsampling
-        # We first compute the filter without subsampling
-
-        psi_levels = [morlet_1d(N, xi2, sigma2)]
-        # compute the filter after subsampling at all other subsamplings
-        # which might be received by the network, based on this first filter
-        for level in range(1, max_sub_psi2 + 1):
-            nperiods = 2**level
-            psi_levels.append(periodize_filter_fourier(psi_levels[0], nperiods))
-        psi2_f.append({'levels': psi_levels, 'xi': xi2, 'sigma': sigma2, 'j': j2})
-
-    # for the 1st order filters, the input is not subsampled so we
-    # can only compute them with N=2**J_support
-    for (xi1, sigma1, j1) in zip(xi1s, sigma1s, j1s):
-        psi_levels = [morlet_1d(N, xi1, sigma1)]
-        psi1_f.append({'levels': psi_levels, 'xi': xi1, 'sigma': sigma1, 'j': j1})
-
-    # compute the low-pass filters phi
-    # Determine the maximal subsampling for phi, which depends on the
-    # input it can accept (both 1st and 2nd order)
+    filterbank_fn, filterbank_kwargs = filterbank
+    max_j = 0
+    previous_J = 0
     log2_T = math.floor(math.log2(T))
-    if max_subsampling is None:
-        max_subsampling_after_psi1 = max(j1s)
-        max_subsampling_after_psi2 = max(j2s)
-        max_sub_phi = min(max(max_subsampling_after_psi1,
-                              max_subsampling_after_psi2), log2_T)
-    else:
-        max_sub_phi = max_subsampling
+    psis_f = []
+
+    # Loop over scattering layers
+    for Q_layer in Q:
+        psi_f = []
+
+        # Loop over center frequencies xi and bandwidths sigma in the filterbank
+        for xi, sigma in filterbank_fn(J, Q_layer, **filterbank_kwargs):
+
+            # Construct filter at full resolution
+            psi_levels = [morlet_1d(N, xi, sigma)]
+            j = get_max_dyadic_subsampling(xi, sigma, **filterbank_kwargs)
+
+            # Resample to smaller resolutions if necessary (beyond 1st layer)
+            # The idiom min(previous_J, j, log2_T) implements "j1 < j2"
+            for level in range(1, min(previous_J, j, log2_T)):
+                psi_level = psi_levels[0].reshape(2 ** level, -1).mean(axis=0)
+                psi_levels.append(psi_level)
+            psi_f.append({'levels': psi_levels, 'xi': xi, 'sigma': sigma, 'j': j})
+            max_j = max(j, max_j)
+
+        # Keep score of how many resolutions will be needed in the next layer
+        previous_J = max_j
+        psis_f.append(psi_f)
 
-    # compute the filters at all possible subsamplings
+    # Same with low-pass filter phi
+    sigma_low = filterbank_kwargs["sigma0"] / T
     phi_levels = [gauss_1d(N, sigma_low)]
-    for level in range(1, max_sub_phi + 1):
-        nperiods = 2**level
-        phi_levels.append(periodize_filter_fourier(phi_levels[0], nperiods))
+    for level in range(1, max(previous_J, 1+log2_T)):
+        phi_level = phi_levels[0].reshape(2 ** level, -1).mean(axis=0)
+        phi_levels.append(phi_level)
     phi_f = {'levels': phi_levels, 'xi': 0, 'sigma': sigma_low, 'j': log2_T}
 
-    # return results
-    return phi_f, psi1_f, psi2_f
+    return tuple([phi_f] + psis_f)
diff --git a/kymatio/scattering1d/frontend/base_frontend.py b/kymatio/scattering1d/frontend/base_frontend.py
--- a/kymatio/scattering1d/frontend/base_frontend.py
+++ b/kymatio/scattering1d/frontend/base_frontend.py
@@ -6,7 +6,8 @@
 from warnings import warn
 
 from ..core.scattering1d import scattering1d
-from ..filter_bank import compute_temporal_support, gauss_1d, scattering_filter_factory
+from ..filter_bank import (compute_temporal_support, gauss_1d,
+    anden_generator, scattering_filter_factory)
 from ..utils import compute_border_indices, compute_padding
 
 
@@ -121,8 +122,7 @@ def build(self):
     def create_filters(self):
         # Create the filters
         self.phi_f, self.psi1_f, self.psi2_f = scattering_filter_factory(
-            self._N_padded, self.J, self.Q, self.T,
-            r_psi=self.r_psi, sigma0=self.sigma0, alpha=self.alpha)
+            self._N_padded, self.J, self.Q, self.T, self.filterbank)
         ScatteringBase._check_filterbanks(self.psi1_f, self.psi2_f)
 
     def scattering(self, x):
@@ -276,6 +276,12 @@ def N(self):
         "or access shape[0] for the unpadded length (previously N).", DeprecationWarning)
         return int(self.shape[0])
 
+    @property
+    def filterbank(self):
+        filterbank_kwargs = {
+            "alpha": self.alpha, "r_psi": self.r_psi, "sigma0": self.sigma0}
+        return (anden_generator, filterbank_kwargs)
+
     _doc_shape = 'N'
 
     _doc_instantiation_shape = {True: 'S = Scattering1D(J, N, Q)',


Test patch:
diff --git a/tests/scattering1d/test_filters_scattering1d.py b/tests/scattering1d/test_filters_scattering1d.py
--- a/tests/scattering1d/test_filters_scattering1d.py
+++ b/tests/scattering1d/test_filters_scattering1d.py
@@ -2,7 +2,7 @@
 Testing all functions in filters_bank
 """
 
-from kymatio.scattering1d.filter_bank import (adaptive_choice_P, periodize_filter_fourier,
+from kymatio.scattering1d.filter_bank import (adaptive_choice_P,
     compute_sigma_psi, compute_temporal_support, compute_xi_max, morlet_1d,
     get_max_dyadic_subsampling, gauss_1d)
 import numpy as np
@@ -31,24 +31,6 @@ def test_adaptive_choice_P():
             assert lim_right <= eps
 
 
-def test_periodize_filter_fourier(random_state=42):
-    """
-    Tests whether the periodization in Fourier corresponds to
-    a subsampling in time
-    """
-    rng = np.random.RandomState(random_state)
-    size_signal = [2**j for j in range(5, 10)]
-    periods = [2**k for k in range(0, 6)]
-
-    for N in size_signal:
-        x = rng.randn(N) + 1j * rng.randn(N)
-        x_f = np.fft.fft(x)
-        for per in periods:
-            x_per_f = periodize_filter_fourier(x_f, nperiods=per)
-            x_per = np.fft.ifft(x_per_f)
-            assert np.max(np.abs(x_per - x[::per])) < 1e-7
-
-
 def test_morlet_1d():
     """
     Tests for Morlet wavelets:
diff --git a/tests/scattering1d/test_numpy_scattering1d.py b/tests/scattering1d/test_numpy_scattering1d.py
--- a/tests/scattering1d/test_numpy_scattering1d.py
+++ b/tests/scattering1d/test_numpy_scattering1d.py
@@ -1,6 +1,7 @@
 import pytest
 from kymatio import Scattering1D
-from kymatio.scattering1d.filter_bank import scattering_filter_factory
+from kymatio.scattering1d.filter_bank import scattering_filter_factory, anden_generator
+import math
 import os
 import sys
 import io
@@ -89,16 +90,16 @@ def test_Scattering1D_filter_factory_T(self, backend):
         sigma_low_scale_factor = [0, 5]
         Js = [5]
 
+        filterbank_kwargs = {"alpha": 5, "r_psi": math.sqrt(1/2), "sigma0": 0.1}
+        filterbank = (anden_generator, filterbank_kwargs)
+
         for j in Js:
             J = j
             for i in sigma_low_scale_factor:
-                T=2**(J-i)
-                if i == 0:
-                    default_str = ' (default)'
-                else:
-                    default_str = ''
-                phi_f, psi1_f, psi2_f = scattering_filter_factory(N, J, Q, T)
-                assert(phi_f['sigma']==0.1/T)
+                T = 2 ** (J-i)
+                phi_f, psi1_f, psi2_f = scattering_filter_factory(
+                    N, J, Q, T, filterbank)
+                assert (phi_f['sigma'] == 0.1/T)
 
 
 frontends = ['numpy', 'sklearn']
@@ -129,4 +130,4 @@ def test_Q(backend, frontend):
     Sc_tuple_out = Sc_tuple.scattering(x)
 
     assert np.allclose(Sc_int_out, Sc_tuple_out)
-    assert Sc_int_out.shape == Sc_tuple_out.shape
\ No newline at end of file
+    assert Sc_int_out.shape == Sc_tuple_out.shape
diff --git a/tests/scattering1d/test_utils_scattering1d.py b/tests/scattering1d/test_utils_scattering1d.py
--- a/tests/scattering1d/test_utils_scattering1d.py
+++ b/tests/scattering1d/test_utils_scattering1d.py
@@ -3,7 +3,7 @@
 import pytest
 from kymatio import Scattering1D
 from kymatio.scattering1d.frontend.numpy_frontend import ScatteringNumPy1D
-from kymatio.scattering1d.filter_bank import (compute_params_filterbank,
+from kymatio.scattering1d.filter_bank import (compute_xi_max, compute_sigma_psi,
     get_max_dyadic_subsampling)
 from kymatio.scattering1d.utils import (compute_border_indices, compute_padding)
 
@@ -77,13 +77,39 @@ def test_meta():
         S.r_psi, S.sigma0, S.alpha)
 
 
+def legacy_compute_params_filterbank(sigma_min, Q, r_psi):
+    xi_max = compute_xi_max(Q)
+    sigma_max = compute_sigma_psi(xi_max, Q, r=r_psi)
+
+    if sigma_max <= sigma_min:
+        xis = []
+        sigmas = []
+        elbow_xi = sigma_max
+    else:
+        xis =  [xi_max]
+        sigmas = [sigma_max]
+
+        # High-frequency (constant-Q) region: geometric progression of xi
+        while sigmas[-1] > (sigma_min * math.pow(2, 1/Q)):
+            xis.append(xis[-1] / math.pow(2, 1/Q))
+            sigmas.append(sigmas[-1] / math.pow(2, 1/Q))
+        elbow_xi = xis[-1]
+
+    # Low-frequency (constant-bandwidth) region: arithmetic progression of xi
+    for q in range(1, Q):
+        xis.append(elbow_xi - q/Q * elbow_xi)
+        sigmas.append(sigma_min)
+
+    return xis, sigmas
+    
+
 def legacy_compute_meta_scattering(J, Q, max_order, r_psi, sigma0, alpha):
     sigma_min = sigma0 / math.pow(2, J)
     Q1, Q2 = Q
-    xi1s, sigma1s = compute_params_filterbank(sigma_min, Q1, r_psi)
+    xi1s, sigma1s = legacy_compute_params_filterbank(sigma_min, Q1, r_psi)
     j1s = [get_max_dyadic_subsampling(xi1, sigma1, alpha)
         for xi1, sigma1 in zip(xi1s, sigma1s)]
-    xi2s, sigma2s = compute_params_filterbank(sigma_min, Q2, r_psi)
+    xi2s, sigma2s = legacy_compute_params_filterbank(sigma_min, Q2, r_psi)
     j2s = [get_max_dyadic_subsampling(xi2, sigma2, alpha)
         for xi2, sigma2 in zip(xi2s, sigma2s)]