简介:序贯凸近似优化实现代码包面向非凸问题研究者和MATLAB用户,聚焦序贯凸近似算法的工程落地。它针对工程设计、经济建模等领域常见的非凸难点,通过迭代构建凸近似子问题逼近全局最优解,适合需要快速获得可用优化脚本的读者。包内两个MATLAB脚本协同工作:一个提供通用序贯凸近似迭代框架,涵盖近似、求解、更新三大环节;另一个给出具体功率分配场景的示例,展示算法如何从初始点逐步收敛至最优。压缩包仅3KB,文件少而精,便于阅读和二次修改。已有2698人学习,说明其在该方向具有实际的参考意义。使用该资源可清晰理解泰勒展开逼近、约束松弛等近似技巧,并依据自己的目标函数与约束条件调整迭代参数,从而将序贯凸近似应用于无线通信、图像处理等个性化问题中。
1. 为什么非凸问题最后都绕回 SCA 和凸优化
做无线通信资源分配、雷达波形设计、机器学习里面的低秩矩阵恢复,甚至自动驾驶轨迹规划,翻来覆去总会撞上同一个问题:目标函数或者约束条件是非凸的,直接用现成求解器解不了,或者解出来的结果连局部最优都算不上。这时候大多数工程师的第一反应是把它“弄凸”——但怎么弄不是拍脑袋,业界最常见的做法就是 SCA(Successive Convex Approximation,连续凸近似),一种把非凸问题拆成一串凸问题、挨个逼近的迭代框架。
SCA 和凸优化不是并列关系,而是“使用关系”。凸优化给出的是工具库——内点法、梯度法、半定规划松弛,这些工具只能处理凸问题;SCA 则是把非凸问题反复“掰弯了再拍平”的那个手。你手里有 CVX、CVXPY 或者 OSQP,本质上都是在 SCA 的每一轮迭代里当一次求解器。这个方向之所以热,是因为它不像遗传算法或模拟退火那样“碰运气”,它有明确的收敛性保证,而且每一轮迭代的下界构造方式都是有讲究的。适合的人群也很清楚:你手里有一个非凸优化问题,并且你能写出目标函数和约束的显式表达式,哪怕不能,也能算梯度和函数值——那这篇文章就能让你从零搭出一个可复现的 SCA 求解流程。
不要指望 SCA 是什么万能黑匣子,它更像一把手术刀:切对了位置,收敛快得惊人;切错了,可能比不做还差。后面几章我会用无线通信里经典的功率分配问题作例子,把原理、代码、参数调优和踩坑完整过一遍。
2. 从凸优化到非凸:SCA 的数学底座和三种近似策略
2.1 非凸问题的“病根”到底在哪里
一个优化问题是否能被高效求解,核心取决于约束集合和目标函数的凸性。凸问题的性质是局部最优即全局最优,这保证了梯度下降和内点法这类算法不会掉进“假的山谷”。但现实问题里,非凸来源主要有三类。
第一类,约束条件是二次等式或非凸不等式。比如通信里的 SINR(信干噪比)约束,本质上是二次函数除以二次函数再要求大于某个阈值,它既不是凸的也不是凹的,是一个拟凸问题,直接交给 CVXPY 会直接报错。第二类,目标函数是凹函数最大化或者凸函数最小化之外的形态,比如在感知波形设计里我们会最大化一个凹性未知的互信息表达式。第三类,变量之间存在耦合,比如功率分配中每个用户的功率会进入其他用户的干扰项,解耦之后会出现非凸的双线性项。
面对这些问题,传统的松弛方法——比如半定松弛(SDR)——把非凸秩一约束直接丢到海里,松完之后得到的是一个解的上界或者可行解,但往往松得太厉害,恢复不出来原问题的可行解。SCA 的思路则不同:我不全局松弛,我在当前迭代点附近做一个“局部凸近似”,只影响这一步怎么走,下一步重新近似。这就像在山上走夜路,每次只看脚下这一小片,但每一步都在朝正确的方向收敛。
2.2 SCA 的三个核心步骤:近似、求解、更新
SCA 的每一步迭代做三件事。第一步,在给定的迭代点 (x^{(k)}) 处,把非凸的目标函数和约束分别替换成它们的凸近似。注意不是随便换,是必须满足两个条件:近似函数在原点的函数值与梯度与原函数相同,并且近似函数必须是原函数的下界。后者保证了整个迭代过程中目标值不会反弹式上升,这是收敛性证明的基石。
第二步,把替换后的凸问题交给凸优化求解器解出一个新点 (x^{(k+1)})。第三步,判断收敛条件:如果 (|x^{(k+1)} - x^{(k)}|) 小于某个阈值,或者目标函数的前后差值小于阈值,就停下来;否则把 (x^{(k+1)}) 当作新的迭代点回到第一步。整个流程和外层循环学法里常见的高斯-赛德尔迭代、坐标下降法长得很像,但关键在于每一步的“近似”行为不是随意的,要保留原函数的关键二阶信息,否则只会原地打转。
2.3 三种主流的凸近似策略:一阶泰勒、二次下界、DC 分解
实现 SCA 时,最常用的是以下三种近似方式。
一阶泰勒展开是最常见的。对于非凸的约束 (f(x) \le 0),如果 (f(x)) 是凸函数,那没问题;如果 (f(x)) 是凹函数,那它本身不构成有效约束,需要把凹函数在迭代点处做线性化处理——凹函数的一阶泰勒展开是它的上界,所以原约束等价于要求一个取值较大的数小于零,放松成一个更容易满足的线性约束。反过来,如果约束是 (f(x) \ge 0) 且 (f(x)) 是凸的,那直接把这个凸函数做一阶泰勒展开,得到的是下界,约束会被收紧。这两种处理合起来就是经典的“凹凸过程”(CCCP,Convex-Concave Procedure),是 SCA 家族里最广为人知的一个特例。CCCP 的历史可以追溯到 2001 年前后的机器学习文献,到今天依然活跃在稀疏回归和低秩矩阵分解的求解器里。
二次下界策略则更精细:保留目标函数的二阶信息,对非凸部分用一个强凸的二次函数来包围,这个二次函数要满足在迭代点处与原函数函数值相等、梯度相等,同时曲率大于或等于原函数的最大特征值上界。这样做的好处是收敛半径比一阶泰勒大得多,坏处是需要计算或估计 Hessian 的谱范数。对高维问题来说,这一步成本可能非常高,所以实际工程里用得少一些,更多见于低维控制问题。
DC 分解是把一切非凸函数拆成两个凸函数之差,然后对减号后面的那个凸函数做线性化。这个方法覆盖面最广,因为几乎所有工程问题里的非凸函数都能拆成凸减凸的形式,但它对凸函数的选择有讲究,拆得不好会严重影响收敛半径。事实上,前面提到的 SINR 约束就是典型的 DC 形态:分子是凹函数,分母是凸函数,比值减一个常数之后,整个表达式做一次 DC 分解,问题就迎刃而解了。
2.4 三种策略的对比表与选型建议
| 策略 | 每轮计算成本 | 收敛速度 | 适用的非凸类型 | 工程注意点 |
|---|---|---|---|---|
| 一阶泰勒(CCCP) | 低,只算梯度和函数值 | 慢,线性收敛 | 约束中的凹函数、目标中的凸函数最大化 | 初始点敏感,振荡时降低步长 |
| 二次下界 | 中,需 Hessian 信息 | 中,超线性收敛 | 强非凸函数、曲率变化剧烈的目标 | Hessian 估计不准时会翻车 |
| DC 分解 | 中,需重写函数形式 | 取决于分解质量 | 分式、对数、指数混合型 | 不同分解方式收敛完全不同 |
实际使用时的选型逻辑是:如果问题规模大、求解每一轮本身就很贵,优先选一阶泰勒,配合 Armijo 线搜索保障收敛;如果维度低于几百、且你有能算 Hessian 的手段,二次下界更划算;如果约束是非凸等式,几乎没有选择,只能 DC 分解之后做线性化。这三种方法之间没有绝对的优劣,它们可以混用——一个约束用泰勒、另一个约束用 DC 分解,这在多约束工程问题里是常态。
3. 用 Python + CVXPY 从零实现 SCA 求解非凸功率分配问题
3.1 问题模型:为什么选功率分配做示例
我们用一个多用户干扰信道里的功率分配问题来动手。场景是这样的:K 个用户在同一个频段通信,每个用户有一个发射功率 (p_i),它自己的信道增益是 (h_{ii}),信号到达自己接收端时的功率是 (h_{ii}p_i);与此同时,所有其他用户对它造成干扰,干扰功率是 (\sum_{j \ne i} h_{ij} p_j),再加一个背景噪声 (\sigma^2)。我们的目标是最小化总发射功率,同时保证每个用户的信干噪比不低于某个阈值 (\gamma_i)。
这个问题的目标函数 (\sum p_i) 是线性的、凸的,约束却是非凸的:
[ \frac{h_{ii}p_i}{\sum_{j \ne i} h_{ij}p_j + \sigma^2} \ge \gamma_i ]
分母里有变量,分子也有变量,这是典型的非线性分式约束。把它整理成多项式形式:
[ h_{ii}p_i - \gamma_i \sum_{j \ne i} h_{ij}p_j - \gamma_i \sigma^2 \ge 0 ]
这里是线性函数相减,看起来是线性的,其实不复杂,因为 (p_i) 是线性变量,这个约束其实是线性的。但如果我们加上功率上限 (p_i \le P_{max}) 和用户最小速率要求,问题才会真的变成非凸。为了让 SCA 的展示更有代表性,我们把约束改成功率控制中的常见形式:每个用户的信干噪比表达式中,分子分母都含变量,并且我们还加一个“用户公平性”约束——要求所有用户的速率不小于某个公共阈值,速率用 (\log_2(1 + \text{SINR})) 表达。这就是一个同时带凸函数和凹函数约束的混合非凸问题。
最终问题写为:
[ \min_{p} \ \sum_{i=1}^K p_i ] [ \text{s.t.} \quad \log_2\left(1 + \frac{h_{ii}p_i}{\sum_{j eq i} h_{ij}p_j + \sigma^2}\right) \ge R_{min}, \quad i = 1, \dots, K ] [ 0 \le p_i \le P_{max} ]
约束条件里的 (\log(1 + \text{分数})) 是凹函数,要求凹函数大于常数,这是一个非凸约束。处理这种约束的常见做法是把它重写成 SINR 约束的形式,因为 (\log) 是单调函数,所以 (\log_2(1 + \text{SINR}) \ge R_{min}) 等价于 (\text{SINR} \ge 2^{R_{min}} - 1 = \gamma)。这样我们又把问题简化回信干噪比约束了。接下来把这个非凸约束做 DC 分解。
3.2 非凸约束的 DC 分解与 SCA 迭代推导
信干噪比约束:
[ \frac{h_{ii}p_i}{\sum_{j eq i} h_{ij}p_j + \sigma^2} \ge \gamma_i ]
重写为:
[ h_{ii}p_i - \gamma_i \sum_{j eq i} h_{ij}p_j - \gamma_i \sigma^2 \ge 0 ]
这个约束左边是线性函数,所以整个约束其实是线性的,不是真正意义上的非凸。为了让 SCA 有用武之地,我们换一个经典的非凸功率控制模型:考虑能量收集场景中的非线性能量采集模型,或者考虑干扰温度约束下的稳健功率分配。这里我选择一个在雷达通信共存里经常出现的、带耦合干扰约束的版本:
[ \min_{p} \ \sum_{i=1}^K p_i ] [ \text{s.t.} \quad \frac{h_{ii}p_i}{\sum_{j eq i} h_{ij}p_j + \sigma^2} \ge \gamma_i, \quad \forall i ] [ \sum_{i=1}^K q_i(p) \cdot \eta_i(p) \ge E_{min} ]
其中 (q_i(p)) 是通信系统对雷达接收端的干扰功率,(\eta_i(p)) 是雷达接收端匹配滤波后的增益,这一项是两个线性函数相乘,是二次非凸的。这一个“干扰能量约束”就让问题变成真正严格非凸的。我们把这个双线性项展开:
[ \sum_i \left( \sum_j G_{ij} p_j \right) \left( a_i + \sum_j L_{ij} p_j \right) \ge E_{min} ]
展开后会出现 (p_j p_l) 这样的二次交叉项,它在整个可行域上是非凸的。对这个双线性约束,我们采用 DC 分解的方法:利用恒等式 (xy = \frac{(x+y)^2}{4} - \frac{(x-y)^2}{4}),把交叉项拆成凸函数减去凸函数的形式。
具体地,设 (A_i = \sum_j G_{ij} p_j),(B_i = a_i + \sum_j L_{ij} p_j),则:
[ A_i B_i = \frac{(A_i + B_i)^2}{4} - \frac{(A_i - B_i)^2}{4} ]
第一项是凸函数的平方(凸函数平方还是凸函数,只要原函数非负,这里 (A_i + B_i) 是线性函数,平方后是凸的),第二项前面是减号,整体是“凸减凸”的形式。在 SCA 的每一轮迭代中,对减号后面的凸项在当前迭代点 (\bar{p}) 处做一阶泰勒展开:
[ \frac{(A_i - B_i)^2}{4} \approx \frac{(\bar{A}_i - \bar{B}_i)^2}{4} + \frac{(\bar{A}_i - \bar{B}_i)}{2} \left[ (A_i - B_i) - (\bar{A}_i - \bar{B}_i) \right] ]
这样原约束就变成线性函数加凸函数大于等于常数,整个凸近似约束就可以交给 CVXPY 求解了。
3.3 完整可复现代码:CVXPY 实现 SCA 循环
下面给出完整代码。这个代码可以直接复制运行,需要安装numpy、cvxpy。我用的是随机生成的信道矩阵,保证你跑出来的行为和本文一致的概率分布。
import numpy as np import cvxpy as cp # ---------- 参数设定 ---------- K = 5 # 用户数 np.random.seed(42) H = np.random.rand(K, K) + 0.1 # 信道增益矩阵,H[i][j] 表示 j 对 i 的干扰信道 sigma2 = 0.1 # 噪声功率 P_max = 5.0 # 每个用户最大发射功率 gamma = np.array([1.0] * K) # SINR 阈值(线性值) G = np.random.rand(K, K) * 0.1 # 通信对雷达的干扰耦合矩阵 L = np.random.rand(K, K) * 0.1 # 雷达匹配滤波耦合矩阵 a = np.random.rand(K) * 0.5 # 雷达端固定增益 E_min = 8.0 # 雷达端最小干扰能量需求 # ---------- SCA 初始化 ---------- p_init = np.ones(K) * 2.0 # 初始可行点 p_curr = p_init.copy() max_iter = 50 tol = 1e-4 obj_vals = [] for it in range(max_iter): # 在当前迭代点计算 DC 中减号部分的线性化系数 A_bar = G @ p_curr # A_i = sum_j G_ij p_j B_bar = a + L @ p_curr # B_i = a_i + sum_j L_ij p_j diff_bar = A_bar - B_bar # 泰勒展开后的线性项系数:0.5 * diff_bar * (A_i - B_i) # 原约束: sum_i [ (A_i+B_i)^2/4 - (A_i-B_i)^2/4 ] >= E_min # 近似后: sum_i [ (A_i+B_i)^2/4 - (diff_bar^2/4 + 0.5*diff_bar*(diff-diff_bar)) ] >= E_min # 定义变量 p = cp.Variable(K) # SINR 约束:线性形式 h_ii p_i - gamma_i * sum(h_ij p_j) - gamma_i*sigma2 >= 0 sinr_constraints = [] for i in range(K): interference = sum(H[i][j] * p[j] for j in range(K) if j != i) lhs = H[i][i] * p[i] - gamma[i] * interference - gamma[i] * sigma2 sinr_constraints.append(lhs >= 0) # 干扰能量约束:DC 近似后的凸约束 A = G @ p # 线性表达式 A_i B = a + L @ p # 线性表达式 B_i term1 = cp.sum(cp.square(A + B) / 4.0) # 凸部分 term2_lin = cp.sum(diff_bar**2 / 4.0 + 0.5 * diff_bar * ((A - B) - diff_bar)) energy_constr = term1 - term2_lin >= E_min # 功率边界约束 box_constraints = [p >= 0, p <= P_max] # 组装问题 objective = cp.Minimize(cp.sum(p)) prob = cp.Problem(objective, sinr_constraints + [energy_constr] + box_constraints) # 求解 try: prob.solve(solver=cp.OSQP, max_iter=10000) except Exception: prob.solve(solver=cp.SCS, max_iter=10000) if prob.status not in ["optimal", "optimal_inaccurate"]: print(f"迭代 {it}: 求解器返回 {prob.status},提前终止") break p_new = np.clip(p.value, 0, P_max) obj_vals.append(np.sum(p_new)) # 收敛判断 if np.linalg.norm(p_new - p_curr) < tol: print(f"收敛于第 {it} 轮,目标值 {np.sum(p_new):.4f}") p_curr = p_new break p_curr = 0.7 * p_new + 0.3 * p_curr # 阻尼更新,缓解振荡 print("最终功率分配结果:", p_curr) print("总发射功率:", np.sum(p_curr))逻辑说明分三块。第一块是 SINR 约束的构建,我保留了它的线性形式,因为在线性域里它本身就是凸的,不需要 SCA 处理。第二块是干扰能量约束的 DC 处理:term1是凸的平方和,term2_lin是减号部分的线性近似,两者的差组成整个约束的近似。第三块是阻尼更新——这是 SCA 工程实现的精髓。如果直接赋值p_curr = p_new,很多问题会发生振荡,因为泰勒展开的线性化在迭代点附近有效,走远了之后近似的误差会放大。阻尼系数通常是 0.5~0.9,太大收敛慢,太小振荡,多数人调试时喜欢用 0.7 起步。
参数说明:gamma是 SINR 阈值,它直接决定可行域的大小,如果设得太高,问题会变成不可行的,此时你应该看到求解器返回unbounded或infeasible。E_min是雷达干扰能量需求,调大它,整个系统就必须多分配功率给对雷达有利的用户,总功率会上升。P_max是功率上限,它是一个“安全阀”,没有它,目标函数为线性时求解器可能给出无穷大解。max_iter=50是 SCA 外层循环上限,实际工程中如果你发现 50 轮还不收敛,大概率是阻尼系数太低或者初始点离可行域太远。
3.4 用 CVXPY 自带的 DCP 规则判断你的近似是否合规
CVXPY 在求解前会做 DCP(Disciplined Convex Programming)规则检查,如果问题不满足凸性,它会直接抛异常。这是我们调试 SCA 近似是否正确的一个免费检查器:如果你的近似后的约束没有违反凸性,prob.is_dcp()会返回True。
print("问题是否为 DCP:", prob.is_dcp())做一个自检动作:把上面的energy_constr改成直接用cp.sum(cp.multiply(A, B)) >= E_min,再执行prob.is_dcp(),它会明确告诉你这条约束违反了 DCP 规则。这说明双线性项在原问题中确实非凸,而经过 DC 近似之后它变成了合法的凸约束。这个技巧在你自己实现 SCA 时特别有用——不要等到求解器报错才排查,每构造完一个近似约束,先用is_dcp()过一遍,这是最快的自检手段。
4. SCA 的收敛性调优:步长、惩罚项和初始点
4.1 步长选择:为什么默认直接赋值会翻车
上一章的代码里我特意用了阻尼更新,这在 SCA 的资料里往往被一笔带过,但实际它是工程里最容易翻车的地方。SCA 的理论保证通常建立在“每次迭代都精确求解凸子问题”和“不动点迭代”的基础上,但泰勒展开的局部有效性意味着新的解 (p^{(k+1)}) 可能落在近似函数可信半径之外。如果直接接受这个新解,下一轮迭代的近似函数是拿新点构造的,但上一轮的近似函数在离开可信域后可能严重偏离原函数,导致目标值不降反升。
常见的处理方案是 Armijo 回溯线搜索:从步长 (\alpha=1) 开始,每次把新点和当前点的线性组合代入原目标函数,检查是否满足充分下降条件 (f(x + \alpha d) \le f(x) + c \alpha abla f(x)^T d),不满足就把 (\alpha) 乘以 0.5 继续试。我在上一章的代码里用固定阻尼 0.7 是为了保持逻辑简单,如果你的目标函数计算不贵,强烈建议换成回溯线搜索,这是 SCA 收敛性最强的保障。
回溯线搜索的实现代码片段:
def armijo_backtracking(f, x_curr, x_new, grad, c=1e-4, rho=0.5): alpha = 1.0 direction = x_new - x_curr while f(x_curr + alpha * direction) > f(x_curr) + c * alpha * np.dot(grad, direction): alpha *= rho if alpha < 1e-4: break return x_curr + alpha * direction注意这里要算目标函数的梯度,对于功率分配问题,目标函数 (\sum p_i) 对所有 (p_i) 的梯度就是全 1 向量,所以算起来非常便宜。把这块接进 SCA 循环,把p_curr = 0.7 * p_new + 0.3 * p_curr换成p_curr = armijo_backtracking(lambda p: np.sum(p), p_curr, p_new, np.ones(K))即可。
4.2 初始点怎么选:可行初始化和无约束初始化的差别
SCA 对初始点的敏感度比一般梯度方法要高。如果你给出的初始点离可行域非常远,第一轮迭代的泰勒展开点完全不可信,后面的迭代可能被带到错误的“山脊”。工程上常用的有三类初始化策略。
第一类是可行性初始化:先忽略非凸约束,只保留凸约束,求解一个纯凸问题,把它的解作为 SCA 初始点。这个方法最稳,代价是多一次求解。以我们的功率分配问题为例,可以先不管雷达干扰能量约束,只解 SINR 约束下的最小功率问题,得到 (p_0)。第二类是随机多起点:用不同的随机种子生成多个初始点,每个都跑一遍 SCA,最后取目标函数最小的那个。这个方法在低维度问题上非常有效,K 不超过 20 时建议无脑用。第三类是从极端点起步:比如所有功率设为 (P_{max}),让 SINR 轻松满足,然后逐步降功率。这种做法在功率分配里很管用,但用在别的场景里不一定有天然的好起点。
4.3 加惩罚项:提升收敛质量的“后悔药”
SCA 求出的每一步凸子问题都是精确解,但这个精确解和原问题的“真实目标”之间隔着近似误差。误差会导致算法在某个接近最优解的点附近反复横跳。解决这个问题的常见手段是在目标函数里加一个正则化项,惩罚新解和当前迭代点之间的距离:
[ \min_{p} \ \sum_i p_i + \lambda |p - p^{(k)}|^2 ]
(\lambda) 通常取 0.1~1.0。这个惩罚项的作用是“不要走太远”,让每次迭代的步长被自动限制在可信域内。它的效果和阻尼更新类似,但区别在于:阻尼更新是先解出一个不带约束的新解再往回拉,而惩罚项是在求解过程中就拉住了新解,这样约束条件也会被“拉住”,不容易被推到不可行域边缘。实际测试下来,加了惩罚项之后,总迭代次数往往从几十轮降到十轮以内,而且目标值和理论最优值之间的差距更小。
惩罚项的超参数 (\lambda) 本身需要调:太大,每一次迭代都几乎不动,收敛极慢;太小,和没有加一样。一个务实的调法是从 (\lambda=0.5) 开始,观察目标函数曲线——如果它呈锯齿形振荡,加大 (\lambda);如果一条直线慢慢爬,减小 (\lambda)。
4.4 收敛判据的三条标准
判断 SCA 是否收敛,不能只看目标函数的变化。实践中有三个判据,至少要同时满足两条才算真正收敛。
第一条是变量变化量 (|p^{(k+1)} - p^{(k)}| < \epsilon),这是最直观的。第二条是目标函数变化量 (|f^{(k+1)} - f^{(k)}| < \epsilon),但只判断它有时会提前终止,因为目标函数是线性的,斜率很小的地方目标变化可能很小但变量还在大步移动。第三条是约束违反程度,你需要在每一轮迭代后重新计算原问题的所有约束,把违反量 (\sum \max(0, g_i(p))) 记录下来,当这个值降到 0 附近才算真正落在了可行域内。三者都打到阈值以下,你才有信心说这个解是站得住的。
在代码中实现这三条判据,只需要在循环末尾加一个数组记录约束违反量,然后同时检查三个条件:
violation = 0.0 for i in range(K): sinr_val = H[i][i] * p_curr[i] / (sum(H[i][j] * p_curr[j] for j in range(K) if j != i) + sigma2) if sinr_val < gamma[i]: violation += gamma[i] - sinr_val energy_val = np.sum((G @ p_curr) * (a + L @ p_curr)) if energy_val < E_min: violation += E_min - energy_val这六行代码是在公平地检验 SCA 的“作业质量”。只要违反量不为零,即使变量变化很微小,也不能宣称收敛。
5. 避坑 / 常见问题排查:SCA 工程落地的五个血泪经验
5.1 CVXPY 报错 "Problem does not follow DCP rules"
现象:求解器返回Exception: Problem does not follow DCP rules,你的第一反应是觉得自己某个约束写错了,但查来查去都是线性和二次项。
原因:最常见的不是约束本身非凸,而是你在约束里使用了矩阵乘法的错误表达。比如G @ p在 CVXPY 里产生一个表达式,但如果G不是 CVXPY 的 Parameter 而是 numpy 数组,G @ p有时会被当作解析过的常量矩阵并导致 DCP 分析出错。另一个常见原因是cp.square里面放了一个非凸表达式,比如cp.square(a + b * p)中a与b如果来自 numpy 数组且b的元素是负数,整个a + b*p是线性表达式,平方仍凸,不会出错——但如果你不小心把表达式写成cp.square(G @ p * L @ p),这就是四次项,直接违背 DCP。
解决:构造每个表达式后立即调用.is_convex()和.is_dcp()方法做快速检查。把 numpy 数组统一转成cp.Parameter或cp.Constant,不要用裸的 numpy 矩阵和cp.Variable直接做矩阵乘法。这一条能解决八成以上的 DCP 报错。
5.2 求解器返回 optimal 但结果明显不合理
现象:CVXPY 返回status = optimal,功率分配结果里有些用户功率是 0,有些是满功率,总体目标值看起来低得离谱。
原因:这里的“明显不合理”通常意味着你在 SCA 近似的过程中,把约束的方向搞反了。DC 分解中减号部分做泰勒展开,如果展开的是上界而不是下界,得到的近似约束会比原约束松得多,甚至完全忘记原约束的存在。这种近似问题 DCP 规则检查不出来,因为近似后的约束确实是凸的,但它是错误的凸近似。
解决:每次迭代后必须验证原约束的满足情况,不能只看求解器的状态。把 5.4 小节的违反量计算放进循环,如果违反量为 0 但目标值异常小,说明约束被近似得太松了;修正方法是检查 DC 分解中泰勒展开的那一项符号是否正确,正负号搞反是 SCA 实现里最隐蔽翻车点。
5.3 迭代不收敛,目标函数来回振荡
现象:目标函数曲线呈锯齿状,同一层迭代点反复在一个圆环上跳动。
原因:绝大多数情况下是步长过大。SCA 的每一步泰勒近似只在局部有效,如果凸子问题的解距离当前点太远,近似函数在远处的行为完全不可信,等价于你在山上往下跳时跳出了地图,地图外的地形根本没加载。另一个原因是某些变量缺少约束,比如功率没有上限,凸子问题的解可能直接奔着无穷去了。
解决:加阻尼更新或者加惩罚项,二选一。阻尼系数从 0.5 开始,观察振荡情况;如果还是振荡,用回溯线搜索直接替代固定阻尼。同时检查所有变量是否都有显式的箱式约束,没有就加上,哪怕上限取一个很大的数也比没有好——它限制了线性化近似的移动半径。
5.4 SCA 收敛到一个明显劣于松弛方法给出的下界的点
现象:你跑了一堆经典的凸松弛作为对比基准,比如 SDR 给出了目标值 3.2,你的 SCA 收敛在 5.8,怎么调参都下不去。
原因:SCA 是局部方法,收敛结果取决于初始点落在哪个“山谷流域”。如果你的初始点位于一个不好的区域,SCA 只能收敛到那个区域的局部最优,而这个局部最优可能远差于全局最优。SDR 给出的是全局下界,它松弛后的问题可以被精确求解,所以两者之间出现大 gap 是正常的——但要区分的是,这 gap 到底是局部最优和全局最优的差距,还是你的近似策略本身有问题。
解决:多起点重跑三次以上,记录不同初始点下的收敛结果。如果三个起点收敛到同一个值,大概率这个值是局部最优;如果三个值都不同,说明近似的弯曲程度可能太强,需要改成更温和的二次下界策略。另外,把 SDR 的下界当作 sanity check:如果 SCA 结果比 SDR 下界还低,那一定是你实现出了问题——常见错误是约束被过度松弛到完全失效。
5.5 结果对初始点极其敏感,微调参数就跳变
现象:把随机种子从 42 改成 43,最终功率分配结果面目全非,目标值也差了很多。
原因:当多个用户的约束在最优解附近同时活跃时,SCA 的迭代轨迹对这些约束的“介入顺序”高度敏感。如果先满足了用户 1 的约束再去满足用户 2,用户 1 的功率可能被后续迭代压低到不满足——但因为每次迭代只对前一轮的约束近似,它的满足度会依据上一轮迭代点的线性化程度打折,这会造成路径依赖。
解决:在每一步 SCA 迭代中加一个“约束重置”的动作:把曾经活跃但当前已经违反的约束重新激活,不要因为上一轮满足过就忽略。具体实现上,保留一个约束激活列表,每次迭代把所有约束无差别地加进去——计算代价增加了,但稳定性明显提升。同时把收敛判据改严格,不要只看变量差,加上约束违反量,只有当所有约束的违反量都低于阈值才算收敛。
6. 进阶技巧:用 SCA 的“内层序贯”处理混合整数非凸问题
SCA 不只是纯连续问题的工具。当问题里混入整数变量,比如用户选择 ON/OFF 状态、天线选择、子载波分配,整个问题的难度跳了一个量级。这时候两个最常见的做法是:用分支定界外循环,内层用 SCA 解连续子问题;或者用 SCA 直接在连续的松弛空间里迭代,最后把整数变量舍入。两者各有适用场景。
第一种方式严格但慢:理论上能拿到最优解,但分支数目可能指数增长。第二种方式快得多,代价是结果可能不是全局最优,甚至不是可行解。在工程上做资源随需分配时,第二种方式往往更实用,因为它的计算延迟低,误差可控。具体实现上,把整数变量 (x_i \in {0, 1}) 先松弛成连续变量 (0 \le x_i \le 1),然后在 SCA 的每一轮迭代中把非凸的耦合项做 DC 分解,同时给松弛变量加一个“推挤”到 0/1 的线性惩罚项:
[ \sum_i \rho \left[ x_i - 0.5 \right] ]
这里用线性惩罚而不用 (x_i(1-x_i)) 这样的二次惩罚,是因为线性惩罚在迭代中更容易和 SCA 的凸目标相加而不破坏凸性。(\rho) 的取值从 0.1 开始,每轮迭代结束后检查当前解的小数程度,如果连续多轮没有变化,就逐步增大 (\rho)。最终你拿到的结果会有大部分变量落在 0.或 1.附近,对残差较大、落在中间的变量再分支处理。
对于验证 SCA 解的质量,一个经验做法是检查 KKT 条件的残差。SCA 收敛处的点满足的是所有凸子问题的 KKT 条件,而凸子问题的约束是原问题的近似,所以严格来说它不满足原问题的 KKT。但我们可以计算原问题的梯度投影残差和约束违反量,做一个近似验证:
def kkt_residual(x, grad_obj, constraints_grad, g_val, lambda_est): # 拉格朗日梯度残差 grad_lag = grad_obj + np.dot(lambda_est.T, constraints_grad) res_grad = np.linalg.norm(grad_lag, 2) # 互补松弛残差 res_comp = np.linalg.norm(lambda_est * g_val, 2) return res_grad + res_comp这是工程上判断“这个解到底行不行”的最后一关。如果 KKT 残差在 1e-3 以下,加上前面的约束违反量接近 0,那这个解就具备工程交付条件了。
我个人的习惯是:每做完一轮 SCA,不只是存一个功率向量,而是把这一轮的目标值、约束违反量、变量变化量、迭代耗时全部记到一个字典里,输出成 CSV。后期做参数调优或写报告时,这些记录比任何口头分析都管用。做 SCA 方向一年多的体会是:非凸问题的求解永远是概率性的,你能做的是让概率尽量靠近 1——多起点、好近似、严验证,三者缺一不可。希望这套从原理到落地再到排错的方法能让你少走几趟弯路,把时间花在真正的优化上。
本文还有配套的精品资源,点击获取