原理、构造与调试)
做通信系统优化或者信号处理算法设计的朋友大概率都撞到过这种场景目标函数写出来很漂亮比如系统总速率最大、基站发射功率最小可再一看目标要么非凸要么约束里藏着一个非凸集甚至两者都有。教科书上凸优化的那套KKT条件和内点法全部失灵暴力全局搜索在几十个变量面前又根本跑不动。这时候非凸问题的求解方法里连续凸逼近SCASuccessive Convex Approximation算是工程上最常用、也最容易落地到代码的一类。这个思路说起来很朴素在一个可行点附近把复杂的非凸问题用一个凸问题去近似解完再在新的点重复。真正难的不是记住这个流程而是搞清楚每一步为什么这么做、近似函数怎么选、参数怎么调。这篇就按我实际调非凸问题的经验把这个过程完整拆给你看。1. 非凸问题到底难在哪儿1.1 凸与非凸的分界线先花两分钟把概念对齐。一个优化问题是凸问题要求目标函数是凸函数并且约束定义的可行域是凸集。凸函数最简单的理解就是“弦在函数图像上方”或者更常用的一阶判定条件对任意 (x,y)都有 (f(y) \ge f(x)\nabla f(x)^T(y-x))。这个性质非常强它直接保证了一阶泰勒展开在全局范围内都是函数的下界后面SCA反复用到这个性质。凸问题的关键好处是任何一个局部最优解都自动是全局最优解。而且只要约束规范条件成立KKT条件就是充要条件这意味着可以用内点法、单纯形法这些成熟算法快速可靠地求解。你随便打开CVX、CVXPY、YALMIP这些工具箱就是靠这套理论在跑。问题就出在现实中的目标函数和约束往往不满足这个要求。非凸的来源大概有三类目标函数本身非凸比如凹函数的最大化问题、两个凸函数相减形成的DCDifference of Convex函数还有各种带分式项的函数。可行域是非凸集合比如整数变量、恒模约束、秩一约束、离散频率选择这些约束形成的集合一画出来就是一堆不连通的点或曲面。前两类混在一起目标非凸约束也非凸这种最头疼。拿通信里常见的恒模波束成形来说约束是 (|w_i|1)要求每个天线系数的幅度严格等于1这个约束在复平面上是一个圆边界。单独看 (|w_i|\le 1) 是凸的但偏偏还要求 (|w_i|\ge 1)这个“圆外部分”不是凸集整个可行域就变成了一堆离散的环标准的凸优化工具拿它毫无办法。1.2 非凸优化为什么让算法失效理论上一般的非凸优化问题是NP难的这几乎是优化领域最让人遗憾的结论之一。意思是一旦问题规模变大想用多项式时间算法找到全局最优解基本不可能。核心难点在于你从任意一个初始点出发做局部搜索梯度为零的地方可能只是一个小山坳离真正的谷底还远得很从不同起点出发最后收敛到的地方可能完全不一样而且你没法事先知道自己落在哪个“吸引域”里。还有一层麻烦是很多经典算法和性质遇到非凸就会失效。比如梯度下降方法依然能用但收敛性分析从“保证到全局最优”退化成“只能到一个稳定点”凸优化里那些判断最优性的条件也不再有全局意义。很多人第一次接触非凸优化的时候最不适应的就是这一点怎么写出来的代码都能跑但每次跑出来的解都不一样也说不好哪个更好。不过话说回来工程上并不总是需要严格的全局最优。很多时候只要目标值足够好、能稳定复现、性能比现有方案有明显提升就可以接受。这也正是SCA这一类局部迭代方法在工程中大行其道的原因它们不追求一步到位而是在合理的计算成本内给出一个“好”的解。2. 非凸问题的常见解法路线2.1 松弛把非凸问题“放宽”成凸问题处理非凸问题最经典的一条路线是做松弛。核心思想是先把那些导致非凸的约束“放掉”或者“放宽”让问题变成凸问题解完再想办法把松弛解还原到原问题。最典型的就是半定松弛SDR。比如在波束成形问题里秩一约束 (W ww^H) 是非凸的因为秩一矩阵集合根本不是凸集。做法是干脆丢掉这个约束只保留 (W) 半正定然后求解一个半定规划SDP。解完之后如果 (W) 碰巧是秩一的那直接分解就能得到原问题的最优解如果秩大于1还需要用高斯随机化那种方法去恢复一个近似解。松弛的优点是理论上很干净松弛问题有全局最优解而且有些问题还能证明松弛是紧的即最优解原封不动就是原问题的解。但缺点也很明显松弛解不一定落在原问题的可行域里随机化恢复的过程可能带来性能损失如果问题里非凸约束太多松弛后的间隙会累积最后得到的下界可能非常松根本没参考价值。2.2 精确法与全局优化小规模的最后防线如果想要真正的全局最优解那就得走分支定界Branch and Bound、割平面法或者差分进化这类全局优化路线。分支定界的想法是把可行域不断切成小块对每块求一个上下界剪掉那些不可能含最优解的子空间直到把最优解“逼”出来。原理很好懂但实际跑起来非常贵——它本质上是系统地枚举搜索空间复杂度往往是指数级的。几十个连续变量的小问题还好说变量一多分支树会爆炸式增长。所以我自己的使用习惯是只有在变量维数很低、而且对最优性有硬性要求的时候才会用这类方法作为基准去验证其他近似算法的效果比如跑完SCA之后用小规模例子和全局最优解对比一下看看到底差了多少。2.3 局部迭代方法SCA所在的赛道除了松弛和全局优化还有一个大类是局部迭代方法。梯度下降、投影梯度、ADMM、罚函数方法以及本文的主角SCA都归在这一类里。它们的共同点是从一个初值出发通过反复迭代让目标函数值逐步改善最终收敛到一个局部解或稳定点。这类方法胜在灵活、快速、适用于大规模问题也是实际工程中用到最多的一类。我把几条典型路线放在一起对比一下方法核心思想适用场景主要代价凸松弛放宽非凸约束成凸问题约束少、间隙可验证松弛解需要修复间隙大时会失效分支定界/全局优化系统切割搜索空间小规模、需要全局最优指数级复杂度变量一多就不可行梯度/投影类方法沿梯度方向迭代更新目标约束简单、可算梯度对步长敏感约束处理麻烦SCA/MM每次迭代用凸近似替代原问题结构明确、能构造凸近似局部最优近似设计依赖经验SCA在里面的特殊位置在于它把每一次迭代都变成了一个标准的凸优化子问题可以直接复用CVX、CVXPY、MOSEK这类成熟求解器不用自己手搓步长和梯度投影。只要凸近似构造得合理迭代过程通常很稳定这也是它能在很多工程场景里“打遍通信和信号处理问题”的原因。3. SCA方法原理拆解3.1 直觉每次只看眼前一小片SCA的思路可以用一个爬山的比喻来理解。假设你在雾天爬山整座山的全貌看不清楚只能看清脚下几十米的路线。那你会怎么走最合理的做法是在当前位置判断哪个方向能让海拔下降走一小段然后再停下来重新判断。每一小段你处理的是局部信息但只要你每走一步都可能让海拔降低迭代到最后就能到达一个山谷。SCA做的就是这件事。它在当前点 (x^t) 处把原本复杂的非凸目标函数或非凸约束替换成一个局部有效的凸近似然后求解这个近似的凸子问题得到下一个迭代点 (x^{t1})。这个凸近似只在 (x^t) 附近才是可靠的所以要么用正则项把步子控制住要么用步长参数限制更新的幅度。这种“局部摊平”的做法比一次性把整个问题近似成凸问题要聪明得多。一次性近似往往在远离当前点的地方误差巨大导致近似解根本不接近原问题的最优解。而SCA边走边看每次只承认局部信息的有效性迭代多次之后近似点和原问题之间的误差逐渐被消掉。3.2 数学框架和三条核心条件SCA的标准框架以最小化问题为例可以写成对于原问题 [ \min_x f(x) \quad \text{s.t.} \quad x \in C ] 在迭代点 (x^t)构造 (f) 的凸近似 (\tilde f(x;x^t))构造可行域 (C) 的凸近似 (\tilde C(x^t))然后求解子问题 [ \min_x \tilde f(x;x^t)\frac{\rho}{2}|x-x^t|^2 \quad \text{s.t.} \quad x \in \tilde C(x^t) ] 得到下一个迭代点。这里的凸近似函数需要满足三个条件凸性(\tilde f(x;x^t)) 关于 (x) 是凸函数保证子问题容易求解。上界性质对任意可行 (x)都有 (\tilde f(x;x^t) \ge f(x))也就是说近似函数始终在原函数上方。点处相等(\tilde f(x^t;x^t)f(x^t))在当前点两个函数值完全相同。为什么要强调这三点因为它们放在一起直接保证了目标函数不会越迭代越差。如果 (\rho0)那么 [ f(x^{t1}) \le \tilde f(x^{t1};x^t) \le \tilde f(x^t;x^t)f(x^t) ] 第一个不等号来自上界性质第二个来自子问题是精确求解的最优解。这样整个序列的目标值就是单调不增的这给了算法很直观的稳定感。如果问题换成最大化就把“上界”换成“凹下界”推倒出来的就是目标值单调不减。对于约束部分常见的做法有三种一是把原可行域放宽成超集迭代解不一定可行最后需要投影二是把近似可行域做成原集合的子集也就是内逼近这样每一步解出的点都保证原问题可行三是把约束放进罚函数里处理。工程上内逼近最省心因为你不必在每次迭代后处理不可行解。3.3 怎样构造一个好的凸近似凸近似的构造方法没有万能公式但有万能的工具——一阶泰勒展开。凸函数一阶泰勒展开有一个美妙性质它总是函数的全局下界。即如果 (g(x)) 是凸函数则 [ g(x) \ge g(x^t)\nabla g(x^t)^T(x-x^t) ] 这个性质在SCA里被反复拿来用。最典型的场景是DC函数 (f(x)h(x)-g(x))其中 (h) 和 (g) 都是凸函数。这样的函数本身不一定是凸的因为减去一个凸函数会破坏凸性。但我们可以在 (x^t) 处把 (g) 做一阶展开让减号后面的部分变成线性的 [ \tilde f(x;x^t)h(x)-g(x^t)-\nabla g(x^t)^T(x-x^t) ] 由于 (\nabla g(x^t)^T(x-x^t)) 是 (g) 的下界所以 (-g(x)) 是小于等于 (-g(x^t)-\nabla g(x^t)^T(x-x^t)) 的整体上 (\tilde f) 就大于等于 (f)。换句话说我们得到的是原目标的一个凸上界近似。最小化这样一个上界函数每次迭代都会让原目标下降。约束部分的近似也可以类似处理。拿恒模约束 (|w_i|1) 举例这个约束可以拆成 (|w_i|\le 1) 和 (|w_i|\ge 1) 两个约束。前者是凸的直接保留后者是非凸的但在 (|w_i^t|\ne 0) 的情况下可以写成一阶近似的线性形式 [ \text{Re}\left(\frac{\overline{w_i^t}}{|w_i^t|}w_i\right)\ge 1 ] 因为 (|w_i|) 是凸函数它的一阶展开是全局下界所以只要这个线性不等式成立就一定有 (|w_i|\ge 1)近似约束是原约束的严格内逼近。这个例子里SCA把非凸圆环约束切成了当前点附近的一个半平面既凸又快效果出奇地好。构造近似的经验法则是近似函数越紧越好而且最好保持一阶梯度一致。梯度一致意味着子问题的解在局部能准确反映原问题的变化趋势紧致性则避免近似误差太大导致收敛慢。另外尽量避免引入太复杂的近似结构因为子问题本身还要交给凸优化求解器去算如果子问题都难解整个算法的效率就无从谈起。3.4 正则项和步长SCA稳定性的两个旋钮SCA框架里有两个重要的调节参数一个是正则项系数 (\rho)一个是步长 (\gamma)。正则项 (\frac{\rho}{2}|x-x^t|^2) 的作用是限制子问题的解不要离当前点太远。近似函数终究是局部有效的如果它在远处失真严重子问题可能给出一个非常激进的更新方向导致迭代震荡甚至发散。加了这个惩罚项之后即使近似函数在远处很离谱解也不会跑到离当前点太远的地方。实际使用中 (\rho) 太小会控制不住更新步幅(\rho) 太大又会让算法变得极其保守每步都像老太太挪步收敛很慢。步长则是通过加权平均来控制实际更新量的 [ x^{t1}x^t\gamma(\hat x-x^t) ] 其中 (\hat x) 是子问题的最优解。当 (\gamma1) 时就是完全跳到子问题的最优解处这被称为贪心更新(\gamma) 取小一点则相当于做阻尼更新每一步只迈向子问题最优解的一小段距离。从理论上看当 (\gamma) 满足递减条件时或者配合一定的正则项强度整个算法可以稳健地收敛到一个稳定点。工程上我习惯先用 (\gamma1)、(\rho1) 做基线遇到震荡再把 (\rho) 调大或者把 (\gamma) 调到0.5左右。严格说起来SCA是MMMajorization-Minimization算法家族在优化领域的具体形式MM中的代理函数在SCA框架里就是这里的凸近似而针对DC问题的DCA算法实际上也等价于一类特殊的SCA。理解这层联系有助于举一反三但工程实现上并不要求你严格区分它们。4. 从公式到代码SCA实操4.1 演示案例一维双井函数为了把SCA的迭代过程看清楚先用一个二维空间里最简单、但足够说明问题的例子最小化 [ f(x)x^4-8x^2 ] 这个函数有两个全局极小点 (x\pm 2)在 (x0) 处是一个局部极大点。这个函数可以看成一个DC函数(h(x)x^4)(g(x)8x^2)两者都是凸函数。按照前面的方法在 (x^t) 处把 (g) 线性化得到凸近似 [ \tilde f(x;x^t)x^4-8(x^t)^2-16x^t(x-x^t) ] 求这个凸近似的最小值可以直接算导数(4x^3-16x^t0)得到 (x(4x^t)^{1/3})这就是下一个迭代点。整个SCA循环用Python写非常短import numpy as np def run_sca(x0, iters30): x x0 trace [x] for _ in range(iters): xt x # 凸近似: min x^4 - 8*xt^2 - 16*xt*(x-xt) # 导数为 4*x^3 - 16*xt极值点为 x (4*xt)^(1/3) x float(np.cbrt(4.0 * xt)) trace.append(x) if abs(x - xt) 1e-8: break return np.array(trace) for x0 in [3.0, 0.5, 0.0, -3.0]: trace run_sca(x0) print(fstart {x0}: {np.round(trace[:8], 4)} ... last{trace[-1]:.4f})跑出来的迭代序列大致是这样start 3.0: [3.0, 2.2894, 2.0924, 2.0311, 2.0103, 2.0034, 2.0011, 2.0004] ... last2.0000 start 0.5: [0.5, 1.2599, 1.7141, 1.9000, 1.9665, 1.9885, 1.9961, 1.9987] ... last2.0000 start 0.0: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] ... last0.0000 start -3.0: [-3.0, -2.2894, -2.0924, -2.0311, -2.0103, -2.0034, -2.0011, -2.0004] ... last-2.0000这组结果把SCA的几个特点全暴露出来了从 (x3) 出发很快收敛到 (x2)从 (x-3) 出发收敛到 (x-2)从 (x0.5) 出发也被“吸入”了右边的谷底而从 (x0) 出发则完全停在原处——因为 (x0) 是一个驻点一阶条件在这里就是 (x^{t1}0)。这个例子说明SCA本质上是局部收敛算法初值决定你最后到哪个局部最优。所以我每次在正式求解之前都会用几个不同的初值跑一遍看看目标值和收敛点是否稳定。这就是一个最便宜的“全局性检查”。4.2 实战案例恒模波束成形再看一个约束非凸的典型问题恒模波束成形设计。假设要设计一个长度为 (N) 的波束成形向量 (w)最小化输出功率 (w^H R w)同时每个分量的幅度都必须等于1 [ \min_w w^H R w \quad \text{s.t.} \quad |w_i|1,; i1,\dots,N ] 这里 (R) 是一个半正定协方差矩阵目标函数是凸的问题完全出在恒模约束上。用SCA处理的完整步骤如下初始化一个满足恒模约束的可行点 (w^0)比如全1向量。在 (w^t) 处把 (|w_i|\ge 1) 替换成线性约束 (\text{Re}\left(\overline{w_i^t}w_i/|w_i^t|\right)\ge 1)同时保留 (|w_i|\le 1)。求解凸子问题 [ \min_w w^H R w\rho|w-w^t|^2 \quad \text{s.t.} \quad |w_i|\le 1,; \text{Re}\left(\frac{\overline{w_i^t}}{|w_i^t|}w_i\right)\ge 1 ]更新得到 (w^{t1})检查约束违反度 (||w|-1|\le \epsilon)满足则停止否则回到第2步。用CVXPY实现这个子问题非常直接import cvxpy as cp import numpy as np N 16 R np.random.randn(N, N) 1j * np.random.randn(N, N) R (R.conj().T R) / 2 N * np.eye(N) # 强制半正定 w cp.Variable(N, complexTrue) w_t np.ones(N, dtypecomplex) # 当前点 rho 1.0 phase np.conj(w_t) / np.abs(w_t) objective cp.quad_form(w, R) rho * cp.sum_squares(w - w_t) constraints [ cp.abs(w) 1, cp.real(cp.multiply(phase, w)) 1 ] prob cp.Problem(cp.Minimize(objective), constraints) prob.solve() print(value:, prob.value) print(constraint max:, np.max(np.abs(np.abs(w.value) - 1)))几个工程细节要特别注意。第一(R) 必须是数值上半正定的否则cp.quad_form会直接报错或者子问题非凸第二初始点不要有任何分量为零否则线性化时的分母 (|w_i^t|) 没有定义这也是恒模问题里很常见的坑第三虽然理论上线性近似约束保证了 (|w_i|\ge 1)但求解器的数值误差仍然可能导致轻微的约束违反所以最后一定要做一个后验检查。4.3 子问题怎么交给求解器更稳SCA的每一个子问题本质上都是凸优化问题直接用CVXPY、CVX或者MOSEK求解即可。但有几个细节能显著提升稳定性。一个是松弛变量。如果约束近似让你担心子问题可能不可行可以考虑在约束里加一个小的松弛变量 (s)并把它的范数加到目标函数里惩罚。这样做的好处是即使近似出了问题求解器也能给出一个次优解整个迭代不会因为一个局部不可行就崩溃。另一个是缩放问题。很多实际优化问题的变量尺度差别很大比如天线权重的模长在1附近协方差矩阵的特征值却可能是 (10^4) 级别。这时候如果不做任何预处理目标函数和约束的量级差异会让求解器很难达到高精度。我的习惯是先做归一化或者标准化让关键变量都在同一个数量级再进求解器。还有一个技巧是热启动。CVXPY允许你给变量设置初始值直接把上一次迭代的 (w) 作为下一次子问题的初始值。这样可以明显减少求解器内部迭代次数尤其是当迭代进行到后期、连续两次子问题解非常接近的时候热启动能省下大量时间。4.4 参数选择的工程经验SCA里最主要的三个参数是正则项系数 (\rho)、步长 (\gamma) 和终止容差。给一个供参考的经验表参数推荐初值调整方向(\rho)和目标函数中二次项系数同量级发散/震荡就调大收敛太慢就调小(\gamma)1.0震荡时降到 0.5 或 0.2终止容差(10^{-6}) 到 (10^{-4})对精度要求高取更小值最大迭代次数30~100根据子问题求解时间调整判断收敛的标准有两个目标函数值的变化量小于容差以及约束违反度小于容差。只盯目标值是不够的——有时目标变化很小但约束还在缓慢漂移这时候解并没有真正稳定下来。5. 常见问题与排查技巧实录5.1 目标值不降反升或者来回震荡SCA理论上保证了目标值单调不增前提是近似函数满足上界条件、子问题被精确求解。所以一旦你发现目标值在上升先按这个顺序排查第一检查近似函数是不是真正的上界。很多人写代码时把近似符号写反或者在DC函数里线性化错了函数导致近似低于原函数单调性自然失效。第二检查子问题是不是真的被求解到了最优。如果子问题本身规模很大求解器提前终止或者精度设得太低那算出来的“最优点”并不满足单调性条件。第三看是不是正则项和步长导致更新太激进。这时候把 (\rho) 调大或者把 (\gamma) 降到0.2震荡通常会缓解。我自己调试时会把每一轮的目标值打出来如果前几轮还在明显上升大概率是近似构造的问题不是参数的问题。5.2 收敛到比较差的局部解SCA本身就是局部算法收敛到差解几乎是必然存在的风险。最简单的应对是多初值策略用5到10个差异明显的随机初值分别跑一遍挑目标值最好的那个解。这个策略在工程上非常有效因为很多问题的“吸引域”比想象中大只要随机撒点总有一个初值能落入好的区域内。另一种思路是在迭代早期注入随机扰动。比如前几次迭代的凸近似里加一点随机偏移类似模拟退火的思想让迭代点有机会跳出比较小的山坳。不过这种方法要谨慎使用搞不好会把已经收敛好的解又扰动坏掉建议在后期关闭扰动并做一次干净的局部精修。5.3 子问题不可行我最早用SCA做非凸约束问题的时候最大的挫折来源就是子问题不可行。出现这种情况的原因一般是约束近似构造得太严格把当前可行点本身都排除在外了另一个常见原因是初始点其实并不真正可行只是你当成可行了。处理手段也很直接在线性化约束后面加一个松弛变量并配上较大的惩罚系数。这样即使某一步约束近似的可行域和原问题产生冲突求解器也能给出一个兼容性较好的点不至于直接报错中断。等迭代进入平稳阶段再把惩罚系数加大保证最终解满足原约束的精度。5.4 什么时候别硬上SCASCA不是银弹遇到下面几种情况我会优先考虑其他方法。如果问题规模很小比如十个变量以内并且在乎严格全局最优那就用分支定界或者穷举拿它当工况基准去验证其他近似算法如果问题虽然非凸但松弛和随机化恢复的间隙在可接受范围内用半定松弛往往更快因为松弛问题一步到位不需要多次迭代如果目标函数本身光滑性很差、甚至不可导SCA的泰勒展开思路就不好使了这时候更适合用遗传算法、贝叶斯优化这类无梯度方法还有如果对全局最优有硬性要求那SCA再方便也不能硬上得考虑混合整数规划或者商用全局优化器。我在实际项目里经常遇到有人拿着SCA去硬啃根本不满足近似条件的问题结果调了很久参数还在震荡。一个简单的判断标准是你能不能写清楚原函数在当前点的一阶导数或者次梯度如果能SCA大概率走得通如果连梯度都不知道怎么算那就趁早换思路。6. 一些个人体会和建议做优化算法最怕的就是“看起来跑通了但不知道跑没跑对”。SCA这类以凸代非凸的思路本质上是在结构性和复杂度之间做一个聪明的妥协。它最大的优势在于工程落地非常顺滑子问题都是标准凸问题换一个非凸问题只需要改改近似构造和约束处理代码框架可以反复复用。它最大的代价是你永远不知道自己离全局最优有多远目标值只在单调方向上移动不代表已经走到了真正的山脚。我自己的一个固定习惯是拿到一个新问题之后先不做大规模计算而是先把问题降到两三个变量的维度把目标函数和凸近似的图像画出来看一眼。这一步能暴露出大量问题近似是否真的在局部贴合、约束近似会不会把可行域切歪、初始点附近是否平坦到梯度几乎为零。图像上看明白了再上真实规模和真实数据后面就会顺很多。另一个容易被忽略的坑是数值精度。SCA迭代的后期连续两次迭代点的距离可能已经小于求解器的默认精度这时目标函数的改善非常微小但约束违反度可能还在缓慢漂移。我会把目标值、最大约束违反度、迭代点距离三个量同时打出来看到前两个一起往下走、第三个趋于零才敢说这个解是基本收敛的。最后分享一个实用的小技巧不要在每次迭代都从头写一个优化问题的建模脚本而是把“给定当前点构造子问题”封装成一个函数初值、(\rho)、(\gamma)、终止条件全部做成参数。这样你在做多初值实验、参数扫描的时候会省下大量的时间和精力也能更系统地去理解非凸问题在你这个场景下的结构特点。SCA不是魔术但它是一个可以让你把复杂问题一步一步“啃”下来的可靠脚手架关键是你得愿意多试几次、多看几眼迭代中间结果。