简介:这份资源是凸优化理论学习的心得配套代码包,面向正在入门或希望系统梳理凸优化知识体系的机器学习、数据分析方向学习者。内容围绕学习路径展开,涵盖入门书籍选择、Boyd《凸优化》结构解读、线性代数与微积分基础铺垫,以及多所高校公开教程与资源的整理,帮助读者少走弯路、建立清晰的知识框架。压缩包共3个文件,以inscode工程配置、html页面和gitignore为主,整体约5KB,属于轻量级项目代码,便于快速查看与二次整理。目前已有71人学习下载。读者可从中获得一份完整的学习路线参考,包括课程资源索引、书籍章节脉络梳理以及基础学科补强建议,适合作为凸优化自学的起步指南,也便于结合代码工程结构理解资源组织方式。
1. 凸优化学习心得:从理论推导到代码复现的完整路径
很多人学凸优化卡在同一个地方:书上的对偶推导能看懂,KKT 条件也能背,但一到自己写代码求解就不知道从哪下手。这个项目代码包就是冲着这个痛点来的——它把凸优化从数学定义到算法实现再到数值验证串成了一条线,每个核心概念都配了可运行的代码。适合正在学凸优化课程但作业写不动的学生,也适合工作中需要手推优化模型再落地的算法工程师。代码包覆盖了凸集判定、凸函数验证、梯度下降、牛顿法、内点法、ADMM 等核心模块,语言以 Python 为主,依赖 NumPy 和 Matplotlib,不需要额外的商业求解器。换句话说,这是一份能让你从「看懂公式」过渡到「跑出结果」的实操材料。
2. 凸优化代码包的结构拆解:六个模块各自解决什么问题
2.1 模块划分与依赖关系
拿到代码包之后别急着跑main.py,先花十分钟把目录结构看清楚。常见的组织方式是按「数学基础 → 无约束优化 → 有约束优化 → 应用案例」四层递进,每个目录下再按算法拆分文件。我一般会先看requirements.txt和README,确认依赖版本和入口脚本,避免因为 NumPy 版本差异导致np.float报错这种低级翻车。
典型的目录结构长这样:
convex-optimization/ ├── requirements.txt ├── basics/ │ ├── convex_set.py # 凸集判定与可视化 │ └── convex_function.py # 凸函数验证(一阶/二阶条件) ├── unconstrained/ │ ├── gradient_descent.py # 梯度下降及变体 │ ├── newton_method.py # 牛顿法 │ └── line_search.py # 线搜索策略 ├── constrained/ │ ├── kkt_solver.py # KKT 条件数值验证 │ ├── interior_point.py # 内点法 │ └── admm.py # ADMM 交替方向乘子法 ├── applications/ │ ├── lasso_regression.py # Lasso 回归 │ ├── svm_dual.py # SVM 对偶求解 │ └── portfolio.py # 投资组合优化 └── utils/ ├── plot_utils.py # 收敛曲线、等高线绘制 └── timer.py # 算法耗时统计这个结构的好处是模块之间低耦合。比如你想单独测试牛顿法,只需要from unconstrained.newton_method import NewtonSolver,不用把整个包跑一遍。依赖方面,核心计算只用到 NumPy,可视化用 Matplotlib,没有引入 CVXPY 或 SciPy 的优化模块——这是有意为之,目的是让你看清算法本身的迭代逻辑,而不是调一个黑匣子求解器。
2.2 核心数据结构与接口约定
代码包里所有求解器遵循统一的接口约定,这是能复用的关键。每个 solver 类至少暴露三个方法:__init__接收超参数,fit接收目标函数和梯度函数,solve执行迭代并返回结果字典。结果字典里固定包含x_opt(最优解)、f_opt(最优值)、history(每步的目标函数值列表)、n_iter(迭代次数)。
# 统一的求解器接口示例 class GradientDescentSolver: def __init__(self, lr=0.01, max_iter=1000, tol=1e-6): self.lr = lr # 学习率,控制每步走多远 self.max_iter = max_iter # 最大迭代次数,防止死循环 self.tol = tol # 收敛阈值,梯度范数小于此值认为收敛 def solve(self, f, grad_f, x0): x = x0.copy() history = [] for i in range(self.max_iter): g = grad_f(x) if np.linalg.norm(g) < self.tol: break x = x - self.lr * g # 沿负梯度方向更新 history.append(f(x)) return {"x_opt": x, "f_opt": f(x), "history": history, "n_iter": i + 1}这段代码里三个参数各有讲究。lr设大了会震荡甚至发散,设小了收敛慢到你想砸键盘;max_iter是后悔药,防止目标函数不收敛时程序卡死;tol决定了你什么时候认为「够了」。我一般先用lr=0.01跑一遍看收敛曲线,如果震荡就把lr砍半,如果曲线太平就适当放大。history列表是后续画收敛图的基础,别把它丢了。
2.3 从数学定义到代码的映射关系
凸优化代码包最有价值的地方在于它把数学定义直接翻译成了可验证的代码。比如凸集的定义是「集合内任意两点的连线仍在集合内」,代码里就对应一个采样验证函数:随机取若干对点,检查线段上的点是否满足集合的约束条件。凸函数的一阶条件「f(y) ≥ f(x) + ∇f(x)ᵀ(y-x)」和二阶条件「Hessian 矩阵半正定」也各有对应的数值验证脚本。
这种映射关系看起来简单,但实际写的时候有几个细节容易忽略。第一,数值验证只能证伪不能证明——采样没找到反例不代表一定是凸的。第二,Hessian 半正定的判定要用特征值分解而不是行列式,因为行列式非负只是必要条件。第三,浮点误差会让边界情况误判,通常要加一个eps=1e-8的容差。代码包里这些细节都处理了,你在读的时候留意一下convex_function.py里的容差参数是怎么设的。
3. 无约束优化算法的代码实现:梯度下降、牛顿法与线搜索
3.1 梯度下降的三种变体与收敛对比
梯度下降是最容易上手但也最容易踩坑的算法。代码包里实现了三种变体:批量梯度下降(BGD)、随机梯度下降(SGD)和小批量梯度下降(Mini-batch GD)。三者的区别在于每次更新时用多少样本计算梯度。
# 三种梯度下降变体的核心差异 def bgd_update(x, X, y, lr): grad = compute_grad(x, X, y) # 用全部样本算梯度 return x - lr * grad def sgd_update(x, X, y, lr): i = np.random.randint(len(y)) # 随机选一个样本 grad = compute_grad(x, X[i:i+1], y[i:i+1]) return x - lr * grad def minibatch_update(x, X, y, lr, batch_size=32): idx = np.random.choice(len(y), batch_size, replace=False) grad = compute_grad(x, X[idx], y[idx]) return x - lr * gradBGD 每步都用全量数据,梯度方向准但计算量大;SGD 每步只用一个样本,快但梯度噪声大,收敛曲线会抖得像心电图;Mini-batch 是折中方案,batch_size一般取 32 到 256 之间。代码包里有一个对比脚本,能在同一个凸二次目标函数上跑三种变体,把收敛曲线画在一张图里。跑完之后你会直观看到:BGD 曲线平滑但下降慢,SGD 前期下降快但后期在最优解附近震荡,Mini-batch 兼顾了两者。
学习率的设置是另一个关键。固定学习率在简单问题上够用,但遇到条件数大的问题就力不从心。代码包里额外实现了两种自适应策略:一种是按迭代次数衰减lr = lr0 / (1 + decay * t),另一种是回溯线搜索。前者实现简单,后者更鲁棒但每步要多算几次函数值。
3.2 牛顿法的实现细节与 Hessian 矩阵处理
牛顿法用二阶信息来加速收敛,迭代公式是x = x - H⁻¹∇f。理论上有二次收敛速度,但实际实现有两个绕不开的问题:Hessian 矩阵可能不可逆,以及计算 Hessian 的代价可能很高。
def newton_solve(f, grad_f, hess_f, x0, max_iter=100, tol=1e-8): x = x0.copy() for i in range(max_iter): g = grad_f(x) if np.linalg.norm(g) < tol: break H = hess_f(x) # 加正则项保证 Hessian 可逆 H_reg = H + 1e-6 * np.eye(len(x)) try: d = np.linalg.solve(H_reg, -g) # 牛顿方向 except np.linalg.LinAlgError: d = -g # 退化为梯度方向 # 回溯线搜索确定步长 t = 1.0 while f(x + t * d) > f(x) + 0.5 * t * g @ d: t *= 0.5 x = x + t * d return x这段代码里有三个值得注意的点。第一,H_reg加了1e-6的正则项,这是防止 Hessian 奇异时的兜底手段,代价是稍微偏离纯牛顿方向。第二,np.linalg.solve比直接求逆np.linalg.inv(H) @ g数值更稳定,速度也更快。第三,回溯线搜索的 Armijo 条件f(x+td) ≤ f(x) + 0.5*t*g@d保证了每步都有充分下降,t从 1.0 开始折半,通常几次就能满足。
牛顿法在低维问题(比如维度小于 100)上优势明显,迭代次数通常是个位数。但维度一高,Hessian 矩阵的存储和求逆就成了瓶颈。代码包里对高维情况提供了拟牛顿法(BFGS)的简化实现,用梯度差来近似 Hessian 的逆,省去了直接计算二阶导数的麻烦。
3.3 线搜索策略的选择与参数调优
线搜索解决的是「方向定了,走多远」的问题。代码包里实现了三种策略:固定步长、回溯线搜索(Armijo 条件)和精确线搜索(黄金分割)。固定步长最简单但最不靠谱,精确线搜索最稳但每步开销大,回溯线搜索是实际用得最多的。
选择策略时我一般看两个指标:每步的计算代价和总迭代次数。精确线搜索每步要解一个一维优化问题,单步代价高,但迭代次数少;回溯线搜索单步代价低,但可能需要更多迭代。在目标函数计算便宜的场景下(比如简单的二次函数),精确线搜索反而总耗时更短。代码包里有一个line_search.py专门做对比实验,你可以改c1参数(Armijo 条件的系数,默认 1e-4)看收敛行为的变化。c1设得太大会导致步长过小,收敛慢;设得太小则可能不满足下降条件。
4. 有约束优化的代码实战:KKT 条件、内点法与 ADMM
4.1 KKT 条件的数值验证方法
KKT 条件是有约束优化的核心理论,但很多人学完之后不知道怎么用代码验证。代码包里的kkt_solver.py提供了一个数值验证框架:给定一个等式约束和不等式约束的优化问题,求解之后逐项检查 KKT 条件的满足程度。
KKT 条件包含四部分:平稳性条件(∇f + Σλᵢ∇gᵢ + Σμⱼ∇hⱼ = 0)、原始可行性(gᵢ(x) ≤ 0, hⱼ(x) = 0)、对偶可行性(μⱼ ≥ 0)和互补松弛性(μⱼgⱼ(x) = 0)。代码里对每一项计算残差,如果所有残差都小于容差,就认为 KKT 条件满足。
def check_kkt(x, f_grad, g_funcs, g_grads, h_funcs, h_grads, lambdas, mus, tol=1e-4): # 平稳性残差 stationarity = f_grad(x).copy() for lam, hg in zip(lambdas, h_grads): stationarity += lam * hg(x) for mu, gg in zip(mus, g_grads): stationarity += mu * gg(x) res_stat = np.linalg.norm(stationarity) # 原始可行性残差 res_prim = max( max(0, g(x)) for g in g_funcs ) if g_funcs else 0 res_prim = max(res_prim, max(abs(h(x)) for h in h_funcs) if h_funcs else 0) # 对偶可行性 res_dual = max(0, -min(mus)) if mus else 0 # 互补松弛性 res_comp = max(abs(mu * g(x)) for mu, g in zip(mus, g_funcs)) if mus else 0 return {"stationarity": res_stat, "primal": res_prim, "dual": res_dual, "complementary": res_comp}这个验证框架的实用价值在于:当你自己实现了一个求解器但不确定结果对不对时,把解代进 KKT 检查一遍,四项残差都小就说明解是靠谱的。容差tol一般设 1e-4 到 1e-6,取决于问题的尺度。如果某一项残差特别大,就针对性地排查——平稳性不满足说明梯度计算或乘子更新有问题,互补松弛性不满足说明不等式约束的活跃集判断有误。
4.2 内点法的障碍函数与中心路径
内点法的思路是把不等式约束通过障碍函数「吸收」进目标函数,把有约束问题转化为一系列无约束问题。代码包里用的是对数障碍函数:φ(x) = -Σ log(-gᵢ(x)),当 x 靠近约束边界时障碍函数趋于无穷大,从而把迭代点「挡」在可行域内部。
def barrier_method(f, grad_f, g_funcs, grad_g_funcs, x0, t0=1.0, mu=10.0, tol=1e-6): x = x0.copy() t = t0 while True: # 定义障碍目标函数 def barrier_obj(x): val = t * f(x) for g in g_funcs: gv = g(x) if gv >= 0: return np.inf # 不可行点直接返回无穷 val -= np.log(-gv) return val # 用牛顿法求解当前 t 下的无约束问题 x = newton_solve(barrier_obj, None, None, x, max_iter=50) # 检查对偶间隙 m = len(g_funcs) if m / t < tol: break t *= mu # 增大 t,逼近原问题 return x这段代码的核心逻辑是:外层循环逐步增大t,内层用牛顿法求解障碍问题。t越大,障碍项的影响越小,解越接近原问题的最优解。mu是增长因子,一般取 10 到 20,太小会导致外层迭代次数多,太大则内层牛顿法可能不收敛。对偶间隙的估计用m/t,当它小于tol时停止。
内点法的收敛性很好,理论上多项式时间内就能收敛到高精度解。但实现时要注意初始点必须在可行域内部(严格满足所有不等式约束),否则对数障碍函数没有定义。代码包里提供了一个寻找严格可行初始点的辅助函数,用的是 Phase I 方法。
4.3 ADMM 的拆分策略与收敛判据
ADMM(交替方向乘子法)适合求解可以拆成两块变量的凸优化问题,形式是min f(x) + g(z) s.t. Ax + Bz = c。它的迭代分三步:x 更新、z 更新、对偶变量更新。代码包里的admm.py用 Lasso 回归作为例子展示了完整的实现。
def admm_lasso(A, b, lam, rho=1.0, max_iter=1000, tol=1e-4): m, n = A.shape x = np.zeros(n) z = np.zeros(n) u = np.zeros(n) # 缩放后的对偶变量 for k in range(max_iter): # x 更新:解一个岭回归 x = np.linalg.solve(A.T @ A + rho * np.eye(n), A.T @ b + rho * (z - u)) # z 更新:软阈值操作 z_old = z.copy() z = soft_threshold(x + u, lam / rho) # 对偶变量更新 u = u + x - z # 收敛判据 r_norm = np.linalg.norm(x - z) # 原始残差 s_norm = np.linalg.norm(-rho * (z - z_old)) # 对偶残差 if r_norm < tol and s_norm < tol: break return zADMM 的关键参数是惩罚因子rho。rho太大,原始残差下降快但对偶残差下降慢;rho太小则反过来。实际使用中如果发现原始残差和对偶残差量级差很多,就要调整rho。代码包里实现了自适应rho的策略:当原始残差远大于对偶残差时增大rho,反之减小。软阈值函数soft_threshold(v, kappa) = sign(v) * max(|v| - kappa, 0)是 Lasso 问题的近端算子,也是 ADMM 能高效求解 Lasso 的原因——z 更新有闭式解,不需要迭代。
5. 避坑与排查:凸优化代码复现中的五个血泪教训
5.1 梯度检查不通过:数值梯度与解析梯度的偏差
现象:自己实现的解析梯度和数值梯度对不上,相对误差超过 1e-4。
原因:常见的有三种——梯度公式推导错误、数值梯度的步长h选得不当、或者函数在某个点不可导但没做处理。数值梯度用(f(x+h) - f(x-h)) / (2h)计算时,h太大会引入截断误差,太小会引入浮点舍入误差,一般取1e-5到1e-7之间比较合适。
解决:代码包里有一个grad_check工具函数,用中心差分做数值梯度,逐分量对比解析梯度。如果某个分量的误差特别大,就单独检查那一项的推导。另外注意,如果目标函数里有max、abs或norm在零点不可导,梯度检查要避开这些点。
5.2 收敛曲线震荡:学习率与条件数的关系
现象:梯度下降的收敛曲线上下震荡,目标函数值不降反升。
原因:学习率相对于目标函数的曲率太大了。对于二次函数f(x) = 0.5 xᵀQx,梯度下降稳定的条件是lr < 2 / λ_max(Q),其中λ_max是 Q 的最大特征值。如果问题的条件数λ_max / λ_min很大,稳定学习率的上限会很小,收敛也会很慢。
解决:先计算 Hessian 的最大特征值,把学习率设成1 / λ_max左右。如果条件数太大,考虑换用牛顿法或拟牛顿法,或者对变量做预处理(比如归一化)来改善条件数。代码包里的plot_utils.py可以画出目标函数的等高线和迭代轨迹,能直观看到震荡是怎么产生的。
5.3 内点法在边界附近报 NaN:对数障碍的定义域问题
现象:内点法迭代到某一步时目标函数变成 NaN,程序崩溃。
原因:迭代点跑到了可行域外面,log(-g(x))里的-g(x)变成负数或零,对数无定义。这通常是因为牛顿步太大,一步跨过了约束边界。
解决:在牛顿法里加回溯线搜索,确保每步更新后所有不等式约束仍然严格满足。代码包里的barrier_method在计算障碍目标函数时做了检查,遇到不可行点直接返回np.inf,线搜索会自动缩小步长。另外初始点必须严格可行,不能取在边界上。
5.4 ADMM 残差不下降:rho 参数的调整策略
现象:ADMM 跑了几百轮,原始残差和对偶残差都卡在某个值不再下降。
原因:rho的设置导致原始问题和对偶问题的收敛速度严重不匹配。原始残差大说明x和z差得远,需要增大rho来加强一致性约束;对偶残差大说明对偶变量更新不够,需要减小rho。
解决:用自适应rho策略,每隔若干轮检查两个残差的比值。如果r_norm / s_norm > 10,把rho乘以 2;如果s_norm / r_norm > 10,把rho除以 2。注意调整rho之后对偶变量u要按比例缩放,否则会引入额外的震荡。代码包里admm.py的自适应版本已经处理了这个细节。
5.5 数值精度导致 KKT 验证失败:容差设置的经验
现象:求解结果看起来是对的,但 KKT 检查的某一项残差总是比容差大一点点。
原因:浮点运算的累积误差。特别是当问题尺度很大或很小时,绝对容差1e-4可能过严或过松。另外,互补松弛性条件μⱼgⱼ(x) = 0在数值上很难精确满足,因为μⱼ和gⱼ(x)可能都不为零但乘积很小。
解决:用相对容差代替绝对容差,比如res / (1 + |f(x)|) < tol。互补松弛性的检查可以放宽到|μⱼgⱼ(x)| < 1e-3。如果只有这一项不满足而其他三项都很好,通常不影响解的实际可用性。代码包里的check_kkt函数支持传入自定义容差,你可以根据问题尺度调整。
6. 进阶技巧:用收敛曲线和等高线验证算法行为
6.1 收敛曲线的正确读法
收敛曲线是最直接的验证工具,但很多人只看「有没有下降」就完事了。实际上曲线的形状能告诉你更多信息。横轴用迭代次数还是用耗时,结论可能完全不同——牛顿法迭代次数少但每步慢,梯度下降迭代次数多但每步快,画在同一张图上才能公平比较。
我一般会画三张图:目标函数值随迭代次数的变化、梯度范数随迭代次数的变化、以及目标函数值随耗时的变化。第一张看收敛速度,第二张看是否真的到了驻点,第三张看实际效率。代码包里的plot_utils.py提供了plot_convergence函数,支持同时画多条曲线并自动加图例。
from utils.plot_utils import plot_convergence # 假设 results 是不同求解器返回的结果字典列表 plot_convergence( results, # 结果列表 labels=["GD", "Newton", "ADMM"], # 图例标签 x_axis="iter", # 横轴:迭代次数 y_axis="f_val", # 纵轴:目标函数值 log_scale=True # 纵轴取对数,便于看后期收敛 )纵轴取对数是个实用技巧。线性坐标下后期的小幅下降看不出来,取对数之后收敛速率一目了然——直线下降说明线性收敛,曲线变陡说明超线性收敛。
6.2 等高线与迭代轨迹的叠加分析
对于二维问题,把等高线和迭代轨迹画在一起是最直观的调试手段。你能看到迭代点是不是沿着「山谷」走,有没有在窄长区域来回震荡,牛顿法是不是一步就跳到了中心。
from utils.plot_utils import plot_contour_with_path plot_contour_with_path( f, # 目标函数 x_range=(-5, 5), # x 轴范围 y_range=(-5, 5), # y 轴范围 paths=[gd_path, newton_path], # 迭代轨迹列表 labels=["GD", "Newton"], levels=20 # 等高线条数 )等高线的形状反映了问题的条件数。圆形等高线说明条件数接近 1,梯度下降也能很快收敛;椭圆越扁说明条件数越大,梯度下降越容易震荡。看到椭圆特别扁的时候,我就会直接换牛顿法或者先做变量缩放。
6.3 一个具体的调试习惯
从那以后我每次实现一个新的优化算法,都强制走一遍这个流程:先用一个已知最优解的简单问题(比如二维二次函数)验证正确性,再画收敛曲线和迭代轨迹确认行为符合预期,最后才上真实数据。这个习惯帮我省下了大量排查时间——很多 bug 在简单问题上就会暴露,不用等到复杂场景才发现。
具体来说,我会在tests/目录下放一个test_simple_quadratic.py,里面定义一个f(x) = 0.5 * xᵀQx + cᵀx的目标函数,Q 取正定矩阵,最优解用解析公式x* = -Q⁻¹c算出来。任何新算法先在这个问题上跑,解和解析解对不上就说明实现有问题。这个测试文件不到 50 行,但能拦住八成以上的低级错误。
代码包里的applications/目录下还有 Lasso 回归、SVM 对偶和投资组合优化三个案例,每个都是从问题建模到求解到结果可视化的完整流程。建议按顺序看:Lasso 最简单,适合理解 ADMM 的拆分思想;SVM 对偶涉及不等式约束,适合练 KKT 条件;投资组合优化是二次规划,适合对比内点法和 ADMM 的效果。把这几个案例跑通,凸优化的代码落地能力基本就过关了。希望帮到你。
本文还有配套的精品资源,点击获取