--- title: "Solving Quadratic Programs with OSQP" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Solving Quadratic Programs with OSQP} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` ## Introduction The `osqp` package provides R bindings to the [OSQP](https://osqp.org/) (Operator Splitting Quadratic Program) solver. OSQP solves convex quadratic programs of the form $$ \begin{array}{ll} \mbox{minimize} & \frac{1}{2} x^T P x + q^T x \\ \mbox{subject to} & l \leq A x \leq u \end{array} $$ where $x \in \mathbf{R}^n$ is the optimization variable, $P \in \mathbf{S}^n_+$ is a positive semidefinite matrix, $q \in \mathbf{R}^n$, $A \in \mathbf{R}^{m \times n}$, and $l, u \in \mathbf{R}^m$ (with elements possibly equal to $-\infty$ or $+\infty$). The solver is based on the Alternating Direction Method of Multipliers (ADMM). Key features include: - **Efficient**: exploits structure in sparse QPs, requires only a single matrix factorization - **Robust**: places no requirements on problem data (e.g., P need not be strictly positive definite); detects primal and dual infeasibility - **Warm starting**: allows reusing previous solutions to speed up parametric solves - **Polishing**: an optional step to obtain high-accuracy solutions For algorithm details see [Stellato et al. (2020)](https://doi.org/10.1007/s12532-020-00179-2). ## Migrating from osqp < 1.0 Version 1.0.0 brings two major changes: 1. **S7 classes replace R6.** The model object returned by `osqp()` is now an S7 object. Methods are accessed with `@` instead of `$`: | Old (< 1.0) | New (>= 1.0) | |:------------|:-------------| | `model$Solve()` | `model@Solve()` | | `model$Update(...)` | `model@Update(...)` | | `model$WarmStart(...)` | `model@WarmStart(...)` | | `model$ColdStart()` | `model@ColdStart()` | | `model$GetParams()` | `model@GetParams()` | | `model$GetDims()` | `model@GetDims()` | | `model$UpdateSettings(...)` | `model@UpdateSettings(...)` | | `model$GetData(...)` | `model@GetData(...)` | During the transition, the old `$` syntax still works but emits a deprecation warning. It will be removed in a future release. 2. **OSQP C library upgraded from 0.6.3 to 1.0.0.** Some solver settings have been renamed: | Old (0.6.x) | New (1.0) | |:------------|:----------| | `warm_start` | `warm_starting` | | `polish` | `polishing` | The old names still work but emit a deprecation warning. They will be removed in a future release. ## Setup and Solve We demonstrate the basic workflow with a small QP. First, load the required packages. ```{r setup} library(osqp) library(Matrix) ``` Define the problem data. The objective matrix `P` and constraint matrix `A` should be sparse (from the `Matrix` package). Only the upper triangular part of `P` is used. ```{r define-problem} P <- Matrix(c(4., 1., 1., 2.), 2, 2, sparse = TRUE) q <- c(1., 1.) A <- Matrix(c(1., 1., 0., 1., 0., 1.), 3, 2, sparse = TRUE) l <- c(1., 0., 0.) u <- c(1., 0.7, 0.7) ``` This corresponds to: $$ \begin{array}{ll} \mbox{minimize} & \frac{1}{2} x^T \begin{bmatrix}4 & 1\\ 1 & 2 \end{bmatrix} x + \begin{bmatrix}1 \\ 1\end{bmatrix}^T x \\ \mbox{subject to} & \begin{bmatrix}1 \\ 0 \\ 0\end{bmatrix} \leq \begin{bmatrix} 1 & 1\\ 1 & 0\\ 0 & 1\end{bmatrix} x \leq \begin{bmatrix}1 \\ 0.7 \\ 0.7\end{bmatrix} \end{array} $$ Create the model and solve. ```{r solve} model <- osqp(P, q, A, l, u, pars = osqpSettings(verbose = FALSE)) result <- model@Solve() ``` The result is a list containing the primal solution `x`, dual solution `y`, and solver information in `info`. ```{r inspect-result} result$x result$y result$info$status result$info$obj_val ``` ### Convenience Function For one-shot solves where you do not need to update the problem afterward, `solve_osqp()` provides a simpler interface. ```{r solve-osqp} result <- solve_osqp(P, q, A, l, u, pars = osqpSettings(verbose = FALSE)) result$x ``` ## Updating Problem Data A major advantage of OSQP is the ability to update problem vectors and matrices *without* re-running the full setup. This avoids re-factorizing the KKT system and is much faster than creating a new model from scratch. ### Updating Vectors The vectors $q$, $l$, and $u$ can be updated freely. ```{r update-vectors} q_new <- c(2., 3.) l_new <- c(2., -1., -1.) u_new <- c(2., 2.5, 2.5) model@Update(q = q_new, l = l_new, u = u_new) result <- model@Solve() result$x result$info$status ``` ### Updating Matrices The nonzero values of $P$ and $A$ can be updated as long as the **sparsity pattern remains the same**. Pass the new nonzero values via `Px` and `Ax`. ```{r update-matrices} # Create new matrices with same sparsity pattern P_new <- Matrix(c(5., 1.5, 1.5, 1.), 2, 2, sparse = TRUE) A_new <- Matrix(c(1.2, 1.5, 0., 1.1, 0., 0.8), 3, 2, sparse = TRUE) # Update only the nonzero values model@Update(Px = triu(P_new)@x, Ax = A_new@x) result <- model@Solve() result$x ``` Note that for `P` you must pass only the **upper triangular** nonzero values (using `triu()`), since OSQP stores $P$ in upper triangular form. ## Warm Starting When solving a sequence of related problems (e.g., in a parameter sweep or iterative algorithm), warm starting can dramatically reduce the number of ADMM iterations by initializing the solver with a good starting point. ```{r warm-start} # Solve original problem model <- osqp(P, q, A, l, u, pars = osqpSettings(verbose = FALSE, warm_starting = TRUE)) res1 <- model@Solve() cold_iters <- res1$info$iter # Warm start with previous solution model@WarmStart(x = res1$x, y = res1$y) res2 <- model@Solve() warm_iters <- res2$info$iter cat("Cold start iterations:", cold_iters, "\n") cat("Warm start iterations:", warm_iters, "\n") ``` You can warm start with just the primal variables (`x`), just the dual variables (`y`), or both. To reset the iterate and start from scratch, use `ColdStart()`. ```{r cold-start} model@ColdStart() res3 <- model@Solve() cat("After cold start:", res3$info$iter, "iterations\n") ``` ## Solver Settings Solver behavior is controlled through `osqpSettings()`. Settings are passed at model creation time, and a subset can be updated afterward. ```{r settings} # Tighter tolerances and solution polishing settings <- osqpSettings( eps_abs = 1e-06, eps_rel = 1e-06, polishing = TRUE, verbose = FALSE ) model <- osqp(P, q, A, l, u, pars = settings) result <- model@Solve() result$info$obj_val ``` ### Key Settings | Setting | Default | Description | |:--------|:-------:|:------------| | `eps_abs` | 1e-3 | Absolute convergence tolerance | | `eps_rel` | 1e-3 | Relative convergence tolerance | | `max_iter` | 4000 | Maximum ADMM iterations | | `polishing` | FALSE | Polish the ADMM solution for higher accuracy | | `warm_starting` | TRUE | Enable warm starting | | `verbose` | TRUE | Print solver output | | `scaling` | 10 | Data scaling iterations (0 to disable) | | `adaptive_rho` | 1 | Adaptive ADMM step size (0=off, 1=iterations) | | `time_limit` | 1e10 | Time limit in seconds | | `check_termination` | 25 | Check termination every N iterations (0 to disable) | See `?osqpSettings` for the full list. ### Updating Settings Some settings can be updated after the model is created. ```{r update-settings} model@UpdateSettings(osqpSettings(max_iter = 2000L)) pars <- model@GetParams() pars$max_iter ``` ### Retrieving Problem Dimensions ```{r get-dims} dims <- model@GetDims() cat("Variables:", dims[["n"]], " Constraints:", dims[["m"]], "\n") ``` ## Result Object The result from `Solve()` contains: | Field | Description | |:------|:------------| | `x` | Primal solution ($n$-vector) | | `y` | Dual solution ($m$-vector) | | `prim_inf_cert` | Primal infeasibility certificate (if applicable) | | `dual_inf_cert` | Dual infeasibility certificate (if applicable) | | `info` | List of solver information | The `info` list includes: | Field | Description | |:------|:------------| | `iter` | Number of ADMM iterations | | `status` | Status string (e.g., `"solved"`) | | `status_val` | Status integer code | | `obj_val` | Optimal objective value | | `prim_res` | Primal residual | | `dual_res` | Dual residual | | `setup_time` | Time for setup (seconds) | | `solve_time` | Time for solve (seconds) | | `run_time` | Total time (seconds) | ### Status Values | Status | Constant | Value | |:-------|:---------|:-----:| | solved | `OSQP_SOLVED` | 1 | | solved inaccurate | `OSQP_SOLVED_INACCURATE` | 2 | | primal infeasible | `OSQP_PRIMAL_INFEASIBLE` | -3 | | dual infeasible | `OSQP_DUAL_INFEASIBLE` | -4 | | maximum iterations reached | `OSQP_MAX_ITER_REACHED` | -2 | | run time limit reached | `OSQP_TIME_LIMIT_REACHED` | -6 | ## Example: Lasso Regression [Lasso](https://en.wikipedia.org/wiki/Lasso_(statistics)) performs sparse linear regression by adding an $\ell_1$ penalty: $$ \mbox{minimize} \quad \frac{1}{2} \| A_d x - b \|_2^2 + \gamma \| x \|_1 $$ This can be reformulated as a QP by introducing auxiliary variables $y$ and $t$: $$ \begin{array}{ll} \mbox{minimize} & \frac{1}{2} y^T y + \gamma \mathbf{1}^T t \\ \mbox{subject to} & y = A_d x - b \\ & -t \leq x \leq t \end{array} $$ Because the regularization parameter $\gamma$ appears only in the linear cost $q$, we can update it without re-factorizing the KKT system. Warm starting makes sweeping over $\gamma$ very efficient. ```{r lasso} set.seed(1) n <- 10 # number of features m <- 100 # number of observations # Generate random data Ad <- Matrix(rnorm(m * n), m, n, sparse = FALSE) Ad <- as(Ad, "CsparseMatrix") x_true <- rnorm(n) * (runif(n) > 0.8) / sqrt(n) b <- as.numeric(Ad %*% x_true + 0.5 * rnorm(m)) # Auxiliary matrices In <- Diagonal(n) Im <- Diagonal(m) On <- Matrix(0, n, n, sparse = TRUE) Onm <- Matrix(0, n, m, sparse = TRUE) # QP formulation: variables are (x, y, t) P <- bdiag(On, Im, On) q <- rep(0, 2 * n + m) A <- rbind( cbind(Ad, -Im, Matrix(0, m, n, sparse = TRUE)), cbind(In, Onm, -In), cbind(In, Onm, In) ) l <- c(b, rep(-Inf, n), rep(0, n)) u <- c(b, rep(0, n), rep(Inf, n)) # Setup model once model <- osqp(P, q, A, l, u, pars = osqpSettings(warm_starting = TRUE, verbose = FALSE)) # Solve for a range of gamma values gammas <- seq(0.1, 2.0, length.out = 10) results <- data.frame(gamma = gammas, nnz = integer(10), obj = numeric(10)) for (i in seq_along(gammas)) { gamma <- gammas[i] q_new <- c(rep(0, n + m), rep(gamma, n)) model@Update(q = q_new) res <- model@Solve() x_hat <- res$x[1:n] results$nnz[i] <- sum(abs(x_hat) > 1e-4) results$obj[i] <- res$info$obj_val } results ``` ## Example: Portfolio Optimization Mean-variance portfolio optimization allocates assets to maximize risk-adjusted return: $$ \begin{array}{ll} \mbox{maximize} & \mu^T x - \gamma \, x^T \Sigma x \\ \mbox{subject to} & \mathbf{1}^T x = 1 \\ & x \geq 0 \end{array} $$ where $x \in \mathbf{R}^n$ is the portfolio, $\mu$ the expected returns, $\gamma > 0$ the risk aversion, and $\Sigma$ the covariance matrix. Using a factor model $\Sigma = F F^T + D$ (with $F \in \mathbf{R}^{n \times k}$, $D$ diagonal), we can reformulate this as: $$ \begin{array}{ll} \mbox{minimize} & \frac{1}{2} x^T D x + \frac{1}{2} y^T y - \frac{1}{2\gamma}\mu^T x \\ \mbox{subject to} & y = F^T x \\ & \mathbf{1}^T x = 1 \\ & x \geq 0 \end{array} $$ ```{r portfolio} set.seed(42) n <- 20 # assets k <- 5 # factors gamma <- 1 # Random factor model F_mat <- Matrix(rnorm(n * k) * (runif(n * k) > 0.3), n, k, sparse = TRUE) D <- Diagonal(n, runif(n) * sqrt(k)) mu <- rnorm(n) # QP data: variables are (x, y) P <- bdiag(D, Diagonal(k)) q <- c(-mu / (2 * gamma), rep(0, k)) A <- rbind( cbind(t(F_mat), -Diagonal(k)), # y = F'x cbind(Matrix(1, 1, n, sparse = TRUE), Matrix(0, 1, k, sparse = TRUE)), # 1'x = 1 cbind(Diagonal(n), Matrix(0, n, k, sparse = TRUE)) # x >= 0 ) l <- c(rep(0, k), 1, rep(0, n)) u <- c(rep(0, k), 1, rep(1, n)) result <- solve_osqp(P, q, A, l, u, pars = osqpSettings(verbose = FALSE, polishing = TRUE)) cat("Status:", result$info$status, "\n") # Portfolio weights (first n variables) weights <- result$x[1:n] cat("Invested assets:", sum(weights > 1e-4), "of", n, "\n") cat("Sum of weights:", round(sum(weights), 6), "\n") ``` ## Sparse Matrix Input OSQP requires sparse matrices in compressed sparse column (CSC) format. The package accepts several input types and coerces them automatically: - **`dgCMatrix`** (from `Matrix`) --- used directly - **Dense `matrix`** --- converted to sparse - **`simple_triplet_matrix`** (from `slam`) --- converted to sparse The objective matrix `P` is stored as upper triangular internally; the package handles this conversion for you. ```{r sparse-input} # Dense matrix input works fine P_dense <- matrix(c(4, 1, 1, 2), 2, 2) q <- c(1, 1) A_dense <- matrix(c(1, 1, 0, 1, 0, 1), 3, 2) l <- c(1, 0, 0) u <- c(1, 0.7, 0.7) result <- solve_osqp(P_dense, q, A_dense, l, u, pars = osqpSettings(verbose = FALSE)) result$x ``` ## References If you use OSQP in your work, please cite the following references. - B. Stellato, G. Banjac, P. Goulart, A. Bemporad, and S. Boyd, "OSQP: An Operator Splitting Solver for Quadratic Programs," *Mathematical Programming Computation*, 2020. [doi:10.1007/s12532-020-00179-2](https://doi.org/10.1007/s12532-020-00179-2) - G. Banjac, P. Goulart, B. Stellato, and S. Boyd, "Infeasibility Detection in the Alternating Direction Method of Multipliers for Convex Optimization," *Journal of Optimization Theory and Applications*, 2019. Please also cite the R package: run `citation("osqp")` for details. - B. Stellato, G. Banjac, P. Goulart, S. Boyd, V. Bansal, and B. Narasimhan, *osqp: Quadratic Programming Solver using the 'OSQP' Library*, R package, .