Skip to content

Writing a covariance backend

The critical line algorithm never sees the covariance matrix. It reaches it through five operations, collected in the protocol cvxcla.QuadraticForm (the cvx.linalg.SymmetricOperator contract). Any object implementing them can be passed as covariance= to CLA, and the algorithm runs unchanged: the bundled DenseCovariance, FactorCovariance and GramCovariance are three such backends, and you can add your own.

The contract

A backend subclasses QuadraticForm and implements:

Member Returns Used for
n the number of assets sizes
matvec(x) Sigma @ x (x a vector or a matrix of columns) gradients, the free-to-blocked cross product
block_matvec(rows, cols, v) Sigma[rows, cols] @ v the gross-exposure (leverage) lift
solve_free(free, rhs) Sigma[free, free]^{-1} @ rhs the reduced KKT solve, once per turning point
rcond_free(free) the reciprocal condition number of Sigma[free, free], in [0, 1] the singularity guard

The algorithm assumes Sigma is symmetric and that every free block it solves is positive definite; rcond_free lets it detect a block that is not and stop with an error that names the problem instead of trusting the solve.

Conventions

These are the conventions CLA relies on. tests/test_backend_protocol.py checks each of them for every backend it tests, and is a template for testing your own.

Arrays. Every array passed in is float64 (vectors and right-hand sides) or numpy.intp (index sets). Return float64 arrays.

Index sets. rows, cols and free are 1-d integer arrays into range(n) without duplicates, but not necessarily sorted: the gross-exposure lift passes the assets of the free legs in leg order. Results are aligned with the order given, so solve_free(free, rhs)[i] belongs to asset free[i]. CLA never calls solve_free with an empty free set; rcond_free of an empty set should return 1.0.

Shapes. matvec takes x of shape (n,) or (n, k) and returns the same shape. block_matvec(rows, cols, v) takes v of shape (len(cols),) or (len(cols), k) and returns (len(rows),) or (len(rows), k). solve_free takes rhs of shape (len(free),) or (len(free), k) and returns the same shape. CLA passes a matrix: the reduced KKT solve stacks the constraint columns and its two right-hand sides into one multi-column solve, so k is the number of active constraint rows plus two. NumPy broadcasting usually gives both shapes for free.

No mutation, no aliasing. Do not modify any argument in place, and do not return an array that aliases an argument or a buffer a later call overwrites: the caller keeps the results (the turning points hold them). A backend may keep internal state between calls, as IncrementalDenseCovariance does, provided the results do not depend on the call history beyond round-off.

Accuracy. The frontier is exact to the accuracy of solve_free. A backend that solves its free blocks only approximately (an iterative solver stopped early, say) traces the frontier of the matrix it effectively applies, with errors of the order of its relative residual; the KKT residuals of the result are a direct check. matvec and block_matvec must agree with the matrix the solve inverts, or the event search and the solve describe different problems.

Conditioning. rcond_free(free) returns the reciprocal 2-norm condition number of Sigma[free, free], lambda_min / lambda_max, in [0, 1], or a lower bound on it (the factor backend returns a bound from Weyl's inequalities, so it never forms the block). CLA declines a free block below cvxcla.operators.RCOND_FLOOR (1e-12), so a lower bound errs on the safe side and an overestimate can let a singular solve through. CLA first calls rcond_free(range(n)) once: if the whole covariance clears the floor, no free block can fall below it (eigenvalue interlacing) and the per-step check is skipped, so that call must not overstate the conditioning either.

Errors. Validate the data when the backend is built and raise ValueError for malformed or inadmissible input, as the bundled builders do. During the trace, a solve_free that meets a singular block may raise numpy.linalg.LinAlgError. CLA does not translate it: with a correct rcond_free the guard declines first, with a DegenerateProblemError.

Example: a block-diagonal covariance

Assets in groups with no cross-group covariance give a block-diagonal Sigma. Its free-block solve splits into one small Cholesky solve per block:

import numpy as np
from scipy.linalg import block_diag, cho_factor, cho_solve

from cvxcla import CLA, QuadraticForm


class BlockDiagonalCovariance(QuadraticForm):
    """Sigma = blockdiag(S_1, ..., S_m) with symmetric positive-definite blocks."""

    def __init__(self, blocks):
        self._blocks = [np.asarray(b, dtype=float) for b in blocks]
        sizes = [len(b) for b in self._blocks]
        self._starts = np.cumsum([0, *sizes])
        self._block_of = np.repeat(np.arange(len(sizes)), sizes)

    @property
    def n(self):
        return int(self._starts[-1])

    def matvec(self, x):
        return np.concatenate([b @ x[s:e] for b, s, e in zip(self._blocks, self._starts[:-1], self._starts[1:])])

    def block_matvec(self, rows, cols, v):
        x = np.zeros((self.n, *np.shape(v)[1:]))
        x[np.asarray(cols)] = v
        return self.matvec(x)[np.asarray(rows)]

    def _free_blocks(self, free):
        free = np.asarray(free)
        for k in np.unique(self._block_of[free]):
            pos = np.flatnonzero(self._block_of[free] == k)
            local = free[pos] - self._starts[k]
            yield pos, self._blocks[k][np.ix_(local, local)]

    def solve_free(self, free, rhs):
        out = np.empty_like(np.asarray(rhs, dtype=float))
        for pos, sub in self._free_blocks(free):
            out[pos] = cho_solve(cho_factor(sub), rhs[pos])
        return out

    def rcond_free(self, free):
        eig = np.concatenate([np.linalg.eigvalsh(sub) for _, sub in self._free_blocks(free)])
        return float(eig.min() / eig.max())


rng = np.random.default_rng(4)
blocks = [m @ m.T / 5 + 0.2 * np.eye(5) for m in rng.standard_normal((4, 5, 5))]
mean = rng.uniform(0.0, 1.0, 20)
problem = dict(lower_bounds=np.zeros(20), upper_bounds=np.ones(20), a=np.ones((1, 20)), b=np.ones(1))

custom = CLA(mean=mean, covariance=BlockDiagonalCovariance(blocks), **problem)
dense = CLA(mean=mean, covariance=block_diag(*blocks), **problem)
assert np.allclose([tp.weights for tp in custom.turning_points], [tp.weights for tp in dense.turning_points])

The same backend works with general constraints and with a gross-exposure cap (leverage=); tests/test_custom_backend.py checks all three against the dense trace. The structure need not be block-diagonal: a sparse matrix with a sparse Cholesky, a Kronecker product, or a matrix-free operator with an iterative solve_free fit the same five methods. tests/test_backend_protocol.py takes four independently written backends (block-diagonal, a hand-written diagonal-plus-low-rank Woodbury solve, a Kronecker product and a permuted dense matrix) through the conventions above and through randomized problems with and without general constraints, each against the dense trace of the same matrix.