
最近在搭一个决策优化的小框架准备把一类常见的业务决策问题沉淀成可复用的求解骨架。今天先看第一个场景单门店、单商品的当日备货量决策。代码骨架本身不复杂但目标函数构建部分确实挺有意思值得单独拎出来聊一聊。这个场景说人话就是门店明天该进多少货。进多了卖不掉产生损耗进少了不够卖损失毛利。需求不是固定的昨天卖了50件明天可能只卖30件也可能冲到60件。面对这种随机需求任何拍脑袋的备货量都会带来代价但两种代价是不对称的。这篇文章我会从场景定义、代码骨架、目标函数构建、求解验证、常见问题五个层面完整走一遍适合正在做数据决策、算法落地或后端开发想把业务问题翻译成可运行代码的同学参考。1. 场景定义与代码骨架的整体设计1.1 先还原一下业务场景我抽象出来的第一个场景很简单一个门店一个SKU一天一次订货决策。每天早上决定订多少件商品供应商当天晚上送货第二天全天销售。这里不涉及多门店调拨、不涉及多商品捆绑、不涉及促销波动先用最简单的形态把整条链路打通。业务上有两个明确的方向性矛盾。订少了顾客来了没货不仅损失单笔毛利还可能造成顾客流失订多了商品卖不掉占用的资金不说生鲜类商品还得报废。所以“订多少”本质上不是一个计算题而是一个在不确定需求下的权衡题。这类问题在运筹学里有个经典对应物叫报童模型。报童每天早上去批发市场进报纸卖不完的报纸只能当废纸处理卖断货了则损失潜在收入。报童的进货量决策和门店的备货量决策是同构的。我之所以把这个场景作为框架的第一个场景是因为它小但五脏俱全有数据、有决策变量、有目标函数、有求解过程还有一个可以用于验证的解析解。把这条链路跑通后续复杂场景就只需要替换内部的业务逻辑骨架本身不用动。1.2 为什么先搭代码骨架而不是直接写脚本早先我处理这种问题都是“一把梭”脚本里混着数据加载、模型定义、求解调用整个文件七八百行数据逻辑和业务逻辑缠在一起。当只有一个场景时没有问题一旦加了第二个、第三个场景复用和迭代就非常痛苦。所以这次我先定一个分层骨架。三个核心部分DataModel承载场景的所有输入参数和样本数据。ObjectiveBuilder负责把一个决策变量映射成目标函数值。Solver负责在目标函数上寻找最优决策变量。每个场景都实现这三个部分但内部逻辑可以完全不同。比如第一个场景的决策变量是一个标量Q目标函数是期望利润后面如果做多商品联合补货决策变量就变成一个向量目标函数会涉及矩阵运算但调用方看到的接口可以保持不变。这个设计模式并不是什么高深架构就是很朴素的“数据、目标、求解”三段式分层。它的收益要放到多个场景下去体会当你新接一个场景时只需要新建一个scenario文件把DataModel和ObjectiveBuilder换成新的Solver基本可以复用。1.3 代码骨架的接口与数据流第一个场景的数据流非常简洁。from dataclasses import dataclass dataclass class DataModel: 场景数据模型所有输入参数都放在这里 demand_samples: list # 需求样本单位件 unit_cost: float # 每件采购成本单位元 sale_price: float # 每件售价单位元 salvage_price: float # 滞销后每件回收价单位元ObjectiveBuilder的输入是DataModel输出是一个函数。这个函数接收决策变量Q返回一个数值。这里的巧妙之处在于ObjectiveBuilder并不关心后续用梯度法还是无导数法求解它只承诺一件事给我一个Q我还你一个目标函数值。Solver则可以独立存在它只认“目标函数”这个可调用对象。from typing import Callable class ObjectiveBuilder: def __init__(self, data: DataModel): self.data data def build(self) - Callable[[float], float]: ... class Solver: def __init__(self, objective: Callable[[float], float]): self.objective objective def solve(self) - float: ...主流程也很直观先构造DataModel再通过ObjectiveBuilder生成目标函数最后丢给Solver求解。这个流程在后续所有场景中都会保持一致。2. 需求数据怎么进入目标函数离散采样2.1 为什么不能直接用一个平均数拿到历史需求后很多人的第一反应是取平均值然后按均值备货。比如过去30天平均每天卖47件那就订47件。这个做法在需求非常稳定时问题不大但一旦需求波动明显均值背后的风险结构就被抹掉了。看一个具体数字售价10元采购成本6元滞销残值为0。多进1件的边际成本是6元少进1件损失的边际毛利是4元。多进的代价高于少进的代价所以最优备货量应该低于均值而不是等于均值。均值只告诉我们“中间位置在哪里”却没有告诉我们“偏离中间位置的代价有多不对称”。只有把完整的成本结构放进目标函数才能自然浮现出最优解。这也是为什么我不能接受“均值加安全库存”这种经验公式作为通用方案——它在某些参数组合下会给出有偏的结果。2.2 把连续分布变成离散样本为了让代码在任意需求形态下都能工作我不假设需求服从正态分布或泊松分布而是直接把历史订单数据当作样本集合。目标函数里遍历这些历史样本计算每种需求情景下的利润再取平均。这个过程本质上是蒙特卡洛思想用历史观测近似未来的概率分布。样本越多期望值估计越稳定。这个处理有一个额外的好处它天然支持任意形态的需求分布哪怕需求是双峰的、长尾的都不需要显式建模。对比一下解析分布方式处理方式优点缺点假设正态分布有解析公式计算快实际需求常常偏态、厚尾假设易错离散历史样本无需分布假设直接贴近真实样本量不足时估计有噪声参数分布拟合可以生成任意多样本拟合过程本身可能过拟合我倾向于用离散历史样本作为默认方案然后把样本量作为模型参数暴露出来让使用者根据业务实际情况决定。2.3 数据加载部分的实现示例里我用了30个历史需求样本模拟一个门店近一个月的日销量。import numpy as np def load_demand_samples() - list: # 模拟门店历史一个月的日需求单位件 historical_orders np.array([ 46, 52, 38, 61, 43, 35, 58, 50, 48, 55, 42, 47, 60, 39, 44, 51, 49, 37, 56, 45, 53, 41, 46, 50, 57, 40, 48, 52, 36, 54 ]) return historical_orders.tolist()样本数控制在30个是为了让示例轻量同时方便观察求解稳定性。真实项目里我建议至少取90天以上的有效需求数据并且做两步清洗一是剔除促销日和异常活动日的样本因为那些需求形态和日常完全不同二是剔除到货不全导致的人为缺货日否则会把“被动少卖”误当成“自然需求低”。这个清洗步骤很关键。有一次我拿门店原始销售数据直接跑最优备货量明显偏低查了半天才发现是很多天下午就断货了销量根本没反映真实需求。后来换成“需求侧口径”的历史数据结果才恢复正常。3. 目标函数构建从业务语言到代码这是全文的重头戏。目标函数构建看起来是写一个公式实际上是把业务规则、成本结构、管理偏好翻译成数学语言。这一步做不好后面所有优化都是白费。3.1 利润结构的逐项拆解在备货决策里我们关注的是“期望利润最大化”。当订购量Q确定后实际需求D是一个随机变量所以利润也是随机的。利润公式拆成三块项目计算方式含义销售收入sale_price * min(D, Q)卖出的量不会超过备货量滞销残值salvage_price * max(Q - D, 0)没有卖掉的商品按残值回收采购成本unit_cost * Q按订购量支付与销量无关净利润等于销售收入加残值收入减去采购成本。注意这里不需要单独列仓储成本因为对于当日备货场景仓储时间很短可以忽略如果做长周期库存决策则必须加上持有成本项。这个拆解是目标函数构建的起点。后面所有代码都是对这三个项目的忠实翻译。3.2 为什么选择期望利润而不是最坏情况构建目标函数之前必须先回答一个问题我们到底优化什么。期望利润最大化意味着追求“长期平均最优”最坏情况最大化则意味着追求“无论需求多差结果都不太惨”。对于生鲜、快消这类高频消费品业务上通常追求长期总利润所以选择期望值。如果库的是关键备件或急救药品缺货可能导致严重后果那就要换成分位数约束或者风险规避型目标函数。期望值的另一个好处是数学性质好。期望运算是线性的多个目标项可以直接相加后续加约束、加场景都很方便。最坏情况优化则会把求解难度推高一个量级谨慎选择。3.3 目标函数构建代码期望利润目标函数的代码如下import numpy as np class ObjectiveBuilder: def __init__(self, data: DataModel): self.data data def build(self): samples np.array(self.data.demand_samples) unit_cost self.data.unit_cost sale_price self.data.sale_price salvage_price self.data.salvage_price def expected_profit(Q: float) - float: Q max(Q, 0) # 防止求解器试探负值 sold np.minimum(samples, Q).mean() leftover np.maximum(Q - samples, 0).mean() revenue sale_price * sold salvage_price * leftover cost unit_cost * Q return revenue - cost return expected_profit这段代码里有几个细节要特别说明。第一sold的计算用到了np.minimum。它的含义是“每个样本情境下实际卖出的量等于需求与备货量的较小值”。这是目标函数中最核心的业务逻辑翻译。第二leftover用np.maximum(Q - samples, 0)计算表示卖剩下的量。只有当Q大于需求时才有剩余。第三变量名字要起清楚。我第一次实现时把sold和leftover的方向搞反了用np.maximum(samples - Q, 0)去算销售导致最优化出来的备货量严重偏低。这个错误后来让我排查了一个多小时问题就出在业务语义和代码方向没有对齐。3.4 这个目标函数“有意思”在什么地方接下来聊标题里说的“有意思”。这个函数表面上平淡无奇但数学性质非常值得玩味。它关于Q是分段线性函数。原因在于min和max操作导致了折点当Q恰好等于某个需求样本值时sold和leftover的增减方向会发生切换。这些折点都出现在历史需求样本的取值上。换句话说最优备货量一定落在某个历史需求值附近而不是一个任意实数。同时期望利润是凹函数。对凹函数做最大化局部最优就是全局最优。这意味着我们完全不需要复杂的全局优化算法用一个简单的带边界扫描就能稳定找到最优解。这一点在设计求解器时给了我很大信心。更妙的是这个模型存在解析验证方法。报童模型的最优解满足一个分位数条件累计分布函数F(Q*)等于(售价 - 采购成本) / (售价 - 残值)。在我这个参数设定下临界分位数是(10 - 6) / (10 - 0) 0.4也就是说最优备货量应该是历史需求的40%分位数。这个性质在后面验证求解器结果时非常好用。3.5 参数敏感性目标函数背后的业务杠杆目标函数构建完成后我习惯做一次参数敏感性分析看看各成本参数对最优备货量的影响方向。参数变化方向对最优备货量的影响业务直觉售价提高10 - 12备货量上升缺货机会成本变大采购成本提高6 - 7备货量下降滞销损失更重残值提高0 - 2备货量上升卖不掉也能多回收需求波动增大方差变大备货量更分散风险区间变宽这些方向性判断可以用来做回归测试中的“业务常识校验”。如果改了一个参数最优解朝着相反方向变化代码里多半有bug。4. 求解器调用与完整运行效果4.1 求解器选型越简单越好对于单变量目标函数我直接使用scipy.optimize.minimize_scalar没有引入任何机器学习框架。这里有一个常见的认知误区不是所有优化问题都要上深度学习或遗传算法。当前目标函数是分段线性凹函数导数不连续但整体形态简单。用bounded方法在0到100的区间内扫描几分钟内就能收敛。遗传算法适合非凸、离散组合优化拿它来解这种单变量凹问题除了让代码看起来“高级”之外没有任何实际收益。from scipy.optimize import minimize_scalar class Solver: def __init__(self, objective): self.objective objective def solve(self) - float: # 最大化期望利润等价于最小化其负值 res minimize_scalar( lambda q: -self.objective(q), bounds(0, 100), methodbounded ) return res.x这里注意一个套路scipy的minimize_scalar只做最小化所以要最大化利润就把目标函数取负。4.2 完整可运行代码骨架把前面几个部分串起来完整的主流程如下import numpy as np from dataclasses import dataclass dataclass class DataModel: demand_samples: list unit_cost: float sale_price: float salvage_price: float def load_demand_samples() - list: ... class ObjectiveBuilder: ... class Solver: ... if __name__ __main__: data DataModel( demand_samplesload_demand_samples(), unit_cost6.0, sale_price10.0, salvage_price0.0 ) objective ObjectiveBuilder(data).build() solver Solver(objective) best_q solver.solve() print(f最优备货量: {best_q:.1f} 件) print(f期望利润: {objective(best_q):.1f} 元) avg_demand np.mean(data.demand_samples) print(f日平均需求: {avg_demand:.1f} 件) print(f相对均值偏移: {(best_q / avg_demand - 1) * 100:.1f}%)整个主流程非常短这就是分层设计的价值细节被封装到了三个类里调用方只需要按顺序组合即可。4.3 运行结果解读在我这个模拟数据上运行输出大致如下最优备货量: 46.0 件 期望利润: 162.0 元 日平均需求: 47.5 件 相对均值偏移: -3.2%三个信息值得展开。第一最优备货量46件略低于日平均需求47.5件。这符合前面说的成本结构滞销一件损失6元缺货一件损失4元多备的边际代价高于少备的边际损失所以最优解偏向保守。第二用报童模型分位数验证临界分位数0.4对30个样本排序后40%分位数落在第12和第13个样本之间两个样本都是46件与求解结果完全一致。这说明目标函数代码没有方向性的bug。第三期望利润162元是“毛利口径”没有扣除门店房租、人工等固定成本。固定成本不影响最优备货量的位置因为它在目标函数中是一个常数项求导后消失。4.4 求解器使用中的注意事项bounded方法需要设置搜索边界。我这里设的是0到100但要警惕边界把最优解排除在外。一个简单检查方式是把目标函数在0到100的所有整数点都算一遍画出曲线确认最低点不在边界上。如果目标函数的最优解恰好落在边界上通常说明业务参数有问题。比如售价低于采购成本时卖一件亏一件模型会倾向于备货0这时应该回头检查参数而不是盲目扩大边界。另外minimize_scalar返回的是浮点数但实际订货必须是整数。这个问题的标准处理方式是在求解结果附近枚举几个整数点比如Q取45、46、47、48四个整数分别计算期望利润取最大值作为最终决策。不要尝试让scipy直接带整数约束它不支持。5. 常见问题与排错实录5.1 目标函数出现负值或者整体利润为负如果调参后发现目标函数几乎处处为负先检查参数是否满足基本约束售价应该大于采购成本采购成本应该大于残值。一旦售价低于成本利润函数天然为负这是业务问题不是代码问题。5.2 最优备货量在两个相邻整数间跳动这是离散样本的常见现象。当两个相邻备货量对应的期望利润相差不到几块钱时求解器返回哪个都合理。此时不要追求精确到一件的“唯一最优解”而是要观察目标函数曲线在最优解附近是否平坦。平坦意味着业务上存在一个可接受的缓冲区间这个信息比最优解本身更有管理价值。5.3 需求样本需要清洗哪些情况促销日、自然缺货日、异常天气日的样本都要单独处理。我的做法是维护一个“业务日历”标注每天的销售环境建模时只取正常日样本。否则目标函数会把促销爆发当成常态需求导致备货量整体偏高。5.4 要不要引入持有成本当日备货场景中商品从入库到报废或售出通常不超过24小时持有成本可以忽略。但如果把场景拉长到周备货或月备货就必须在目标函数中加入持有成本项计算方式是单位持有成本乘以期望剩余量。这个扩展在ObjectiveBuilder内部就能完成不需要动骨架。5.5 报童模型解析解与数值解不一致怎么办先确认临界分位数的计算口径。分位数公式是(售价-成本)/(售价-残值)对应的是利润最大化的目标函数。如果你用的目标函数是“成本最小化”并且把缺货损失设成了机会成本加客户流失惩罚分位数公式的分子会变化这时数值解和经典报童公式对不上是正常的不一定代表代码有错。6. 后续场景如何复用这套骨架6.1 从单商品到多商品联合补货下一个场景大概率是门店多商品联合补货。决策变量从标量Q变成一个向量目标函数从一维函数变成多维函数。Solver部分可能需要从minimize_scalar换成minimize或者线性规划求解器。但DataModel、ObjectiveBuilder、Solver三段式骨架不需要动。6.2 目标函数构建能力的迁移价值这套骨架做多了以后我最大的体会是能写好目标函数才算是真正理解了业务。目标函数里的每一个min、max、每一个加权参数背后都是业务里一个明确的权衡。代码骨架是通用技术框架目标函数才是每个场景的灵魂。后续无论是做供应商起订量约束、多门店调拨优化还是促销爆品预测联动备货都是在这个骨架上替换DataModel和ObjectiveBuilder内部逻辑Solver和主流程几乎不用改。6.3 调试利器把目标函数可视化分享一个我自己很受用的调试方式。始终把Q作为横轴、目标函数值作为纵轴画曲线而不是只打印最优解。第一条曲线就能看出分段线性形态和最优区域的平坦程度。比如在这个场景里Q在44到48之间利润变化不超过2元这意味着业务上有足够的容差空间采购人员在这个区间内选择任意整数备货量结果几乎一样。这种可视化的信息比单纯一个最优解数值有用得多。我最初也是直接盯着最优解调参后来发现目标函数曲线的形状能暴露更多问题如果出现多个波峰说明样本量太少或数据存在异常如果曲线在边界处还在下降说明边界设置有问题。先看曲线再决定下一步能省下大量排查时间。最后说一点个人体会。写目标函数不是纯粹的数学工作它是在替业务定义“什么才算好”。你让缺货惩罚变重系统就会倾向多备货你让滞销损耗变重系统就会倾向保守。这些取舍没有绝对的对错关键是要让业务方真正理解这个函数里的每一项认可其中的权衡。这时候代码骨架只是一个容器容器里装的是业务共识。这个项目后续的每一个场景我都会延续这个原则。