Practical Session: KKT Conditions
The "Water-Filling" Algorithm in Telecommunications
This practical session focuses on solving a constrained optimization problem analytically using KKT conditions and numerically using Python. We will derive and implement the famous "Water-Filling" algorithm for power allocation.
To implement a constrained optimization problem, solve it analytically via KKT conditions, and compare the custom algorithm against a generic numerical solver (scipy.optimize).
1. Problem Statement: Power Allocation
Consider a transmitter with a total power budget to be distributed among parallel communication channels. Each channel has a specific gain .
Goal: Maximize the system capacity (data rate) subject to power constraints.
The Objective Function
Based on the Shannon-Hartley theorem, the capacity is:
Where is the power allocation vector.
The Constraints
- Budget Constraint: The total power is fixed.
- Positivity Constraint: Power cannot be negative.
2. Mathematical Analysis (Part 1)
Before coding, we derive the optimal structure using KKT conditions.
Standard Form
First, convert the maximization problem to a minimization problem:
Subject to:
Lagrangian
We define the Lagrangian with multipliers (for equality) and (for inequality):
(Note: We use instead of for easier derivation, scaling by a constant).
KKT Derivation
From the Stationarity condition ():
From Complementary Slackness ():
If , then . The equation becomes , which implies .
If optimal , the condition holds directly.
Combining these, we get the Water-Filling Solution:
Here, acts as the "water level" and is the "noise level" (ground). We pour power (water) into channels with low noise until the budget is used.
3. Python Implementation (Part 2)
We will implement two solvers: a generic one (scipy) and our custom KKT-based one.
Step 1: Setup
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import minimize
# 1. Setup
N = 10
P_tot = 10.0
np.random.seed(42)
g = np.random.uniform(0.1, 2.0, N) # Channel gains
noise_levels = 1.0 / g
Step 2: Generic Solver (SciPy)
We use SLSQP to handle the constraints numerically.
# Objective: Minimize negative capacity
def objective(p):
return -np.sum(np.log2(1 + g \* p))
# Constraints
constraints = ({'type': 'eq', 'fun': lambda p: np.sum(p) - P*tot})
bounds = [(0, None) for * in range(N)] # p_i >= 0
# Solve
result = minimize(objective, x0=np.ones(N) \* P_tot/N, method='SLSQP', bounds=bounds, constraints=constraints)
p_scipy = result.x
print(f"SciPy Allocation: {p_scipy}")
Step 3: Custom KKT Solver (Water-Filling)
We need to find the specific water level (or rather, the level ) such that the sum of powers equals .
Since the total power is strictly increasing with , we can use Binary Search (Bisection).
def water_filling_solver(g, P_tot):
noise = 1.0 / g
# Bisection search for water level L (1/nu)
low, high = 0.0, np.max(noise) + P_tot
tolerance = 1e-6
for _ in range(100): # Max iterations
level = (low + high) / 2
p_temp = np.maximum(0, level - noise)
if np.sum(p_temp) > P_tot:
high = level # Too much power, lower the level
else:
low = level # Too little power, raise the level
if np.abs(np.sum(p_temp) - P_tot) < tolerance:
break
return np.maximum(0, level - noise), level
p_kkt, water_level = water_filling_solver(g, P_tot)
print(f"KKT Allocation: {p_kkt}")
Step 4: Verification & Visualization
Compare the results and visualize the "water filling" effect.
# 1. Verification
error = np.linalg.norm(p_scipy - p_kkt)
print(f"Euclidean distance between solutions: {error:.2e}")
# 2. Visualization
plt.figure(figsize=(10, 6))
indices = np.arange(N)
width = 0.6
# Stacked bar chart
plt.bar(indices, noise_levels, width, label='Noise Level (1/g)', color='lightgray')
plt.bar(indices, p_kkt, width, bottom=noise_levels, label='Allocated Power', color='skyblue')
# Water level line
plt.axhline(y=water_level, color='blue', linestyle='--', label='Water Level')
plt.xlabel('Channel Index')
plt.ylabel('Power / Level')
plt.title('Water-Filling Algorithm Visualization')
plt.legend()
plt.grid(axis='y', linestyle='--', alpha=0.7)
plt.show()
You should observe that the allocated power "fills up" the valleys created by the noise levels up to a constant water level. Channels with very high noise () receive zero power, matching the KKT derivation.