简介:这份资源是2022年美国数学建模竞赛(美赛)B题M奖优秀论文的完整PDF文档,面向备战美赛、数学建模竞赛的高校学生及指导教师,聚焦水资源分配最优化这一典型赛题。论文提出FHSD公平分层供需模型,融合马尔科夫链、蒙特卡洛模拟与NSGA2多目标非线性规划算法,系统解决美国西部干旱危机下的水库调度与跨州配水问题,对学习多目标优化建模思路具有较高参考价值。资源包内仅含1个PDF文件,约1.16MB,完整收录论文摘要、目录、模型构建与求解过程及灵敏度分析等核心章节,便于读者逐段研读建模逻辑与算法细节。目前已有3050人学习下载,适合希望借鉴获奖论文结构、掌握马尔科夫链与遗传算法在资源分配中应用技巧的参赛者参考。
1. 从一份 M 奖论文拆起:FHSD 模型到底能不能复现
如果你手头正压着一道水资源分配或多目标规划的赛题,大概率会搜到这份 2022 年美赛 B 题的 M 奖论文。它给出的 FHSD 模型——公平分层供需模型,核心是用马尔科夫链定更新周期、用 NSGA2 解多目标非线性规划,最后落到科罗拉多河五个州的配水方案上。我第一遍读的时候也觉得它像个黑匣子:公式一堆,结论漂亮,但真要把代码跑起来,中间缺了太多工程细节。这篇笔记就是把我自己拆这份论文时踩过的坑、补过的参数、验证过的步骤摊开讲。适合两类人:一是想拿它当模板改自己赛题的,二是做水库调度、资源分配优化、需要一套可落地多目标求解框架的。读完你至少能判断它哪部分能直接抄、哪部分必须重写。
2. 马尔科夫链定更新周期:把天气序列变成可复现的转移矩阵
论文里最容易被低估的一步,是"模型多久更新一次"。它没有拍脑袋定一个月或一周,而是用连续干旱天数的概率峰值反推更新周期。这个思路本身很稳,但落到代码上,第一道坎就是:历史气候数据从哪来、状态怎么离散、转移矩阵怎么估。
2.1 状态空间与转移概率的工程化定义
论文把天气简化成两个状态:无雨(A)和有雨(B)。这个二值化在赛题里够用,但真实数据里"有雨"的阈值你得自己定。常见做法是设一个日降水量阈值,比如 1mm,超过算有雨。状态空间就是{0, 1},0 代表干旱,1 代表降雨。
转移概率矩阵的估计,本质是数频次。给定一段历史日降水序列,统计从状态 i 转移到状态 j 的次数,再按行归一化。这里有个新手常翻车的地方:如果某个状态在历史数据里从没出现过,整行会是 0,矩阵就不可约了,后面求平稳分布直接崩。我一般会加一个极小的拉普拉斯平滑。
import numpy as np def build_transition_matrix(series, n_states=2, alpha=1e-3): """ series: 0/1 序列,0=干旱,1=降雨 alpha: 拉普拉斯平滑,防止零概率行 """ counts = np.zeros((n_states, n_states)) for i in range(len(series) - 1): counts[series[i], series[i + 1]] += 1 # 按行归一化,加平滑 row_sums = counts.sum(axis=1, keepdims=True) trans = (counts + alpha) / (row_sums + alpha * n_states) return trans # 示例:模拟一段历史序列 np.random.seed(42) hist = np.random.choice([0, 1], size=3650, p=[0.85, 0.15]) P = build_transition_matrix(hist) print(P)逻辑说明:counts[i, j]记录的是"昨天状态 i、今天状态 j"的频次。平滑项alpha保证每行和不为零。参数上,alpha取 1e-3 到 1e-2 都行,太大会稀释真实转移特征,太小起不到防零作用。n_states这里固定为 2,如果你想做更细的干旱分级(比如轻旱、重旱),可以扩到 3 或 4,但转移矩阵会变大,后面蒙特卡洛的样本量需求也跟着涨。
2.2 蒙特卡洛模拟连续干旱天数
有了转移矩阵,下一步是模拟未来一个月的天气,统计连续干旱 T 天的概率。论文的结论是:不同月份、不同湖泊,最优更新周期在 10 到 17 天之间。这个数字不是随便来的,是模拟出来的概率峰值对应的 T。
def simulate_drought_prob(P, days=30, n_sim=10000, start_state=0): """ 模拟 days 天天气,返回连续干旱天数的分布 """ max_drought = np.zeros(days + 1) for _ in range(n_sim): state = start_state cur_drought = 0 best = 0 for d in range(days): state = np.random.choice([0, 1], p=P[state]) if state == 0: cur_drought += 1 best = max(best, cur_drought) else: cur_drought = 0 max_drought[best] += 1 return max_drought / n_sim prob = simulate_drought_prob(P, days=30, n_sim=20000) peak_T = np.argmax(prob[1:]) + 1 print("概率最高的连续干旱天数:", peak_T)逻辑说明:每次模拟从start_state出发,按转移矩阵逐日采样。best记录本次模拟中出现的最长连续干旱。跑两万次后,prob[T]就是"一个月内最长连续干旱恰好为 T 天"的频率。取argmax得到峰值 T,就是论文说的更新周期。
参数上,n_sim建议不低于 10000,否则概率曲线毛刺很重,峰值会跳。start_state可以按季节调整——旱季起始状态设为 0 更贴近实际。days固定 30,对应论文的"一个月"窗口。
提示:论文里 Powell 和 Mead 在不同月份的最优 T 不同(10、11、17),说明更新周期应该按月分段设定,而不是全年一个值。这一点在复现时如果偷懒用全年平均,结论会偏乐观。
3. 供需函数与多目标方程:从水位-库容回归到 NSGA2 求解
更新周期定了,接下来是模型主体。论文把一年作为分配周期,先算年度总供水量,再往下分到各州。这条链路里,供应函数靠水位-库容回归,需求函数靠四个系数(公平效益、价格、成本、满意度)拼出来,最后交给 NSGA2 找帕累托前沿。
3.1 水位-库容的 log-log 线性回归
论文用的是双对数回归:log(水位) = slope * log(库容) + intercept。为什么用双对数而不是直接线性?因为水位和库容的关系本身是非线性的,低水位段库容变化慢,高水位段变化快,取对数后近似线性,拟合的 R² 更高。
import numpy as np from scipy import stats def fit_level_storage(storage, level): """ storage: 库容序列 (maf) level: 对应水位序列 (ft above MSL) 返回 slope, intercept, r_squared """ log_s = np.log(storage) log_l = np.log(level) slope, intercept, r, p, se = stats.linregress(log_s, log_l) return slope, intercept, r ** 2 # 示例数据(需替换为真实水位-库容表) storage_sample = np.array([5, 10, 15, 20, 24]) level_sample = np.array([3490, 3560, 3610, 3650, 3700]) slope, intercept, r2 = fit_level_storage(storage_sample, level_sample) print(f"slope={slope:.4f}, intercept={intercept:.4f}, R2={r2:.4f}")逻辑说明:stats.linregress做最小二乘回归,返回斜率、截距和相关系数。R² 越接近 1,说明对数线性关系越强。论文里两个湖的 P 值都远小于 0.01,回归系数显著非零,这是它敢用这个函数关系做供应函数的基础。
参数上,storage单位是 maf(百万英亩英尺),level单位是 ft above MSL。如果你的数据单位不同,回归系数会变,但 R² 不受影响。拟合出 slope 和 intercept 后,供应函数就是level = exp(intercept) * storage ** slope,反过来给定水位能算库容,给定库容能算水位。
3.2 需求函数的四个系数怎么设
论文设计了公平效益系数、价格系数、成本系数和满意度来构造需求函数。这四个系数是模型里最"玄学"的部分,因为论文没有给出完整的取值表。我按自己的理解拆一下:
| 系数 | 含义 | 常见取值思路 |
|---|---|---|
| 公平效益系数 | 各州用水的公平权重 | 按历史用水量占比归一化 |
| 价格系数 | 水价对需求的影响 | 参考农业/工业/居民水价梯度 |
| 成本系数 | 输水距离带来的成本 | 与湖泊到州的距离成正比 |
| 满意度 | 实际供水/需求量的比值 | 0 到 1 之间,越接近 1 越好 |
满意度是目标函数里的关键项。论文的公平性就体现在:不让任何一个州的满意度掉得太低。如果你只优化经济效益,模型会把水全给产值高的州,小州直接断水,这就不"公平"了。
3.3 NSGA2 求解多目标非线性规划
论文用 NSGA2 同时优化经济效益和社会效益。NSGA2 的优势是不需要把多目标加权成单目标,而是直接输出一组帕累托最优解,让决策者自己选。
from pymoo.algorithms.moo.nsga2 import NSGA2 from pymoo.core.problem import Problem from pymoo.optimize import minimize import numpy as np class WaterAllocation(Problem): def __init__(self, n_states=5, total_water=8.0): super().__init__(n_var=n_states, n_obj=2, n_constr=1, xl=0, xu=total_water) self.total_water = total_water def _evaluate(self, X, out, *args, **kwargs): # 目标1:最大化经济效益(取负,因为 pymoo 默认最小化) econ = -np.sum(X * np.array([1.2, 1.5, 0.8, 0.9, 1.1]), axis=1) # 目标2:最大化最小满意度(公平性) demand = np.array([2.0, 3.0, 1.0, 1.5, 1.5]) satisfaction = X / demand fairness = -np.min(satisfaction, axis=1) # 约束:总供水量不超过可用水量 g = np.sum(X, axis=1) - self.total_water out["F"] = np.column_stack([econ, fairness]) out["G"] = g problem = WaterAllocation() algorithm = NSGA2(pop_size=100) res = minimize(problem, algorithm, ('n_gen', 200), seed=1, verbose=False) print("帕累托前沿解数量:", len(res.F))逻辑说明:n_var=5对应五个州的分配量,n_obj=2对应经济效益和公平性两个目标。经济效益用线性权重模拟,公平性用"最小满意度"的负值——最小满意度越大,公平性越好。约束g保证总分配量不超过可用水量。
参数上,pop_size=100是种群规模,n_gen=200是迭代代数。这两个值越大,帕累托前沿越平滑,但计算时间线性增长。xl=0保证分配量非负。实际跑的时候,经济效益的权重向量和需求向量都要按真实数据替换,我这里用的是示意值。
注意:pymoo 默认最小化目标,所以"最大化经济效益"要写成负值。这个符号坑我见过不止一个人踩,跑出来的解全反了还以为是算法有问题。
4. 避坑与排查:复现这份论文时最容易翻车的五个地方
4.1 转移矩阵出现零概率行,平稳分布算不出来
现象:蒙特卡洛模拟跑着跑着报错,或者平稳分布全是 NaN。 原因:历史数据里某个状态从未出现,转移矩阵对应行全为 0,矩阵不可约。 解决:加拉普拉斯平滑,alpha取 1e-3 量级。如果平滑后还是有问题,检查状态离散阈值是不是设得太极端,导致某一类样本极少。
4.2 双对数回归的 R² 很低,供应函数不可信
现象:拟合出来的 R² 只有 0.6 甚至更低,水位-库容关系散点图明显不是对数线性。 原因:数据段选错了。水位-库容关系在极低水位(死水位附近)和极高水位(满库附近)会偏离对数线性。 解决:只取中间段数据做回归,或者分段拟合。论文里用的数据段是经过筛选的,不是全量数据直接扔进去。
4.3 NSGA2 跑出来的解全挤在一个角
现象:帕累托前沿退化成一条竖线,所有解的经济效益差不多。 原因:目标函数量纲差异太大。经济效益可能是几十亿,公平性满意度是 0 到 1,NSGA2 的拥挤度距离被大量纲目标主导。 解决:对目标做归一化,或者调整种群规模和交叉变异算子。我一般先把两个目标都缩放到 0 到 1 再喂给算法。
4.4 更新周期按月分段后,代码里忘了同步切换
现象:模拟结果和论文对不上,Powell 的更新周期算出来是 10 天,但论文六月到八月是 11 天。 原因:转移矩阵是按季节分别估计的,但代码里只建了一个全局矩阵。 解决:把历史数据按月份分组,每组单独估转移矩阵,模拟时按当前月份选对应矩阵。这一步多写十几行代码,但结论的月度差异全靠它。
4.5 总供水量约束设得太松,解不可行
现象:NSGA2 报约束违反,或者解的总分配量远超可用水量。 原因:total_water参数没有和年度来水量对齐。论文里年度供水量是动态的,不是固定值。 解决:把total_water改成从供应函数算出来的年度可用水量,而不是硬编码。如果做敏感性分析,这个值要作为输入变量扫一遍。
5. 敏感性分析与模型验证:怎么判断你的复现是不是"像那么回事"
论文最后做了敏感性分析,看需求侧变化对供应侧的影响。这一步在复现时特别重要,因为它是验证你模型有没有跑偏的最直接手段。如果需求增加 10%,你的模型供水量纹丝不动,那大概率是约束或者目标函数写错了。
5.1 需求侧扰动的三种扫法
我一般会扫三个维度:农业用水、工业用水、居民用水各增减 10% 到 30%,看帕累托前沿怎么移动。论文里提到可再生能源技术占比提高会降低水电需求,这对应的是需求函数里电力部分的权重下调。
def sensitivity_scan(base_demand, factors): """ base_demand: 基准需求向量 factors: 扰动因子列表,如 [0.9, 1.0, 1.1] """ results = {} for f in factors: perturbed = base_demand * f # 重新构造问题并求解 prob = WaterAllocation() prob.demand = perturbed # 需在 Problem 类里支持动态需求 res = minimize(prob, NSGA2(pop_size=100), ('n_gen', 100), seed=1, verbose=False) results[f] = res.F return results逻辑说明:对每个扰动因子,重建问题并求解,记录帕累托前沿。对比不同因子下的前沿位置,就能看出需求变化对供应策略的影响方向。
参数上,factors一般取 0.9、1.0、1.1 三档就够看趋势。如果想做更细的曲线,可以取 0.8 到 1.2 步长 0.05。注意每次求解都要固定随机种子,否则前沿的抖动会掩盖真实趋势。
5.2 用论文的结论做交叉验证
论文给了几个具体数字:经济效益可达 287.01 亿美元,Powell 需要 10 个月满足需求,Mead 需要 12 个月,剩余约 20 亿立方米水流入加利福尼亚湾。这些数字是你验证复现质量的锚点。
| 验证项 | 论文值 | 你的复现值 | 偏差容忍 |
|---|---|---|---|
| 经济效益 | 287.01 亿美元 | 待填 | ±15% |
| Powell 满足月数 | 10 个月 | 待填 | ±2 个月 |
| Mead 满足月数 | 12 个月 | 待填 | ±2 个月 |
| 剩余水量 | 约 20 亿 m³ | 待填 | ±30% |
偏差在容忍范围内,说明模型结构对了,差异来自参数估计。偏差超出容忍范围,优先检查供应函数的回归系数和需求函数的系数取值。
5.3 一个我常用的收敛判断技巧
NSGA2 跑多少代才够?我一般看帕累托前沿的 hypervolume 指标,连续 20 代变化小于 1% 就停。比固定代数靠谱,也比看目标值曲线省事。
from pymoo.indicators.hv import HV def run_with_convergence(problem, max_gen=300, tol=0.01, patience=20): hv = HV(ref_point=np.array([0, 0])) prev_hv = None stall = 0 for gen in range(max_gen): res = minimize(problem, NSGA2(pop_size=100), ('n_gen', 1), seed=1, verbose=False) cur_hv = hv(res.F) if prev_hv is not None: if abs(cur_hv - prev_hv) / prev_hv < tol: stall += 1 if stall >= patience: print(f"收敛于第 {gen} 代") break else: stall = 0 prev_hv = cur_hv return res逻辑说明:每跑一代算一次 hypervolume,如果连续patience代的变化率小于tol,就认为收敛。ref_point要设得比所有目标值都差,否则 HV 算出来是负的。
参数上,tol=0.01对应 1% 变化率,patience=20是容忍的停滞代数。这两个值可以根据你的计算预算调,预算紧就放宽到 2% 和 10 代。
从那以后我每次复现这类多目标论文,都强制先跑一遍敏感性扫描再下结论——因为帕累托前沿好看不代表模型稳,需求侧一扰动就散架的情况我见得太多了。希望这份拆解能帮你少走几个弯路。
本文还有配套的精品资源,点击获取