Whitening with Eigenvalue Decomposition (EVD)#

Example of the whitening process, assuming the transmitted signal \( s_m \) is a 4-QAM signal, and the whitening matrix \( W \) is computed via eigenvalue decomposition (EVD).

Signal Modelling#

Suppose we have the received vector (for simplicity, assume length \(N\)):

\[ \vec{r} = s_m + \vec{n} \]
  • \( s_m \) is a symbol from a 4-QAM constellation, such as:

\[ s_m \in \left\{ (\pm 1 \pm j) \right\} \]
  • \( \vec{n} \) is a colored noise vector with covariance:

\[ R_n = \mathbb{E}[\vec{n}\,\vec{n}^H] \quad\text{(not diagonal; correlated noise)} \]

Compute Noise Covariance Matrix \( R_n \)#

In practice, the covariance matrix \( R_n \) of the noise is known or estimated:

\[ R_n = \mathbb{E}[\vec{n}\vec{n}^H] \]

Typically, this matrix is Hermitian (i.e., \(R_n = R_n^H\)), and positive definite.

Eigenvalue Decomposition of \( R_n \)#

Perform Eigenvalue Decomposition (EVD) of \( R_n \):

\[ R_n = U \Lambda U^H \]
  • \( U \) is a unitary matrix whose columns are the eigenvectors of \( R_n \).

  • \( \Lambda \) is a diagonal matrix containing positive eigenvalues of \( R_n \):

\[ \Lambda = \text{diag}(\lambda_1, \lambda_2, \dots, \lambda_N) \]

These eigenvalues represent the noise power distribution across directions.

Construct the Whitening Matrix \( W \)#

The whitening filter \( W \) is constructed as:

\[ W = \Lambda^{-1/2} U^H \]

where:

  • \( \Lambda^{-1/2} \) is a diagonal matrix obtained by taking the inverse square root of each eigenvalue:

\[ \Lambda^{-1/2} = \text{diag}\left(\frac{1}{\sqrt{\lambda_1}}, \frac{1}{\sqrt{\lambda_2}}, \dots, \frac{1}{\sqrt{\lambda_N}}\right) \]

Thus:

\[ W = \Lambda^{-1/2} U^H \]

This transformation aligns the noise eigenvectors and normalizes the variance to unity, removing correlations.

Apply Whitening Matrix to Received Signal#

Transform the received signal \(\vec{r}\) using the whitening matrix \(W\):

\[ \vec{\rho} = W \vec{r} = W(s_m + \vec{n}) = W s_m + W \vec{n} \]

Define:

  • \( W s_m \): Transformed signal (still from 4-QAM, but rotated/scaled).

  • \( W \vec{n} = \vec{v} \): White noise vector (now has identity covariance \( I \)).

Thus, we have:

\[ \vec{\rho} = W s_m + \vec{v}, \quad \vec{v} \sim \mathcal{CN}(0,I) \]

After whitening, the noise is uncorrelated with identical variance (white noise).

Perform Detection on the Whitened Signal#

Since \(\vec{\rho}\) now has white noise, optimal detection simplifies to comparing against transformed 4-QAM constellation points:

  • Compute reference points \( W s_m \) for each possible transmitted 4-QAM symbol.

  • Apply a standard Euclidean-distance-based detector:

\[ \hat{s}_m = \arg\min_{s_m} \|\vec{\rho} - W s_m\|^2 \]

This step is now straightforward due to the whitened noise structure.

Recover Original Signal#

If the goal includes reconstructing the original received vector from the whitened vector, simply apply the inverse of \( W \):

\[ \vec{r} = W^{-1}\vec{\rho} \]

Since \(W\) is invertible, no information loss occurs.

Simulation#

  1. Set the noise covariance matrix \(R_n\).

  2. Eigen-decompose \(R_n = U\Lambda U^H\).

  3. Construct \(W = \Lambda^{-1/2}U^H\).

  4. Whiten received vector: \(\vec{\rho}= W \vec{r}\).

  5. Detect the symbol from whitened vector \(\vec{\rho}\).

Simulation Setup#

  • 4-QAM Constellation:
    The constellation points are:

    \[ \{[1, 1], [1, -1], [-1, 1], [-1, -1]\} \]

    Each point represents 2 bits:

    • [1, 1] → 00

    • [1, -1] → 01

    • [-1, 1] → 10

    • [-1, -1] → 11

  • Noise:
    The noise vector \(\vec{n}\) has a covariance matrix:

    \[\begin{split} R_n = \begin{bmatrix} 4 & 2 \\ 2 & 3 \end{bmatrix} \end{split}\]

    The noise power is scaled to vary the Signal-to-Noise Ratio (SNR).

  • Detector:

    • A Nearest Neighbor Detector is used.

    • Symbols are detected by selecting the constellation point minimizing the Euclidean distance.

Simulation Steps:#

  1. Generate random 4-QAM symbols and colored noise for different SNR values.

  2. Compute received signal:

    \[ \vec{r} = s_m + \vec{n} \]
  3. Apply whitening filter:

    \[ \vec{\rho} = W \vec{r} \]
  4. Perform detection:

    • Direct detection using \(\vec{r}\) (against the original constellation).

    • Detection using \(\vec{\rho}\) (against the whitened constellation \(W s_m\)).

  5. Calculate the Bit Error Rate (BER):

    • Compare detected bits to transmitted bits.

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import gaussian_kde

# Set random seed for reproducibility
np.random.seed(42)

# Parameters
qam_symbols = np.array([[1, 1], [1, -1], [-1, 1], [-1, -1]])  # 4-QAM [Re, Im]
bit_mapping = {tuple([1, 1]): [0, 0], tuple([1, -1]): [0, 1], 
               tuple([-1, 1]): [1, 0], tuple([-1, -1]): [1, 1]}  # Original symbol to bits
inv_bit_mapping = {(0, 0): [1, 1], (0, 1): [1, -1], 
                   (1, 0): [-1, 1], (1, 1): [-1, -1]}  # Bits to original symbol
num_bits = 100000  # Total bits
num_symbols = num_bits // 2  # 2 bits per symbol
Eb_N0_dB_range = np.arange(0, 15, 2)  # Eb/N0 range in dB
Eb_N0_dB_for_pdf = 6  # Eb/N0 for PDF comparison
import numpy as np

# Noise covariance
R_n_base = np.array([[4, 2],
                [2, 3]], dtype=float)

# Eigen-decomposition
vals, vecs = np.linalg.eig(R_n_base)

# Sort eigenvalues (descending) and reorder eigenvectors accordingly
idx = np.argsort(vals)[::-1]    # indices that would sort eigenvalues descending
vals = vals[idx]
vecs = vecs[:, idx]

# Construct Lambda^{-1/2}  (inverse sqrt of eigenvalues)
Lambda_inv_sqrt = np.diag(1.0 / np.sqrt(vals))

# W = Lambda^{-1/2} * U^T
W = Lambda_inv_sqrt @ vecs.T
W_inv = np.linalg.inv(W)  # Inverse of W

# Display results
print("\nNumerical Whitening Filter:")
print(W)

# Verify
verification = W @ R_n_base @ W.T
print("\nVerification (W R_n W^T, should be close to I):")
print(verification)
Numerical Whitening Filter:
[[ 0.33422689  0.26095647]
 [-0.51312024  0.6571923 ]]

Verification (W R_n W^T, should be close to I):
[[ 1.00000000e+00 -1.31007558e-16]
 [-1.52948929e-16  1.00000000e+00]]

We manually derive the whitening matrix \( W \) for the given noise covariance matrix:

\[\begin{split} R_n = \begin{bmatrix} 4 & 2 \\ 2 & 3 \end{bmatrix}. \end{split}\]

Compute Eigenvalues of \( R_n \)

The eigenvalues are found by solving:

\[ \det(R_n - \lambda I) = 0. \]

Expanding the determinant,

\[\begin{split} \begin{vmatrix} 4 - \lambda & 2 \\ 2 & 3 - \lambda \end{vmatrix} = (4 - \lambda)(3 - \lambda) - (2)(2) = 0. \end{split}\]
\[ (4 - \lambda)(3 - \lambda) - 4 = 12 - 7\lambda + \lambda^2 - 4 = \lambda^2 - 7\lambda + 8 = 0. \]

Solving the quadratic equation,

\[ \lambda = \frac{7 \pm \sqrt{(-7)^2 - 4(1)(8)}}{2(1)} = \frac{7 \pm \sqrt{49 - 32}}{2} = \frac{7 \pm \sqrt{17}}{2}. \]

Approximating,

\[ \lambda_1 \approx 5.5616, \quad \lambda_2 \approx 1.4384. \]

Compute Eigenvectors of \( R_n \)

For \( \lambda_1 = \frac{7 + \sqrt{17}}{2} \), solve:

\[\begin{split} \begin{bmatrix} 4 - \lambda_1 & 2 \\ 2 & 3 - \lambda_1 \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} = 0. \end{split}\]

Substituting \( \lambda_1 \approx 5.5616 \):

\[\begin{split} \begin{bmatrix} -1.5616 & 2 \\ 2 & -2.5616 \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix} = 0. \end{split}\]

Setting the first equation \( -1.5616 x + 2y = 0 \) gives:

\[ y = \frac{1.5616}{2} x = 0.7808 x. \]

Choosing \( x = 1 \), we get:

\[\begin{split} \vec{u}_1 = \begin{bmatrix} 1 \\ 0.7808 \end{bmatrix}. \end{split}\]

Normalizing:

\[ \|\vec{u}_1\| = \sqrt{1^2 + 0.7808^2} = \sqrt{1.6096} \approx 1.269. \]
\[\begin{split} \vec{u}_1 = \frac{1}{1.269} \begin{bmatrix} 1 \\ 0.7808 \end{bmatrix} = \begin{bmatrix} 0.7882 \\ 0.6154 \end{bmatrix}. \end{split}\]

For \( \lambda_2 = \frac{7 - \sqrt{17}}{2} \), solving similarly, we get:

\[\begin{split} \vec{u}_2 = \begin{bmatrix} -0.6154 \\ 0.7882 \end{bmatrix}. \end{split}\]

Form \( \Lambda^{-1/2} \)

\[\begin{split} \Lambda^{-1/2} = \begin{bmatrix} \frac{1}{\sqrt{\lambda_1}} & 0 \\ 0 & \frac{1}{\sqrt{\lambda_2}} \end{bmatrix}. \end{split}\]

Approximating:

\[ \sqrt{\lambda_1} \approx \sqrt{5.5616} \approx 2.36, \quad \sqrt{\lambda_2} \approx \sqrt{1.4384} \approx 1.2. \]
\[\begin{split} \Lambda^{-1/2} = \begin{bmatrix} 1/2.36 & 0 \\ 0 & 1/1.2 \end{bmatrix} = \begin{bmatrix} 0.4237 & 0 \\ 0 & 0.8333 \end{bmatrix}. \end{split}\]

Compute Whitening Matrix \( W = \Lambda^{-1/2} U^T \)

\[\begin{split} W = \begin{bmatrix} 0.4237 & 0 \\ 0 & 0.8333 \end{bmatrix} \begin{bmatrix} 0.7882 & -0.6154 \\ 0.6154 & 0.7882 \end{bmatrix}. \end{split}\]

Multiplying:

\[\begin{split} W = \begin{bmatrix} (0.4237 \times 0.7882) & (0.4237 \times -0.6154) \\ (0.8333 \times 0.6154) & (0.8333 \times 0.7882) \end{bmatrix}. \end{split}\]
\[\begin{split} W = \begin{bmatrix} 0.3342 & -0.2610 \\ 0.5131 & 0.6572 \end{bmatrix}. \end{split}\]

Thus, the exact numeric whitening matrix \( W \) is:

\[\begin{split} \boxed{ W = \begin{bmatrix} 0.3342 & -0.2610 \\ 0.5131 & 0.6572 \end{bmatrix}. } \end{split}\]

Note. NumPy’s np.linalg.eigh sorts eigenvalues in ascending order (\(\lambda_1 = 1, \lambda_2 = 3\)), which swaps the columns of \( U \) and the diagonal elements of \(\Lambda\).

This is the standard eigenvector sign ambiguity.

In an eigen‐decomposition, each eigenvector is only determined up to a factor of \(\pm1\). Flipping the sign of one eigenvector column (or row in \(W\)) does not change the fact that it is a valid whitening transform.

Concretely, if \(\mathbf{v}\) is an eigenvector, then so is \(-\mathbf{v}\). In the whitening matrix \(W = \Lambda^{-\tfrac12}U^T\), flipping one column’s sign in \(U\) flips the corresponding row in \(W\). Either choice yields the same whitening effect, because

\[ W \, R_{n_{\text{base}}}\,W^T \;=\; (\pm W) \,R_{n_{\text{base}}} \,(\pm W)^T \;=\; (\pm1)^2\,\bigl[\,W\,R_{n_{\text{base}}}\,W^T\bigr] \;=\; I. \]

So the “Numerical” \(W\) and “Theoretical” \(W\) differ only by a sign flip in the second row, but both correctly whiten the covariance.

# Whitened constellation
qam_symbols_whitened = (W @ qam_symbols.T).T

# Energy per bit
Es = np.mean(np.sum(qam_symbols**2, axis=1))  # Symbol energy = 2
Eb = Es / 2  # Energy per bit = 1

# Detection function
def detect_symbols(received, constellation):
    detected = np.zeros(received.shape, dtype=float)
    for i in range(received.shape[1]):
        distances = np.sum((constellation - received[:, i][:, np.newaxis])**2, axis=0)
        detected[:, i] = constellation[:, np.argmin(distances)]
    return detected

# Demodulate symbols to bits
def demodulate_symbols(detected_symbols, constellation, bit_mapping, is_whitened=False, W_inv=None):
    if is_whitened:
        detected_original = W_inv @ detected_symbols
        detected_clean = detect_symbols(detected_original, qam_symbols.T)
    else:
        detected_clean = detect_symbols(detected_symbols, qam_symbols.T)
    return np.array([bit_mapping[tuple(detected_clean[:, i])] 
                     for i in range(detected_clean.shape[1])]).flatten()

# Function to plot scatter and PDFs
def plot_comparison(data1, data2, label1, label2, title):
    plt.figure(figsize=(12, 5))

    # Scatter plot
    plt.subplot(1, 2, 1)
    plt.scatter(data1[0, :], data1[1, :], alpha=0.2, label=label1, s=10)
    plt.scatter(data2[0, :], data2[1, :], alpha=0.2, label=label2, s=10)
    plt.xlabel('Real/Component 1')
    plt.ylabel('Imag/Component 2')
    plt.legend()
    plt.title(f'Scatter: {title}')

    # PDFs
    plt.subplot(1, 2, 2)
    for i, comp in enumerate(['Real', 'Imag']):
        kde1 = gaussian_kde(data1[i, :])
        kde2 = gaussian_kde(data2[i, :])
        x = np.linspace(min(data1[i, :].min(), data2[i, :].min()), 
                        max(data1[i, :].max(), data2[i, :].max()), 100)
        plt.plot(x, kde1(x), label=f'{label1} {comp}')
        plt.plot(x, kde2(x), label=f'{label2} {comp}')
    plt.xlabel('Value')
    plt.ylabel('Density')
    plt.legend()
    plt.title(f'PDFs: {title}')

    plt.tight_layout()
    plt.show()
# Generate binary sequence and map to 4-QAM
bits_tx = np.random.randint(0, 2, num_bits)
s_m_samples = np.array([inv_bit_mapping[tuple(bits_tx[2*i:2*i+2])] 
                        for i in range(num_symbols)]).T

# Simulate for BER
ber_colored = []
ber_whitened = []

for Eb_N0_dB in Eb_N0_dB_range:
    Eb_N0_linear = 10**(Eb_N0_dB / 10)
    N0 = Eb / Eb_N0_linear
    noise_power = N0
    scale = np.sqrt(noise_power / (np.trace(R_n_base) / 2))
    R_n = R_n_base * scale**2
    L = np.linalg.cholesky(R_n)

    n_samples = L @ np.random.randn(2, num_symbols)
    r_samples = s_m_samples + n_samples
    rho_samples = W @ r_samples

    detected_r = detect_symbols(r_samples, qam_symbols.T)
    detected_rho = detect_symbols(rho_samples, qam_symbols_whitened.T)

    bits_rx_r = demodulate_symbols(detected_r, qam_symbols.T, bit_mapping, is_whitened=False)
    bits_rx_rho = demodulate_symbols(detected_rho, qam_symbols_whitened.T, bit_mapping, 
                                     is_whitened=True, W_inv=W_inv)

    ber_colored.append(np.mean(bits_tx != bits_rx_r))
    ber_whitened.append(np.mean(bits_tx != bits_rx_rho))

    # For PDF comparison at Eb/N0 = 6 dB
    if Eb_N0_dB == Eb_N0_dB_for_pdf:
        v_samples = W @ n_samples
        plot_comparison(n_samples, v_samples, 'n (colored)', 'v (white)', 'Noise Comparison')
        plot_comparison(r_samples, rho_samples, 'r (received)', 'rho (whitened)', 'Received Signal Comparison')

# Print BER values
print("Eb/N0 (dB) | BER (Colored) | BER (Whitened)")
for snr, bc, bw in zip(Eb_N0_dB_range, ber_colored, ber_whitened):
    print(f"{snr:8d}   | {bc:.6f}    | {bw:.6f}")
_images/57470600214fde3dfe0fea90fe7b345589b6b9847ae2667365daec17bbe0b5a5.png _images/b1db037b6042137fc79e063c4757886503f3a85786413e1a00549641aaf70271.png
Eb/N0 (dB) | BER (Colored) | BER (Whitened)
       0   | 0.159970    | 0.135340
       2   | 0.102530    | 0.082180
       4   | 0.056700    | 0.039330
       6   | 0.023190    | 0.012570
       8   | 0.006180    | 0.002110
      10   | 0.000960    | 0.000180
      12   | 0.000030    | 0.000000
      14   | 0.000000    | 0.000000
# Plot BER vs Eb/N0
plt.figure(figsize=(8, 6))
plt.semilogy(Eb_N0_dB_range, ber_colored, 'b-o', label='Colored Noise (r)')
plt.semilogy(Eb_N0_dB_range, ber_whitened, 'r-^', label='Whitened Noise (rho)')
plt.xlabel('Eb/N0 (dB)')
plt.ylabel('Bit Error Rate (BER)')
plt.title('BER vs Eb/N0: Colored vs Whitened Noise (Binary Sequence)')
plt.grid(True, which="both")
plt.legend()
plt.show()
_images/0b01502ecac8f2c764644475abe74d5f3f675b26e7caf0c15f3792103bce5677.png

Due to the numerical mismatch, the whitened BER is not similar to AWGN BER in this simulation.