mk_vars <- function() {
lapply(1:3, function(i) Variable(pos = TRUE, bounds = list(1, 5)))
}
v <- mk_vars()
x1 <- v[[1]]; x2 <- v[[2]]; x3 <- v[[3]]
objective <- x1 * power(x2, 0.5) * power(x3, 1.2) + 2 * x1
constraints <- list(
2 * x1 + x2 + x3 >= 8,
x2 + 2 * x3 >= 10.5,
x1 + 2 * x3 <= 10
)
problem <- Problem(Minimize(objective), constraints)When It Isn’t a Geometric Program
Introduction
The other examples in this section all share a property worth making explicit: they are geometric programs, and psolve(..., gp = TRUE) accepts them. This one is about the boundary. It works through a problem published under the heading “geometric programming” that CVXR correctly refuses, explains why the refusal is right, and shows how to solve it anyway using a sequence of genuine geometric programs.
Disciplined geometric programming (Agrawal et al. 2019) admits an inequality constraint in one direction only: posynomial \(\leq\) monomial. A constraint pointing the other way — a posynomial bounded below — is called a reverse constraint. Under the logarithmic change of variables \(y_i = \log x_i\) that makes a GP convex, a posynomial becomes a convex function, so \(p(x) \geq c\) describes the complement of a convex set. Such a problem is a signomial program, and it is not convex under any change of variables (Boyd et al. 2007).
This distinction matters in practice because the optimization literature applies the label “geometric programming” generously. Problems presented as GPs routinely contain reverse constraints.
The Problem
The following is the illustrative example of Huang and Kao (2009):
\[ \begin{array}{ll} \mbox{minimize} & x_1 x_2^{0.5} x_3^{1.2} + 2 x_1 \\ \mbox{subject to} & 2 x_1 + x_2 + x_3 \geq 8, \\ & x_2 + 2 x_3 \geq 10.5, \\ & x_1 + 2 x_3 \leq 10, \\ & 1 \leq x_1, x_2, x_3 \leq 5. \end{array} \]
Transcribed directly:
Why It Isn’t a GP
is_dgp() works on individual expressions and constraints, not only on whole problems, which is what lets us find the culprit:
cat("Objective is DGP? ", is_dgp(Minimize(objective)), "\n", sep = "")
for (i in seq_along(constraints))
cat("Constraint ", i, " is DGP? ", is_dgp(constraints[[i]]), "\n", sep = "")
cat("Problem is DGP? ", is_dgp(problem), "\n", sep = "")Objective is DGP? TRUE
Constraint 1 is DGP? FALSE
Constraint 2 is DGP? FALSE
Constraint 3 is DGP? TRUE
Problem is DGP? FALSE
The objective is fine: a posynomial is log-log convex. The third constraint is fine: posynomial \(\leq\) constant. The first two are the reverse constraints, and they are what makes the problem non-DGP. Asking for a GP solve reports exactly that:
psolve(problem, gp = TRUE)Error in `construct_solving_chain()`:
! Problem is not DGP compliant.
ℹ Remove `gp = TRUE` or reformulate as a geometric program.
Dropping gp = TRUE does not rescue it either. The constraints are all linear in \(x\), so the feasible set is a polytope, but the objective is not convex on the positive orthant: for \(x_1^{a} x_2^{b}\) the Hessian determinant carries a factor \((1 - a - b)\), and with a leading exponent of \(1\) that matrix is indefinite.
is_dcp(problem)[1] FALSE
The problem is genuinely non-convex. That is precisely why Huang and Kao (2009) attack it with piecewise linearization and binary variables — it is their mixed-integer benchmark, not a worked GP.
Condensation: A Sequence of Geometric Programs
The classical remedy is condensation. Let \(p(x) = \sum_i u_i(x)\) be a posynomial with monomial terms \(u_i > 0\), and fix a point \(x^{0}\). Setting the weights
\[\alpha_i = \frac{u_i(x^{0})}{p(x^{0})}, \qquad \sum_i \alpha_i = 1,\]
the arithmetic-geometric mean inequality gives
\[p(x) \;=\; \sum_i u_i(x) \;\geq\; \prod_i \left(\frac{u_i(x)}{\alpha_i}\right)^{\alpha_i} \;=:\; m(x),\]
with equality at \(x = x^{0}\). Being a product of powers of monomials, \(m\) is itself a monomial.
So replace each reverse constraint \(c \leq p(x)\) by \(c \leq m(x)\). That is a legal GP constraint, and since \(m \leq p\) everywhere it is a conservative inner approximation: anything feasible for the surrogate is feasible for the original problem. Re-derive the weights at the new iterate and repeat.
f0 <- function(z) z[1] * sqrt(z[2]) * z[3]^1.2 + 2 * z[1]
condense <- function(z0, tol = 1e-10, maxit = 60) {
z <- z0
prev <- Inf
for (it in seq_len(maxit)) {
## AGM weights from the term values at the current iterate
t1 <- c(2 * z[1], z[2], z[3]); a1 <- t1 / sum(t1)
t2 <- c(z[2], 2 * z[3]); a2 <- t2 / sum(t2)
v <- mk_vars()
y1 <- v[[1]]; y2 <- v[[2]]; y3 <- v[[3]]
m1 <- power(2 * y1 / a1[1], a1[1]) * power(y2 / a1[2], a1[2]) *
power(y3 / a1[3], a1[3])
m2 <- power(y2 / a2[1], a2[1]) * power(2 * y3 / a2[2], a2[2])
sub <- Problem(
Minimize(y1 * power(y2, 0.5) * power(y3, 1.2) + 2 * y1),
list(m1 >= 8, m2 >= 10.5, y1 + 2 * y3 <= 10)
)
val <- psolve(sub, gp = TRUE) # each subproblem IS a GP
if (!identical(status(sub), "optimal")) break
z <- c(value(y1), value(y2), value(y3))
if (abs(prev - val) < tol) break
prev <- val
}
z
}Each surrogate is a genuine geometric program, so gp = TRUE accepts it. Starting from an interior point:
z <- condense(c(2, 3, 4))
cat("x1:", z[1], "\n")
cat("x2:", z[2], "\n")
cat("x3:", z[3], "\n")
cat("Objective value:", f0(z), "\n")x1: 1
x2: 1.5
x3: 4.5
Objective value: 9.445616
The iteration converges to \(x = (1,\ 1.5,\ 4.5)\) with objective value \(9.445616\). All three constraints are active there, and solving the resulting linear system \(2x_1 + x_2 + x_3 = 8\), \(x_2 + 2x_3 = 10.5\), \(x_1 + 2x_3 = 10\) returns that vertex exactly. Huang and Kao (2009) report a minimum of \(9.446\), the value produced by their mixed-integer linear approximation.
Condensation Is Local
Condensation converges to a KKT point, not necessarily a global optimum. Different starting points make that concrete:
starts <- list(c(2, 3, 4), c(1, 5, 5), c(5, 5, 2.5),
c(1.2, 2, 4.9), c(3, 1.5, 4))
multistart <- t(sapply(starts, function(s) {
zz <- condense(s)
c(x1 = zz[1], x2 = zz[2], x3 = zz[3], objective = f0(zz))
}))
multistart x1 x2 x3 objective
[1,] 1 1.5 4.50 9.445616
[2,] 1 5.0 2.75 9.528080
[3,] 1 5.0 2.75 9.528080
[4,] 1 1.5 4.50 9.445616
[5,] 1 1.5 4.50 9.445616
Two of the five land on \((1,\ 5,\ 2.75)\) with value \(9.528080\) — a genuine local optimum, not a solver failure.
A Global Certificate
For this particular problem a global check is cheap, because the objective is strictly increasing in every coordinate. For fixed \((x_1, x_2)\) the best \(x_3\) is therefore the smallest feasible one, which collapses the search to two dimensions:
grid <- seq(1, 5, length.out = 1201)
best <- c(objective = Inf, x1 = NA, x2 = NA, x3 = NA)
for (u in grid) for (w in grid) {
lo <- max(1, 8 - 2 * u - w, (10.5 - w) / 2)
if (lo > 5 || u + 2 * lo > 10) next
fv <- f0(c(u, w, lo))
if (fv < best[1])
best <- c(objective = fv, x1 = u, x2 = w, x3 = lo)
}
bestobjective x1 x2 x3
9.445616 1.000000 1.500000 4.500000
The grid search agrees with the condensation result, so \((1,\ 1.5,\ 4.5)\) is the global optimum.
Takeaways
- DGP requires posynomial \(\leq\) monomial. A posynomial bounded below is a reverse constraint, which makes the problem signomial rather than geometric.
- Call
is_dgp()on individual constraints to find which one is responsible. The problem-level answer alone will not tell you. - Condensation replaces each reverse constraint with its arithmetic-geometric mean monomial lower bound, yielding a sequence of true geometric programs, each solvable with
gp = TRUE. It is conservative and converges monotonically, but only to a local optimum. - A global guarantee needs either problem structure, as exploited above, or a global solver — which is outside what a convex modeling framework such as CVXR provides.
Session Info
R version 4.6.1 (2026-06-24)
Platform: aarch64-apple-darwin23
Running under: macOS Tahoe 26.6.2
Matrix products: default
BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
time zone: America/Los_Angeles
tzcode source: internal
attached base packages:
[1] stats graphics grDevices utils datasets methods base
other attached packages:
[1] CVXR_1.9.2
loaded via a namespace (and not attached):
[1] vctrs_0.7.3 slam_0.1-56 cli_3.6.6 knitr_1.51
[5] ECOSolveR_0.6.1 rlang_1.3.0 xfun_0.60 clarabel_0.11.3
[9] otel_0.2.0 Rglpk_0.6-5.1 highs_1.14.0-2 cccp_0.3-3
[13] scs_3.2.7 S7_0.2.2 jsonlite_2.0.0 glue_1.8.1
[17] backports_1.5.1 rprojroot_2.1.1 htmltools_0.5.9 gmp_0.7-5.1
[21] piqp_0.6.2 rmarkdown_2.31 grid_4.6.1 evaluate_1.0.5
[25] fastmap_1.2.0 lifecycle_1.0.5 yaml_2.3.12 compiler_4.6.1
[29] codetools_0.2-20 htmlwidgets_1.6.4 Rcpp_1.1.2 here_1.0.2
[33] osqp_1.0.0 lattice_0.23-1 digest_0.6.39 pillar_1.11.1
[37] checkmate_2.3.4 Matrix_1.7-6 tools_4.6.1