Skip to content

Commit d291c3d

Browse files
authored
DOC: clarify CWT normalization details around sampling_period (#842)
Closes issue #801
1 parent 114ad10 commit d291c3d

4 files changed

Lines changed: 168 additions & 0 deletions

File tree

doc/source/ref/cwt.rst

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,123 @@ of frequency directly.
256256

257257

258258
.. plot:: pyplots/cwt_scaling_demo.py
259+
260+
261+
.. _CWT normalization:
262+
263+
Normalization of the CWT coefficients
264+
-------------------------------------
265+
266+
``cwt`` works entirely in units of samples. Writing :math:`x[n]` for the input
267+
signal and taking both the scale :math:`a` and the translation :math:`b` to be
268+
expressed in samples, the returned coefficients are
269+
270+
.. math::
271+
272+
C[a, b] = \frac{1}{\sqrt{a}}\sum_n x[n]\,
273+
\psi^*\!\left(\frac{n - b}{a}\right).
274+
275+
No sampling interval appears in this expression. In particular, the
276+
``sampling_period`` argument of :func:`pywt.cwt` rescales only the returned
277+
``frequencies``; the coefficients themselves do not depend on it.
278+
279+
Relation to the continuous-time transform
280+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
281+
282+
The continuous-time definition of the CWT, with a scale :math:`a_s` and a
283+
translation :math:`b_s` in seconds, is
284+
285+
.. math::
286+
287+
W_x(a_s, b_s) = \frac{1}{\sqrt{a_s}}
288+
\int x(t)\,\psi^*\!\left(\frac{t - b_s}{a_s}\right)\,\mathrm{d}t.
289+
290+
For data sampled at :math:`t_n = n\,\mathrm{d}t`, approximating that integral by
291+
a Riemann sum introduces a factor :math:`\mathrm{d}t`. Substituting
292+
:math:`a_s = a\,\mathrm{d}t` and :math:`b_s = b\,\mathrm{d}t` then gives
293+
294+
.. math::
295+
296+
W_x(a_s, b_s) \approx \frac{\mathrm{d}t}{\sqrt{a\,\mathrm{d}t}}
297+
\sum_n x[n]\,\psi^*\!\left(\frac{n - b}{a}\right)
298+
= \sqrt{\mathrm{d}t}\, C[a, b].
299+
300+
So the coefficients returned by ``cwt`` have to be **multiplied** by
301+
:math:`\sqrt{\mathrm{d}t}` (equivalently, divided by :math:`\sqrt{f_s}`) to be
302+
expressed in physical-time units. PyWavelets does not apply that factor,
303+
because doing so would make the coefficients depend on ``sampling_period``.
304+
305+
A common source of confusion
306+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
307+
308+
A frequent mistake when comparing a hand-written convolution against ``cwt`` is
309+
to *mix* the two conventions above: to use a scale in seconds,
310+
:math:`a_s = a\,\mathrm{d}t`, in the :math:`1/\sqrt{a_s}` prefactor, while
311+
summing over raw samples without the :math:`\mathrm{d}t` coming from the
312+
integral. Such a result is too large by a factor
313+
:math:`1/\sqrt{\mathrm{d}t} = \sqrt{f_s}`, and an otherwise unexplained factor
314+
:math:`1/\sqrt{f_s}` then has to be inserted by hand to make the amplitudes
315+
agree with ``cwt``. Either keep everything in samples, as :math:`C[a, b]` does,
316+
or write out the full Riemann sum including :math:`\mathrm{d}t` — but do not
317+
combine the two.
318+
319+
The example below evaluates :math:`C[a, b]` directly and compares it to
320+
``cwt``:
321+
322+
.. try_examples::
323+
324+
>>> import numpy as np
325+
>>> import pywt
326+
>>> fs = 3000.0 # sampling rate in Hz
327+
>>> t = np.arange(3000) / fs
328+
>>> x = np.sin(2 * np.pi * 40 * t)
329+
>>> wavelet = pywt.ContinuousWavelet('cmor14-2')
330+
>>> a = pywt.frequency2scale(wavelet, 40 / fs) # scale, in samples
331+
>>> coefs, freqs = pywt.cwt(x, a, wavelet, sampling_period=1 / fs)
332+
333+
Now the same transform, written out as a convolution over samples. ``Fb`` and
334+
``Fc`` are the bandwidth and center frequency of ``'cmor14-2'``:
335+
336+
>>> Fb, Fc = 14.0, 2.0
337+
>>> lb, ub = wavelet.lower_bound, wavelet.upper_bound
338+
>>> k = np.arange(int(a * (ub - lb)) + 1)
339+
>>> u = lb + (k + 0.5) / a
340+
>>> psi = np.exp(2j*np.pi*Fc*u) * np.exp(-u**2 / Fb) / np.sqrt(np.pi * Fb)
341+
>>> conv = np.convolve(x, np.conj(psi)[::-1]) / np.sqrt(a)
342+
>>> trim = (conv.size - x.size) // 2
343+
>>> manual = conv[trim:trim + x.size]
344+
>>> rel = np.max(np.abs(manual - coefs[0])) / np.max(np.abs(coefs[0]))
345+
>>> bool(rel < 1e-3)
346+
True
347+
348+
Note that :math:`\psi` is conjugated *and* reversed before the convolution, and
349+
that it is evaluated at bin midpoints, ``(k + 0.5) / a``. The half-sample offset
350+
is there because ``cwt`` convolves with the integral of :math:`\psi` and then
351+
differences the result, which effectively averages :math:`\psi` over each
352+
sample bin rather than sampling it pointwise.
353+
354+
Limits of the agreement
355+
^^^^^^^^^^^^^^^^^^^^^^^
356+
357+
:math:`C[a, b]` is what ``cwt`` computes in the limit of a finely sampled
358+
wavelet. Two discretization effects prevent an analytic implementation from
359+
reproducing it exactly:
360+
361+
* :math:`\psi` is only evaluated over
362+
``[wavelet.lower_bound, wavelet.upper_bound]``, so its tails are truncated
363+
(see :ref:`Choosing scales`). With the default bounds of :math:`[-8, 8]`,
364+
``cmor14-2`` still retains about 1% of its peak amplitude at the edges, and
365+
that truncation dominates the residual in the example above.
366+
* ``cwt`` resamples the precomputed integral of :math:`\psi` onto the grid for a
367+
given scale by truncated indexing rather than by interpolation. The resulting
368+
jitter grows with the scale and is reduced by raising ``precision``; it is the
369+
"zipper-like" effect mentioned in the ``precision`` documentation of
370+
:func:`pywt.cwt`. It largely averages out for narrowband signals such as the
371+
one above, but is clearly visible for broadband input.
372+
373+
Finally, note that the amplitude of the wavelets themselves does not follow a
374+
single convention across families — ``mexh`` and ``gaus`` are normalized to unit
375+
energy, ``morl`` carries no normalization constant at all, and ``cmor``,
376+
``shan`` and ``fbsp`` use yet other conventions (see the formulas above). CWT
377+
coefficient magnitudes are therefore not directly comparable between wavelet
378+
families.

doc/source/ref/other-functions.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Central frequency of ``psi`` wavelet function
3232

3333
.. autofunction:: scale2frequency
3434

35+
.. autofunction:: frequency2scale
36+
3537

3638
Quadrature Mirror Filter
3739
------------------------

pywt/_cwt.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@ def cwt(data, scales, wavelet, sampling_period=1., method='conv', axis=-1,
8181
Size of coefficients arrays depends on the length of the input array and
8282
the length of given scales.
8383
84+
The transform is computed entirely in units of samples. With the scale
85+
``a`` and the translation ``b`` both expressed in samples, the returned
86+
coefficients are::
87+
88+
C[a, b] = 1/sqrt(a) * sum_n data[n] * conj(psi((n - b)/a))
89+
90+
No sampling interval enters this expression, which is why ``coefs`` does
91+
not depend on ``sampling_period``. To express the coefficients in
92+
physical-time units instead, multiply them by ``sqrt(dt)`` (equivalently,
93+
divide by ``sqrt(fs)``); that factor comes from approximating the integral
94+
of the continuous-time transform by a Riemann sum. See the
95+
:ref:`CWT normalization` section of the documentation for details.
96+
8497
Examples
8598
--------
8699
>>> import pywt

pywt/tests/test_cwt_wavelets.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,39 @@ def test_cwt_method_fft_complex_data_real_wavelet(dtype, tol):
506506
assert_allclose(cfs_conv, cfs_fft, rtol=tol, atol=tol)
507507

508508

509+
def test_cwt_normalization_convention():
510+
# cwt works in units of samples: with the scale a and the shift b both
511+
# given in samples, coefs[a, b] == 1/sqrt(a) * sum_n x[n] conj(psi((n-b)/a))
512+
# and no sampling interval enters.
513+
fs = 3000.
514+
Fb, Fc = 14., 2.
515+
wavelet = pywt.ContinuousWavelet(f'cmor{Fb:g}-{Fc:g}')
516+
x = np.sin(2 * np.pi * 40 * np.arange(3000) / fs)
517+
scale = pywt.frequency2scale(wavelet, 40 / fs)
518+
519+
cfs, freqs = pywt.cwt(x, scale, wavelet, sampling_period=1 / fs)
520+
assert_allclose(freqs, [40.], rtol=1e-12)
521+
522+
# psi is sampled at bin midpoints because cwt convolves with the integral
523+
# of psi and then differences it, which averages psi over each sample bin.
524+
lb, ub = wavelet.lower_bound, wavelet.upper_bound
525+
k = np.arange(int(scale * (ub - lb)) + 1)
526+
psi, _ = ref_cmor(lb + 0.5 / scale, ub + 0.5 / scale, k.size, Fb, Fc)
527+
# the filter is conjugated *and* reversed; convolving with conj(psi) alone
528+
# gives the complex conjugate of the correct result, which an abs()
529+
# comparison would not catch
530+
conv = np.convolve(x, np.conj(psi)[::-1]) / np.sqrt(scale)
531+
trim = (conv.size - x.size) // 2
532+
manual = conv[trim:trim + x.size]
533+
534+
assert_allclose(manual, cfs[0], atol=1e-3 * np.max(np.abs(cfs[0])))
535+
536+
# the coefficients themselves are unaffected by sampling_period
537+
cfs_unit, freqs_unit = pywt.cwt(x, scale, wavelet, sampling_period=1.)
538+
assert_allclose(cfs_unit, cfs, rtol=0, atol=0)
539+
assert_allclose(freqs_unit * fs, freqs, rtol=1e-12)
540+
541+
509542
def test_continuous_wavelet_pickle(tmpdir):
510543
wavelet = pywt.ContinuousWavelet('cmor1.5-1.0')
511544
filename = os.path.join(tmpdir, 'cwav.pickle')

0 commit comments

Comments
 (0)