简介:面向优化理论与运筹学学习者的专题文档,聚焦拟凸问题向凸问题可行性问题的转化及二分求解思路。内容围绕拟凸函数与凸函数的差异、凸问题解集与局部最优性质展开,并说明如何借助新变量与约束把拟凸目标化为阈值可行性判断,再通过二分搜索逐步收缩区间定位可行阈值。文档还会讲解搜索区间初始化、中间值计算、条件检验与边界更新等关键环节,帮助读者理解算法为何有效以及何时终止。资源包仅含1个docx文件,整体约200KB,篇幅紧凑,适合作为课堂笔记补充或算法复习材料。已有559人学习,说明该主题在机器学习、数据分析等场景中具有一定关注度。读者可从中梳理拟凸优化的建模路径、二分算法的迭代条件与终止逻辑,并对照阈值更新规则形成清晰的概念框架,为后续阅读优化教材或开展实验提供参考。
1. 拟凸问题转凸可行性:二分法为什么值得先学
很多人第一次看到拟凸问题,会下意识去找梯度下降,结果在第一步步长上就卡住了:拟凸函数的等值集虽然是凸集,但函数本身可以有平坦段、尖点,甚至不可微,梯度信息靠不住。我实际拆这类题的时候,最先落地的不是求导,而是二分——把“让 f(x) 最小”换成“让 f(x) ≤ t 是否可行”,阈值 t 单调可判,剩下的就是熟悉的二分搜索。这条路子把拟凸优化这个相对冷门的方向,稳稳接回了凸优化这个成熟工具箱,机器学习里的鲁棒回归、运筹里的选址与资源分配、数据分析里的分位数拟合,都能套进去。下面按“概念对齐 → 转化建模 → 二分实现 → 坑位排查 → 精度验证”走一遍,代码可直接抄,参数可照着改。
2. 从拟凸到凸:把优化目标换成一串可行性判定
2.1 拟凸与凸的差别,落在等值集上
先把定义摆正。拟凸函数要求对任意 x、y 和 λ∈[0,1],满足 f(λx+(1-λ)y) ≤ max{f(x), f(y)};凸函数要求 f(λx+(1-λ)y) ≤ λf(x)+(1-λ)f(y)。两者的关键差别不在函数值大小,而在结构:拟凸只保证下等值集 S(t) = {x | f(x) ≤ t} 是凸集,而凸函数额外保证函数图像本身“碗形”。
这个差别直接决定算法选型。凸函数上梯度下降有收敛保证,拟凸函数上梯度可能指向错误方向,甚至出现“沿负梯度走一步函数值反而变大”的翻车现场。但下等值集是凸集这一点极其值钱:判定“存不存在 x 使 f(x) ≤ t”就退化成一个凸可行性问题,凸集交非空与否可以交给成熟的凸求解器或投影方法处理。所以转化的本质不是改写函数,而是把目标函数藏进约束的右端项,让每一层子问题都变成凸的。
顺带说清楚适用边界:拟凸函数的最优值集合(最优解集)是凸集,局部最优即全局最优,这一点比一般非凸函数友好得多;但它不保证严格拟凸,也不保证唯一解。工程上遇到多个最优解是常态,二分最后收敛到的是一个阈值区间,而不是唯一坐标,这一点在后续写停止条件时很关键。
2.2 水平集转化:引入阈值 t,把目标搬进约束
标准做法是引入标量 t,构造一族可行性问题:
原问题: min f(x) s.t. x ∈ C 转化后: 求最小的 t,使得存在 x 满足 f(x) ≤ t x ∈ CC 是原问题的约束集(比如箱约束、线性约束)。这里 f 拟凸、C 凸,则对固定 t,集合 {x ∈ C | f(x) ≤ t} 是凸集——这正是可行域。于是“最小化 f”等价于“在 t 轴上二分,找可行与不可行的分界点”。
这里有个容易被忽略的前提:t 的搜索区间必须先有界。左边界 lb 取 f 的一个已知上界(比如任意可行点处的函数值,哪怕它离最优很远),右边界 ub 取一个明确不可行的值或问题本身的下界。若 f 在 C 上无下界,二分就没有意义,这是建模阶段就要判掉的情况。
我的习惯是先跑一次“可行性冒烟测试”:随便取一个 t,调可行性子问题,确认求解器能返回“可行/不可行”的明确信号,再进二分循环。很多翻车不是二分写错,而是子问题把“求解失败”和“不可行”混为一谈。
| 要素 | 拟凸原问题 | 转化后可行性问题 |
|---|---|---|
| 决策变量 | x | x(t 固定)或 (x, t) |
| 目标 | min f(x) | 无目标,只判可行 |
| 凸性 | 不一定凸 | 固定 t 时为凸 |
| 输出 | 最优 x* | 最优阈值 t* + 一个可行 x |
2.3 可行性子问题怎么写:三种常见落地方式
固定 t 后,判定 f(x) ≤ t 且有 x ∈ C 是否可行,常见有三条路,按问题规模选。
第一种,解析投影。若 f 形式简单(如二次型、加权 l1 范数)且 C 是箱约束,下等值集与非负象限的交可以直接算出投影闭式解,判定退化为“投影后函数值是否 ≤ t”,一次矩阵运算搞定,速度最快。
第二种,凸求解器。把 f(x) ≤ t 写成标准凸约束(如二阶锥约束、线性矩阵不等式),交给现成的凸优化求解器,返回可行点或不可行证书。这条路通用性最好,代价是每次二分迭代都要付一次求解开销。
第三种,对偶/切平面。对偶函数是凹的,可行性可以转成对偶间隙判定;切平面法在低维时收敛快,但实现复杂度高,一般只在求解器不可用或需要嵌入实时系统时考虑。
我一般默认第二种起步,跑通后再针对热点路径换第一种做加速。下面给一个可直接运行的 Python 骨架,用 scipy 的线性规划与投影做可行性判定,避开重型依赖:
import numpy as np from scipy.optimize import linprog # 拟凸目标示例:f(x) = max_i (a_i^T x + b_i),分段线性拟凸 A = np.array([[1.0, 2.0], [-1.0, 1.0], [2.0, -1.0]]) # 每行一个方向 b = np.array([0.5, -0.2, 1.0]) def f(x): return np.max(A @ x + b) # 逐分量取最大,典型拟凸形式 def feasible(t, bounds=(-10.0, 10.0)): """判定是否存在 x 使 f(x) <= t,x 在箱约束内。 用 linprog 的可行性模式:最小化 0,约束为 A x + b <= t。""" res = linprog( c=np.zeros(2), A_ub=A, b_ub=t - b, # 关键:把阈值搬进右端项 bounds=[bounds, bounds], method="highs", ) return res.success, (res.x if res.success else None)这段代码的逻辑说明:把 f(x) ≤ t 逐分量展开成 A x ≤ t·1 − b,这是一个标准线性不等式组,linprog 在目标系数全零时只做可行性判定,返回 success 即代表该 t 可行。参数上,bounds 必须与问题定义域一致,设小了会把可行点误判为不可行,这是最常见的假阴性来源;method 选 highs 是因为它对退化情形更稳。拿到的 res.x 就是该阈值下的一个可行解,二分结束时直接可用,不需要再单独回代求最优 x。
3. 二分算法实现:区间怎么定、循环怎么停
3.1 初始化区间:两个边界都要有依据
二分的正确性依赖一个不变量:lb 始终可行,ub 始终不可行。左右边界不是随便填的。
左边界 lb:取一个已知可行点处的函数值。比如从箱约束中心出发算出 f(x0),令 lb = f(x0),并把这个 x0 存为当前最优解。这样不变量天然成立,还能在后续每次找到更优可行解时顺手更新。
右边界 ub:取一个明确的不可行值。工程上有两种取法:一是问题存在理论下界(如非负函数下界为 0),但下界是“可能的最小值”,不是“不可行值”,不能直接当 ub;二是从 lb 出发倍增扩张,直到某次不可行,即 ub = lb + 2^k,倍增次数通常不超过几十次。倍增是更稳的通用做法,因为它不依赖对问题量级的先验判断。
def init_bracket(f, feasible, x0): lb = f(x0) best_x = x0 step = 1.0 ub = lb + step # 倍增扩张,直到找到一个不可行阈值 for _ in range(60): ok, _ = feasible(ub) if not ok: return lb, ub, best_x # 该阈值可行,顺便记录更好的解 ok2, x2 = feasible(ub) if ok2: best_x = x2 lb = ub # 左边界推进,但仍需保证 lb 可行 step *= 2.0 ub = lb + step raise RuntimeError("未找到不可行上界,检查问题是否有下界")这段里有个细节值得说:倍增时每确认一次可行,就把 lb 推到该阈值,同时更新 best_x,这样 lb 始终保持“可行”不变量。参数 60 是安全上限,选取依据是双精度浮点下 2^60 早已超出任何合理量级,触顶说明问题建模有误——要么 f 在 C 上无下界,要么可行性判定在给出假阴性。上面代码里连续调了两次 feasible(ub) 其实是一次多余的判定,实际写的时候应合并成一次调用缓存结果,这里保留写法是为了让“判定结果驱动分支”的意图更清楚。
3.2 主循环:精度判据与三种停止条件
二分循环本身只有几行,难的是停止条件。常见三类:绝对区间宽度 ub − lb < eps;相对宽度 (ub − lb) / max(1, |lb|) < eps;固定迭代次数。我一般用绝对宽度加迭代上限双保险。
def bisect_quasiconvex(f, feasible, lb, ub, best_x, eps=1e-6, max_iter=200): for it in range(max_iter): if ub - lb <= eps: break mid = 0.5 * (lb + ub) ok, x = feasible(mid) if ok: ub = mid # 注意:可行时收缩右界 best_x = x # 记录该阈值下的可行解 else: lb = mid # 不可行时推进左界 return lb, ub, best_x, it逻辑说明:这里的不变量是 lb 侧“可行”、ub 侧“不可行”,所以判定可行时应把 ub 往下压,而不是更新 lb——这是新手最容易写反的一处,写反后区间会朝错误方向跑,最后得到完全离谱的阈值。参数 eps 的选取和 f 的量级有关:f 值在 1e6 量级时 eps 取 1e-6 意味着相对精度 1e-12,已经接近双精度极限,实际取 1e-4 到 1e-6 之间更合理。max_iter 只是兜底,正常情况迭代次数约为 log2((ub−lb)/eps),区间跨度 1e6、eps 1e-6 时约 40 次。
返回值里的 best_x 是最后一个可行阈值下找到的解,它的函数值落在 [lb, ub] 内,可以直接作为原问题的近似最优解使用;如果业务需要严格可行解,就回退到上一次成功可行时缓存的 x,而不是用 lb、ub 的中点。
3.3 与梯度类方法对照:什么时候二分更划算
不是所有拟凸问题都该上二分。判断标准看两件事:可行性子问题的单次代价,以及所需精度。
| 方法 | 适用条件 | 单次代价 | 收敛性 | 我的使用场景 |
|---|---|---|---|---|
| 梯度下降 | f 可微且梯度 Lipschitz | 低 | 依赖步长,拟凸下无全局保证 | 光滑近似可接受时 |
| 次梯度法 | f 凸(非拟凸) | 低 | O(1/√k) | 真正的凸问题 |
| 二分 + 可行性 | 下等值集凸、能判可行 | 一次可行性求解 | 线性收敛(区间折半) | 拟凸、不可微、有明确阈值语义 |
| 割平面/对偶 | 低维、需高精度 | 中到高 | 超线性(实践中) | 嵌入式、求解器受限 |
二分的优势是收敛率确定:每轮区间精确折半,log 量级迭代,不会出现梯度法那种“步长没调好直接发散”的玄学。劣势也清楚:每轮都要解一个可行性子问题,若子问题是大型半定规划,单次开销足以让总时间远超预期。我的经验是,当可行性子问题能在毫秒到秒级返回时,二分是最省心的选择;单次超过分钟级,就该考虑对偶或直接换问题形式。
4. 避坑与排查:二分在拟凸问题上的五类翻车
4.1 现象:二分裂解,阈值收敛到边界
原因通常是可行性判定有假阴性——本该可行的 t 被判成不可行,于是 lb 被错误推进,区间整体右移。最常见的来源是子问题的数值容差设得比二分精度还松:求解器内部可行性容差 1e-4,而二分 eps 设成 1e-6,两者不匹配,边界附近判定随机翻转。
解决:让子问题容差至少比二分 eps 紧一到两个数量级,或者把二分 eps 放宽到与求解器容差同量级。我的做法是固定求解器容差 1e-9,二分 eps 取 1e-5,留足余量。另外,判定结果不要只看 success 布尔值,还要看求解器返回的状态码,把“数值失败”和“严格不可行”区分开,前者应重试或降精度,而不是直接当不可行处理。
4.2 现象:best_x 的函数值比 ub 还大
原因是可行性子问题返回的 x 只是“满足约束容差”的点,容差内的轻微越界会让 f(x) 略超阈值。这在阈值语义严格(比如资源上限、风险阈值)的场景里是实打实的事故。
解决:拿到 x 后做一次独立回代校验 f(x) ≤ t + tol,tol 按业务定;不通过就退回上一次缓存的严格可行解。更进一步,可以在子问题里把约束收紧一点(t − δ,δ 取容差的几倍),用轻微保守换取严格可行性,这招在风控类场景里我基本是默认开的。
4.3 现象:倍增阶段死循环或直接抛错
原因往往不是代码,而是问题本身无下界,或 f 在 C 上取值无界。比如 C 只给了等式约束,方向上有整条射线都可行,阈值可以无限降低,倍增永远找不到不可行上界。
解决:建模阶段先检查 C 是否有界(有箱约束、范数约束即可),无界时给 f 补一个强制增长项或显式加界。真需要处理无界情形,就改用对偶可行性判定,而不是硬套二分。
4.4 现象:迭代次数远超 log2 预期
原因是区间初始宽度过大,或者 eps 设得远小于浮点可分辨的精度,后期 mid 与 lb、ub 相等,区间不再收缩,只能靠 max_iter 兜底退出。
解决:在循环里加一条保护——若 mid 等于 lb 或 ub,直接 break,并把当前 best_x 作为结果。同时把 eps 与 f 的量级挂钩,用相对精度判据替代纯绝对值。
4.5 现象:多个最优解导致结果不可复现
拟凸问题的最优解集是凸集,可能是一条线段甚至一个面。二分只收敛到最优阈值,具体返回哪个 x 取决于子问题求解器的内部路径,换个求解器或换个线程数,结果就变。
解决:别把 best_x 当唯一答案用。业务侧要么接受“任意一个最优解”,要么在可行性子问题里加一个次级目标(如最小范数解),把解固定下来。需要可复现时,显式设定求解器随机种子并关闭并行,虽然会慢一点,但结果稳定。
提示:以上五类问题里,假阴性和容差不匹配占了绝大多数。调试顺序建议先查容差,再查区间不变量,最后才怀疑算法本身。
5. 精度验证与工程化收尾:把二分结果钉死
跑通不等于可信,最后一章讲怎么验证二分结果,以及我常用的一个收尾技巧。
验证分三层。第一层,不变量检查:在循环每一轮后断言 f 在 lb 处可行、在 ub 处不可行(可行侧用缓存的 best_x 回代),一旦断言失败立刻定位到出问题的那一轮。这个检查成本极低,但能抓住八成的逻辑写反。第二层,与暴力网格对照:在一维或二维小规模实例上,把 t 轴等距打 1000 个点逐个判可行,取最小可行值作为参考解,与二分结果比对,相对误差应在 eps 量级内。第三层,敏感度扫描:把 eps 从 1e-2 扫到 1e-8,观察阈值随 eps 的收敛曲线,正常应单调收敛并趋于平台;若曲线抖动或持续漂移,说明子问题容差或建模有问题,别急着上生产。
def verify(f, feasible, lb, ub, best_x, eps): # 第一层:不变量与回代校验 assert f(best_x) <= ub + 1e-8, "缓存解超出上界" # 第二层:小规模网格对照 grid = np.linspace(lb, ub, 1000) ref = next(t for t in grid if feasible(t)[0]) assert abs(ref - lb) <= 10 * eps, f"与网格参考解偏差过大: {ref} vs {lb}" # 第三层:eps 敏感度由外层循环调用,记录 (eps, lb) 序列 return ref这段校验代码里,网格点数 1000 是经验值,区间跨度不大时足够抓到最小可行阈值的 1e-3 级近似;断言阈值取 10 倍 eps 是为了容忍网格离散误差,不能设太紧。第三层的敏感度扫描不在这个函数里做,而是外层对多个 eps 重复调用二分并记录 (eps, lb),最后看曲线是否收敛到平台。
工程化收尾上,我一般会把二分封装成一个带缓存的求解器:对同一个 t 的可行性判定结果缓存,避免重复求解;把区间不变量、回代校验写成可开关的调试断言,生产环境关掉断言但保留回代校验。还有一个很实用的技巧是把二分过程日志化——每轮记录 lb、ub、mid、可行与否、best_x 的函数值,出了问题直接翻日志就能定位到哪一轮开始跑偏,比事后复现快得多。
从早期把二分写反方向、白跑一整晚实验那次之后,我每次落这类拟凸转化都强制走一遍“模糊测试”:随机生成几十个已知最优值的小实例,让二分跑完和解析解对一遍,全绿了才接真实数据。这个习惯省下的返工时间,远比写测试花的多。希望这套流程帮到你,把拟凸问题稳稳接回凸优化的工具箱里。
本文还有配套的精品资源,点击获取