import numpy as np
import matplotlib.pyplot as plt

np.random.seed(228)

# Parameters
n = 100  # Dimension of x
mu = 10
L = 100


def generate_problem(n=100, mu=mu, L=L, problem_type="clustered"):
    np.random.seed(228)
    if problem_type == "clustered":
        

    elif problem_type == "random":
        A = np.random.randn(n, n)
        factual_L = max(np.linalg.eigvalsh(A.T@A))
        A = A.T.dot(A)/factual_L*L + mu*np.eye(n)
        x_opt = np.random.rand(n)
        b = A@x_opt
        x_0 = 3*np.random.randn(n)
    
    elif problem_type == "uniform spectrum":
        A = np.diag(np.linspace(mu, L, n, endpoint=True))
        x_opt = np.random.rand(n)
        b = A@x_opt
        x_0 = 3*np.random.randn(n)

    elif problem_type == "Hilbert":
        A = np.array([[1.0 / (i+j - 1) for i in range(1, n+1)] for j in range(1, n+1)])
        b = np.ones(n)
        x_0 = 3*np.random.randn(n)
        x_opt = np.linalg.lstsq(A, b)[0]

    elif problem_type == "worst_cg":
        # Parameter t controls the condition number
        t = 0.6  # Small t leads to worse conditioning
        # Create tridiagonal matrix W
        main_diag = np.ones(n)
        main_diag[0] = t
        main_diag[1:] = 1 + t
        off_diag = np.sqrt(t) * np.ones(n-1)
        A = np.diag(main_diag) + np.diag(off_diag, k=1) + np.diag(off_diag, k=-1)
        
        # Create b vector [1, 0, ..., 0]
        b = np.zeros(n)
        b[0] = 1
        
        # Since this is a specific problem, we compute x_opt explicitly
        x_opt = np.linalg.solve(A, b)
        x_0 = np.zeros(n)  # Start from zero vector
        return A, b, x_0, x_opt

    return A, b, x_0, x_opt

# Optimization methods
def gradient_descent(f, grad_f, x_0, step_size, iterations, x_opt):
    x = x_0.copy()
    f_opt = f(x_opt)
    values, gradients = [], []
    values.append(abs(f(x) - f_opt))
    gradients.append(np.linalg.norm(grad_f(x)))
    for _ in range(iterations):
        x -= step_size * grad_f(x)
        values.append(abs(f(x) - f_opt))
        gradients.append(np.linalg.norm(grad_f(x)))
    return values, gradients

def steepest_descent(A, f, grad_f, x_0, iterations, x_opt):
    x = x_0.copy()
    f_opt = f(x_opt)
    values, gradients = [], []
    values.append(abs(f(x) - f_opt))
    gradients.append(np.linalg.norm(grad_f(x)))
    for _ in range(iterations):
        grad = grad_f(x)
        step_size = np.dot(grad.T, grad) / np.dot(grad.T, np.dot(A, grad))
        x -= step_size * grad
        values.append(abs(f(x) - f_opt))
        gradients.append(np.linalg.norm(grad))
    return values, gradients

def nesterov_accelerated_gradient(A, b, x_0, alpha, beta, iterations, x_opt):
    x = x_0.copy()
    y = x.copy()
    f_opt = f(x_opt)
    prev_x = x.copy()
    values, gradients = [f(x)], [np.linalg.norm(np.dot(A, x) - b)]
    for _ in range(iterations):
        y = (1-beta)*y + beta * x
        grad = grad_f(y)
        x, y = y - alpha * grad, y
        
        values.append(abs(f(x) - f_opt))
        gradients.append(np.linalg.norm(grad))
    return values, gradients

def conjugate_gradient(A, b, x_0, iterations, x_opt):
    x = x_0.copy()
    f = lambda x: 0.5 * x.T @ A @ x - b.T @ x
    f_opt = f(x_opt)
    r = b - np.dot(A, x)
    p = r.copy()
    values, gradients = [f(x)], [np.linalg.norm(r)]
    for _ in range(iterations-1):
        alpha = np.dot(r.T, r) / np.dot(p.T, np.dot(A, p))
        x += alpha * p
        r_next = r - alpha * np.dot(A, p)
        beta = np.dot(r_next.T, r_next) / np.dot(r.T, r)
        p = r_next + beta * p
        r = r_next
        values.append(abs(f(x) - f_opt))
        gradients.append(np.linalg.norm(r))
    return values, gradients


def run_experiment(params):
    A, b, x_0, x_opt = generate_problem(n=params["n"], mu=params["mu"], L=params["L"], problem_type=params["problem_type"])
    eigs = np.linalg.eigvalsh(A)
    mu, L = min(eigs), max(eigs)

    f = lambda x: 0.5 * x.T @ A @ x - b.T @ x
    grad_f = lambda x: A@x - b

    if mu <= 1e-2:
        alpha = 1/L
    else:
        alpha = 2/(mu+L)  # Step size
    beta = (np.sqrt(L) - np.sqrt(mu))/(np.sqrt(L) + np.sqrt(mu))  # Momentum parameter

    results = {
        "methods": {
            "Gradient Descent": gradient_descent(f, grad_f, x_0, alpha, params["iterations"], x_opt),
            "Steepest Descent": steepest_descent(A, f, grad_f, x_0, params["iterations"], x_opt),
            "Conjugate Gradients": conjugate_gradient(A, b, x_0, params["iterations"], x_opt),
            # "nag": nesterov_accelerated_gradient(A, b, x_0, alpha, beta, params["iterations"], x_opt),
        },
        "problem":{
            "eigs": eigs,
            "params": params
        }
    }
    return results


def plot_results(results):
    linestyles = {
        "Gradient Descent": "r-",
        "Steepest Descent": "b-.",
        "Conjugate Gradients": "g--"
    }
    plt.figure(figsize=(10, 3.5))
    mu = results["problem"]["params"]["mu"]
    L = results["problem"]["params"]["L"]
    n = results["problem"]["params"]["n"]
    problem_type = results["problem"]["params"]["problem_type"]
    
    if mu > 1e-2:
        plt.suptitle(f"Strongly convex quadratics. n={n}, {problem_type} matrix. ")
    else:
        plt.suptitle(f"Convex quadratics. n={n}, {problem_type} matrix. ")

    plt.subplot(1, 3, 1)
    eigs = results["problem"]["eigs"]
    plt.scatter(np.arange(len(eigs)), eigs)
    plt.xlabel('Dimension')
    plt.ylabel('Eigenvalues of A')
    plt.grid(linestyle=":")
    plt.title("Eigenvalues")
    if results["problem"]["params"]["problem_type"] == "Hilbert":
        plt.yscale("log")

    plt.subplot(1, 3, 2)
    for method, result_  in results["methods"].items():
        plt.semilogy(result_[0], linestyles[method])
    plt.xlabel('Iteration')
    plt.ylabel(r'$|f(x) -f^*|$')
    plt.grid(linestyle=":")
    plt.title("Function gap")

    plt.subplot(1, 3, 3)
    for method, result_ in results["methods"].items():
        plt.semilogy(result_[1], linestyles[method], label=method)
    plt.ylabel(r'$\|\nabla f(x)\|_2$')
    plt.grid(linestyle=":")
    plt.title("Norm of Gradient")

    # Place the legend below the plots
    plt.figlegend(loc='lower center', ncol=4, bbox_to_anchor=(0.5, -0.00))
    # Adjust layout to make space for the legend below
    plt.tight_layout(rect=[0, 0.05, 1, 1])
    plt.savefig(f"cg_{problem_type}_{mu}_{L}_{n}.pdf")
    plt.show()
# Experiment parameters
params = {
    "n": 60,
    "mu": 1e-3,
    "L": 100,
    "iterations": 100,
    "problem_type": "random",  # Change to "clustered", "uniform spectrum", or "Hilbert" as needed
}

results = run_experiment(params)
plot_results(results)

Упражнение 1

В коде выше добавьте генерацию матрицы положительно определенной A, кластер которой состоит из 4 различных значений.

# Experiment parameters
params = {
    "n": 60,
    "mu": 1e-3,
    "L": 100,
    "iterations": 100,
    "problem_type": "random",  # Change to "clustered"
}

results = run_experiment(params)
plot_results(results)

Предобуславливатели

Упражнение 2

В коде ниже добавьте функцию, реализующую предобуславливатель Якоби.

def jacobi_preconditioner(A):
    """ Creates a LinearOperator for the inverse of the diagonal of A (Jacobi preconditioner). """
    
    P_inv = spla.LinearOperator(
        A.shape,  # shape of the preconditioner
        matvec=lambda v: A * v,  # matrix-vector product
        rmatvec=lambda v: A * v  # transpose matrix-vector product
    )
   
    return P_inv
# exercise_pcg.py
import numpy as np
import scipy.sparse as sp
import scipy.sparse.linalg as spla
import scipy.io as sio
import matplotlib.pyplot as plt
import requests
import io
import tarfile
import os

def download_and_load_matrix(matrix_name="bcsstk17", data_dir="."):
    """
    Downloads and loads a matrix from the SuiteSparse Matrix Collection.
    Falls back to a random SPD matrix if download fails.
    """
    mm_filename = os.path.join(data_dir, f"{matrix_name}.mtx")
    
    if not os.path.exists(mm_filename):
        print(f"Matrix file {mm_filename} not found. Attempting download...")
        url = f"https://sparse.tamu.edu/MM/Boeing/{matrix_name}.tar.gz"
        try:
            response = requests.get(url, stream=True)
            response.raise_for_status() # Raise an exception for bad status codes
            
            # Use BytesIO to handle the tarfile in memory
            tar_data = io.BytesIO(response.content)
            
            with tarfile.open(fileobj=tar_data, mode="r:gz") as tar:
                # Find the .mtx file in the tar archive
                mtx_member_name = None
                for member in tar.getmembers():
                    if member.name.endswith(".mtx"):
                        mtx_member_name = member.name
                        break
                
                if mtx_member_name:
                    # Extract directly to the data_dir
                    member_path = os.path.join(data_dir, os.path.basename(mtx_member_name))
                    # Ensure extraction doesn't overwrite outside data_dir (security)
                    if os.path.commonpath([data_dir, os.path.abspath(member_path)]) != os.path.abspath(data_dir):
                         raise Exception("Attempted path traversal in tar file.")

                    os.makedirs(data_dir, exist_ok=True)
                    
                    # Extract the specific member
                    extracted_file = tar.extractfile(mtx_member_name)
                    if extracted_file:
                         with open(mm_filename, "wb") as f:
                              f.write(extracted_file.read())
                         print(f"Successfully downloaded and extracted {mtx_member_name} to {mm_filename}")
                    else:
                         raise Exception(f"Could not extract {mtx_member_name} from tar archive.")
                else:
                    raise FileNotFoundError("No .mtx file found in the downloaded archive.")

        except requests.exceptions.RequestException as e:
            print(f"Download failed: {e}")
            return None, None, None, False # Indicate failure
        except tarfile.TarError as e:
             print(f"Tar extraction failed: {e}")
             return None, None, None, False # Indicate failure
        except Exception as e:
             print(f"An error occurred during download/extraction: {e}")
             return None, None, None, False # Indicate failure

    # Load the matrix
    try:
        A = sio.mmread(mm_filename)
        if sp.isspmatrix_coo(A):
            A = A.tocsr() # Convert to CSR for efficient matrix-vector products
        print(f"Loaded matrix {matrix_name}: shape={A.shape}, nnz={A.nnz}")

        # Check if symmetric (required for eigsh and CG theory)
        if not np.allclose((A - A.T).data, 0):
             print("Warning: Matrix is not symmetric. Making it symmetric (A+A.T)/2.")
             A = 0.5 * (A + A.T)
             A = A.tocsr()


        # Check if positive definite by trying Cholesky or checking eigenvalues
        try:
            # Attempt Cholesky factorization (requires scikit-sparse or similar)
            # Alternatively, check smallest eigenvalue (can be slow for large matrices)
            lambda_min = spla.eigsh(A, k=1, which='SM', return_eigenvectors=False)[0]
            if lambda_min <= 1e-10: # Check if sufficiently positive
                print(f"Warning: Matrix may not be positive definite (smallest eigenvalue ~ {lambda_min}). Adding diagonal shift.")
                A = A + sp.identity(A.shape[0]) * 1e-6
            else:
                print(f"Matrix appears positive definite (smallest eigenvalue ~ {lambda_min}).")
        except Exception as e:
            print(f"Could not verify positive definiteness ({e}). Proceeding with caution.")


        # Generate true solution and rhs
        n = A.shape[0]
        np.random.seed(0)
        x_true = np.random.rand(n)
        b = A @ x_true
        return A, b, x_true, True # Indicate success

    except FileNotFoundError:
        print(f"Matrix file {mm_filename} not found after download attempt.")
        return None, None, None, False
    except Exception as e:
        print(f"Error loading matrix {mm_filename}: {e}")
        return None, None, None, False


def gradient_descent(A, b, x0, max_iter=1000, tol=1e-6, x_true=None):
    """ Solves Ax=b using Gradient Descent. """
    history = []
    x = x0.copy()
    n = A.shape[0]

    # Estimate lambda_max(A) for step size
    try:
       # Ensure A is symmetric before calling eigsh
       if not hasattr(A, 'symmetric') or not A.symmetric:
           if np.allclose((A - A.T).data, 0):
               A.symmetric = True # Mark as symmetric if it is
           else: # If not symmetric, eigsh on non-symmetric matrices is deprecated/problematic
               raise ValueError("Matrix A must be symmetric for eigsh.")
       lambda_max = spla.eigsh(A, k=1, which='LM', return_eigenvectors=False)[0]
       # Optimal step size for quadratic requires lambda_min as well
       # lambda_min = spla.eigsh(A, k=1, which='SM', return_eigenvectors=False)[0]
       # tau = 2.0 / (lambda_max + lambda_min)
       # Simpler choice (may converge slower):
       tau = 1.0 / lambda_max
    except Exception as e:
       print(f"Warning: eigsh failed for A ({e}), using fixed step size 1e-7 for GD")
       tau = 1e-7 # Small fixed fallback

    print(f"Using step size tau = {tau:.2e} for GD")

    norm_xtrue = np.linalg.norm(x_true) if x_true is not None else 1.0
    norm_b = np.linalg.norm(b)

    if x_true is not None:
        history.append(np.linalg.norm(x0 - x_true) / norm_xtrue)
    else:
        history.append(np.linalg.norm(b - A @ x0) / norm_b)

    for k in range(max_iter):
        r = b - A @ x
        norm_r = np.linalg.norm(r)

        if x_true is not None:
            err = np.linalg.norm(x - x_true) / norm_xtrue
            history.append(err)
        else:
            # Use relative residual
            history.append(norm_r / norm_b)

        # Use relative tolerance for residual check
        if norm_r / norm_b < tol:
            print(f"GD converged in {k} iterations.")
            break

        x = x + tau * r
    else: # Loop finished without break
        print(f"GD did not converge within {max_iter} iterations.")


    # Pad history if converges early
    actual_iters = len(history)
    if actual_iters < max_iter + 1:
       history.extend([history[-1]] * (max_iter + 1 - actual_iters))

    return x, history[:max_iter+1]

def conjugate_gradient_scipy(A, b, x0, max_iter=1000, tol=1e-6, x_true=None):
    """ Solves Ax=b using scipy's Conjugate Gradient. """
    history = []
    norm_xtrue = np.linalg.norm(x_true) if x_true is not None else 1.0
    norm_b = np.linalg.norm(b)

    if x_true is not None:
        history.append(np.linalg.norm(x0 - x_true) / norm_xtrue)
    else:
        history.append(np.linalg.norm(b - A @ x0) / norm_b)

    iter_count = 0
    def callback(xk):
        nonlocal iter_count
        iter_count += 1
        if x_true is not None:
             err = np.linalg.norm(xk - x_true) / norm_xtrue
             history.append(err)
        else:
             # Use relative residual
             r = b - A @ xk
             norm_r = np.linalg.norm(r)
             history.append(norm_r / norm_b)

    # Use relative tolerance for spla.cg
    x, info = spla.cg(A, b, x0=x0, maxiter=max_iter, tol=tol, callback=callback)

    if info == 0:
        print(f"CG converged in {iter_count} iterations.")
    elif info > 0:
        print(f"CG did not converge within {info} iterations.")
    else:
        print(f"CG failed with error code {info}.")


    # Pad history if converges early
    actual_iters = len(history)
    if actual_iters < max_iter + 1:
       history.extend([history[-1]] * (max_iter + 1 - actual_iters))

    return x, history[:max_iter+1] # Return history up to max_iter

def jacobi_preconditioner(A):
   
    return P_inv

# ============================================================
# === EXERCISE: Implement Preconditioned Methods ===
# ============================================================
# Objective: Implement PGD and PCG using the Jacobi preconditioner.
# The functions below are provided as a solution.
# For the exercise, you might comment out the implementation details
# and try to write them yourself based on the GD/CG implementations
# and the lecture notes on preconditioning.

def preconditioned_gradient_descent(A, b, x0, P_inv, max_iter=1000, tol=1e-6, x_true=None):
    """
    Solves Ax=b using Preconditioned Gradient Descent with a given preconditioner P_inv.
    P_inv should be a LinearOperator for the inverse of the preconditioner matrix P.
    """
    history = []
    x = x0.copy()
    n = A.shape[0]

    # Estimate lambda_max(P_inv * A) for step size
    # Note: P_inv * A is generally not symmetric even if A and P are.
    # eigsh requires symmetric operators. Power iteration is safer for lambda_max.
    # For simplicity here, we'll try eigsh assuming effective symmetry or use fallback.
    tau = 1e-7 # Small fixed fallback step size

    try:
        # Define the operator P_inv * A
        # Note: For Jacobi (P=diag(A)), if A is SPD, P is SPD, and P^{-1/2} A P^{-1/2} is SPD.
        # We apply GD to the transformed system P^{-1}Ax = P^{-1}b, or minimize
        # f(x) w.r.t. P-inner product. Update is x = x + tau * P^{-1}r
        # Step size depends on eigenvalues of P^{-1}A
        P_inv_A_op = spla.LinearOperator(A.shape, matvec=lambda v: P_inv @ (A @ v))
        # Use power iteration (or eigsh if confident about symmetry properties)
        # spla.eigsh might fail if P_inv_A is not symmetric.
        # Power iteration finds the largest eigenvalue in magnitude.
        # We need largest positive eigenvalue for step size calculation if P_inv A is SPD.
        # If A is SPD and P=diag(A) with positive entries, P_inv*A is similar to P^{-1/2}AP^{-1/2} which is SPD.
        lambda_max_prec = spla.eigsh(P_inv_A_op, k=1, which='LM', return_eigenvectors=False, tol=1e-3)[0]

        # Heuristic step size (often too conservative or requires lambda_min too)
        if lambda_max_prec > 1e-10:
             tau = 1.0 / lambda_max_prec
        else:
            print("Warning: Estimated lambda_max(P_inv*A) is close to zero.")
            # tau remains the fallback
    except Exception as e:
        print(f"Warning: eigsh failed for P_inv_A ({e}), using fixed step size {tau:.1e} for PGD")
        # tau remains the fallback

    print(f"Using step size tau = {tau:.2e} for PGD")

    norm_xtrue = np.linalg.norm(x_true) if x_true is not None else 1.0
    norm_b = np.linalg.norm(b)

    if x_true is not None:
        history.append(np.linalg.norm(x0 - x_true) / norm_xtrue)
    else:
        history.append(np.linalg.norm(b - A @ x0) / norm_b)

    for k in range(max_iter):
        r = b - A @ x
        norm_r = np.linalg.norm(r)

        if x_true is not None:
            err = np.linalg.norm(x - x_true) / norm_xtrue
            history.append(err)
        else:
            # Use relative residual
            history.append(norm_r / norm_b)

        # Use relative tolerance for residual check
        if norm_r / norm_b < tol:
            print(f"PGD converged in {k} iterations.")
            break

        z = P_inv @ r # Apply preconditioner: z_k = P^{-1} r_k
        x = x + tau * z # Update using preconditioned residual: x_{k+1} = x_k + tau * z_k

    else: # Loop finished without break
        print(f"PGD did not converge within {max_iter} iterations.")

    # Pad history if converges early
    actual_iters = len(history)
    if actual_iters < max_iter + 1:
       history.extend([history[-1]] * (max_iter + 1 - actual_iters))

    return x, history[:max_iter+1]


def preconditioned_conjugate_gradient_scipy(A, b, x0, P_inv, max_iter=1000, tol=1e-6, x_true=None):
    """
    Solves Ax=b using Preconditioned Conjugate Gradient with scipy's cg.
    P_inv should be a LinearOperator for the inverse of the preconditioner P.
    """
    history = []
    norm_xtrue = np.linalg.norm(x_true) if x_true is not None else 1.0
    norm_b = np.linalg.norm(b)

    if x_true is not None:
        history.append(np.linalg.norm(x0 - x_true) / norm_xtrue)
    else:
        history.append(np.linalg.norm(b - A @ x0) / norm_b)

    iter_count = 0
    def callback(xk):
        nonlocal iter_count
        iter_count += 1
        if x_true is not None:
            err = np.linalg.norm(xk - x_true) / norm_xtrue
            history.append(err)
        else:
            # Use relative residual
            r = b - A @ xk
            norm_r = np.linalg.norm(r)
            history.append(norm_r / norm_b)

    # Use spla.cg with the M argument for the preconditioner (M = P_inv)
    # M must represent the action of P_inv.
    x, info = spla.cg(A, b, x0=x0, maxiter=max_iter, tol=tol, M=P_inv, callback=callback)

    if info == 0:
        print(f"PCG converged in {iter_count} iterations.")
    elif info > 0:
        print(f"PCG did not converge within {info} iterations.")
    else:
        print(f"PCG failed with error code {info}.")

    # Pad history if converges early
    actual_iters = len(history)
    if actual_iters < max_iter + 1:
       history.extend([history[-1]] * (max_iter + 1 - actual_iters))

    return x, history[:max_iter+1]

# ============================================================
# === END OF EXERCISE SECTION ===
# ============================================================

def plot_convergence(histories, labels, title="Convergence Comparison", use_error=True):
    """ Plots the convergence history of multiple methods. """
    plt.figure(figsize=(12, 7))
    for history, label in zip(histories, labels):
        if history: # Check if history exists (method might have been skipped)
             # Ensure history length matches iterations 0 to max_iter
             iterations = range(len(history))
             plt.semilogy(iterations, history, label=label, linewidth=2)

    plt.xlabel("Iteration", fontsize=12)
    ylabel = "Relative Error $\|x_k - x^*\|_2 / \|x^*\|_2$" if use_error else "Relative Residual Norm $\|Ax_k - b\|_2 / \|b\|_2$"
    plt.ylabel(ylabel, fontsize=12)
    plt.title(title, fontsize=14)
    plt.legend(fontsize=10)
    plt.grid(True, which='both', linestyle='--', linewidth=0.5)
    # Adjust ylim based on expected convergence, avoid overly small limits if methods don't converge well
    min_val = min(h[-1] for h in histories if h)
    plt.ylim(bottom=max(1e-12, min_val / 10), top=1.1 * max(h[0] for h in histories if h)) # Start slightly above initial error/residual
    plt.tight_layout()
    plt.show()

# Main execution block
if __name__ == "__main__":
    MATRIX_NAME = "bcsstk17"
    DATA_DIR = "./matrix_data" # Directory to store downloaded matrix

    # Download or generate matrix
    A, b, x_true, loaded_real_matrix = download_and_load_matrix(MATRIX_NAME, DATA_DIR)

    if not loaded_real_matrix:
        print("Falling back to a random SPD matrix.")
        # Generate a small random SPD matrix as fallback
        n = 200 # Larger random matrix
        np.random.seed(0)
        # Generate sparse matrix directly
        density = 0.01
        A = sp.random(n, n, density=density, format='csr', random_state=0)
        A = 0.5 * (A + A.T) # Make symmetric
        # Ensure positive definiteness by adding diagonal dominance
        diag_shift = sp.diags(A.sum(axis=1).A1 + 0.1, 0, format='csr')
        A = A + diag_shift
        
        x_true = np.random.rand(n)
        b = A @ x_true
        MATRIX_NAME = f"Random {n}x{n} SPD"
        print(f"Using random matrix: shape={A.shape}, nnz={A.nnz}")
        
    use_error_metric = (x_true is not None)
    n_dim = A.shape[0]
    x0 = np.zeros(n_dim) # Initial guess

    # Parameters
    MAX_ITER = min(1000, 2 * n_dim) # Max iterations, capped
    TOL = 1e-8 # Relative tolerance

    # --- Run Methods ---
    print("\n--- Running Solvers ---")
    x_gd, hist_gd = gradient_descent(A, b, x0, max_iter=MAX_ITER, tol=TOL, x_true=x_true)
    x_cg, hist_cg = conjugate_gradient_scipy(A, b, x0, max_iter=MAX_ITER, tol=TOL, x_true=x_true)

    # Setup and run preconditioned methods
    hist_pgd, hist_pcg = None, None
    try:
        print("\nSetting up Jacobi Preconditioner...")
        P_inv = jacobi_preconditioner(A)
        print("Running Preconditioned Gradient Descent (PGD)...")
        x_pgd, hist_pgd = preconditioned_gradient_descent(A, b, x0, P_inv, max_iter=MAX_ITER, tol=TOL, x_true=x_true)
        print("Running Preconditioned Conjugate Gradient (PCG)...")
        x_pcg, hist_pcg = preconditioned_conjugate_gradient_scipy(A, b, x0, P_inv, max_iter=MAX_ITER, tol=TOL, x_true=x_true)
    except ValueError as e:
        print(f"\nError setting up/using Jacobi preconditioner: {e}")
        print("Skipping preconditioned methods.")
        P_inv = None

    # --- Plot Results ---
    print("\n--- Plotting Convergence ---")
    histories_list = [hist_gd, hist_cg]
    labels_list = ["Gradient Descent (GD)", "Conjugate Gradient (CG)"]
    if hist_pgd:
        histories_list.append(hist_pgd)
        labels_list.append("PGD (Jacobi)")
    if hist_pcg:
        histories_list.append(hist_pcg)
        labels_list.append("PCG (Jacobi)")

    plot_title = f"Convergence Comparison for '{MATRIX_NAME}' (N={n_dim})"
    plot_convergence(histories_list, labels_list, title=plot_title, use_error=use_error_metric)

    print("\n--- Exercise Completed ---")
Matrix file ./matrix_data/bcsstk17.mtx not found. Attempting download...
Download failed: 404 Client Error: Not Found for url: http://sparse-files.engr.tamu.edu/MM/Boeing/bcsstk17.tar.gz
Falling back to a random SPD matrix.
Using random matrix: shape=(200, 200), nnz=992

--- Running Solvers ---
Using step size tau = 2.59e-01 for GD
GD did not converge within 400 iterations.
CG converged in 46 iterations.

Setting up Jacobi Preconditioner...
Running Preconditioned Gradient Descent (PGD)...
Using step size tau = 5.20e-01 for PGD
PGD converged in 121 iterations.
Running Preconditioned Conjugate Gradient (PCG)...
PCG converged in 25 iterations.

--- Plotting Convergence ---


--- Exercise Completed ---