第 7 章 · 04 凸优化求解与前向测试 本节摘要:本节精读 forecast.py(约 618 行)和 check consistency.py(约 149 行),Smart Farmers 的工程落点与思想高潮。前三节搭好了所有零件——本节把它们组装成一台会跑的机器。forecast.py 的 getproduction 把上一节估出的回归系数 α/β/γ 组装成 CVXOPT 的 quadraticcoeff(α 对角阵)和 linearcoeff(由历史价格、历史产量、Δ人口、ΔGDP、成本初值拼接),用 cvxopt.solvers.qp 解出 30 维最优产量;Nelder-Mead(scipy.optimize.
本节摘要:本节精读 forecast.py(约 618 行)和 check consistency.py(约 149 行),Smart Farmers 的工程落点与思想高潮。前三节搭好了所有零件——本节把它们组装成一台会跑的机器。forecast.py 的 get_production 把上一节估出的回归系数 α/β/γ 组装成 CVXOPT 的 quadratic_coeff(α 对角阵)和 linear_coeff(由历史价格、历史产量、Δ人口、ΔGDP、成本初值拼接),用 cvxopt.solvers.qp 解出 30 维最优产量;Nelder-Mead(scipy.optimize.minimize)在 costfunction 上随机搜索最优成本初值,cost_optimal 那 30 个硬编码数字就是搜索结果。前向测试外接 IMF 人均 GDP(capita.csv)+ UN 人口预测,假设农地年增 1%/5%,对 2019-2025 情景预测,再用 Bursa Malaysia 棕榈油 CPO 期货做前向验证。但本节最深刻的不是求解,而是前向测试打脸回测——模型预测 2018-2020 backwardation、2021+ contango,实际却被 COVID 的 W 型复苏砸碎。引 MSCI 研究主管 Dimitris Melas 那句"I have never seen a bad backtest"。最后讲 Ricardian 推广版:把 Pareto Optimal 升级成 Nash Equilibrium,禁止国家间竞争合谋(点名 2020 俄沙石油战),作者务实承认"收集全部数据要十年",选择不修未坏之物。
涉及源码:原项目
Smart Farmers project/forecast.py(约 618 行),Smart Farmers project/check consistency.py(约 149 行),外接capita.csv(IMF 人均 GDP 预测)。
⚠️ 注意:本节代码量最大,涉及 CVXOPT 的 QP 标准形式转换、scipy 的 Nelder-Mead、双轴可视化、前向测试的多个外生假设。建议先把第 01 节的目标函数推导和第 03 节的 α/β/γ 含义复习一遍,本节的代码才看得懂。最深刻的反直觉发现放在"前向测试"段,那是全章的思想高潮。
阅读完本节,你应当能够:
Σ((actual−est)/actual)² 及其动机。经过前三节,我们手上已经有:
本节的任务:给定上述输入,求出每年每类作物的最优产量 Q*,然后把这套机制外推到 2019-2025。
CVXOPT 要求所有 QP 都塞进这个形式:
minimize (1/2) xᵀ P x + qᵀ x subject to G x ≤ h A x = b
把 Smart Farmers 的模型对号入座:
| CVXOPT | Smart Farmers | 维度 |
|---|---|---|
x |
30 维产量向量 Q | 30×1 |
P |
α 对角阵(目标函数二次项) | 30×30 |
q |
(历史价 + α·历史产量 − Δ人口·β − ΔGDP·γ + 成本初值)的负向量 | 30×1 |
G |
上下界块矩阵(上界对角 + 下界对角) | 60×30 |
h |
上下界阈值向量 | 60×1 |
A |
单产逆向量(可耕地等式) | 1×30 |
b |
总可耕地面积 | 标量 |
forecast.py 的 get_production 就是按这张表逐行填矩阵。
回测时,所有输入都来自历史数据(2013-2018)——人口、GDP、可耕地、单产全是真实的,我们站在 2020 年回看,拥有"上帝视角"。但前向测试(2019-2025)做不到,因为这些输入的"未来值"必须预测。作者用了三个假设:
| 输入 | 来源 | 假设 |
|---|---|---|
| 人均 GDP | IMF 预测(capita.csv 的 Mid Price 列) | IMF 模型(新冠前数据) |
| 人口 | UN 人口预测 | UN 模型(新冠前数据) |
| 可耕地 | 假设年增 1% | temp[-1]*1.01 |
| 单产 | 用历史均值 | 不变(天气风险中性) |
这三个假设是前向测试的命门——任何一个偏离现实,预测就崩。后文会看到,COVID 把 GDP 和人口预测双双打废,这是失败的主因。
forecast.py 同时定义了线性版(L55-86)和二次版(L93-150),二次版覆盖线性版,是实际使用的。逐段拆二次版:
#forecast.py:93-123(节选) def get_production(initial_guess): # ① 取历史值 price_hist = np.mat(D[currentyear-1]['price']).T # P_eq 近似(去年价) alpha = np.diag(D[currentyear]['alpha']) # 定价系数对角阵 production_hist = np.mat(D[currentyear-1]['production']).T # 去年产量 Q_{t-1} delta_pop = malay_pop[...currentyear...item() - ...currentyear-1...item()] # Δ人口 beta = np.mat(D[currentyear]['beta']).T # 人口系数 delta_gdp = malay_gdp[...currentyear...item() - ...currentyear-1...item()] # ΔGDP gamma = np.mat(D[currentyear]['gamma']).T # GDP 系数 # ② 线性项 q linear_coeff = (-price_hist + (alpha*production_hist*-1) # 注意这里是 -α·Q_{t-1} - delta_pop*beta # -Δ人口·β - delta_gdp*gamma # -ΔGDP·γ + np.mat(initial_guess).T) # +成本初值(对应 -C·Q 里的 C) linear_coeff = cvxopt.matrix(linear_coeff) # ③ 二次项 P quadratic_coeff = cvxopt.matrix(alpha) # 就是 α 对角阵
关键观察:quadratic_coeff = cvxopt.matrix(alpha)——目标函数的二次项矩阵就是 α 对角阵。这正是第 01 节推导的"价格内生 → 二次型"在代码里的兑现。
linear_coeff 的符号有点绕,需要回到目标函数展开:
利润 = Σ [ P_eq + α(D − Q_{t}) − C ] × Q_{t} = Σ [ P_eq + α·D − α·Q_{t} − C ] × Q_{t} = Σ [ (P_eq + α·D − C)·Q_{t} − α·Q_{t}² ]
最大化利润等价于最小化 −利润,所以 CVXOPT 的 q 是 −利润的线性部分:
q = −(P_eq + α·D − C) = −P_eq − α·D + C
其中 α·D 这一项要用工具变量展开 α·D = α(β·Δpop + γ·Δgdp + 历史 D)≈ β·Δpop + γ·Δgdp + α·Q_{t-1}(用历史产量近似历史需求)。所以:
q = −P_eq + α·Q_{t-1} − Δpop·β − ΔGDP·γ + C(initial_guess)
完全对应代码里 linear_coeff 的五项。注意 α·Q_{t-1} 在代码里写成 alpha*production_hist*-1(因为整体符号取反,代码里 production_hist 已经是 +号,再乘 −1 就成了 −α·Q_{t-1},与公式中的 +α·Q_{t-1} 在最终 −利润 形式下一致)。
#forecast.py:125-143(节选) # 等式约束:总可耕地 equality_coeff = cvxopt.matrix(D[currentyear]['yield_i']).T # A = yield_i 向量 equality_value = cvxopt.matrix(malay_land['Value'][...].item()*1000) # b = 总可耕地(×1000 单位换算) # 不等式约束:轮作上下界 area_hist = D[currentyear-1]['area'] # 去年面积 eco_lifespan = D[currentyear]['eco lifespan'].tolist() # 多年生下界系数 upperblock = np.diag(D[currentyear]['yield_i']) # 上界对角块(正) lowerblock = np.diag(D[currentyear]['yield_i'].apply(lambda x: -1*x)) # 下界对角块(负) # 垂直拼接成 60×30 的 G inequality_coeff = cvxopt.matrix(np.append(upperblock, lowerblock, axis=0)) inequality_value = cvxopt.matrix( (area_hist*upperbound).append(np.multiply(np.multiply(area_hist,eco_lifespan),-1)) )
三个细节:
*1000 是单位换算(FAO 单位与 malay_land 单位差 1000)。(area_hist*upperbound).append(...) 把上界阈值和下界阈值拼成一个 60 维的 h。upperbound=1.2(轮作上界,允许面积年增 20%),下界用 area_hist × eco_lifespan × −1(eco_lifespan 对一年生是 −0.8,多年生是 (ωL−1)/(ωL),都是负数,再乘 −1 成正阈值)。注意 (area_hist*upperbound).append(...) 又是旧 pandas API,新版要换 pd.concat。
#forecast.py:39-48 def get_ans(quadratic_coeff, linear_coeff, inequality_coeff, inequality_value, equality_coeff, equality_value): cvxopt.solvers.options['show_progress'] = False # 关闭迭代日志 ans = cvxopt.solvers.qp(P=quadratic_coeff, q=linear_coeff, G=inequality_coeff, h=inequality_value, A=equality_coeff, b=equality_value)['x'] return list(ans)
一行 cvxopt.solvers.qp 解出 30 维最优产量。CVXOPT 内部用内点法,30 维 QP 解起来毫秒级。['x'] 取解向量,转 list 返回。
这是 forecast.py 最巧妙也最容易被忽略的一段。问题:linear_coeff 里有一项 initial_guess(对应成本 C),这个 C 怎么定?
作者的方案是用 Nelder-Mead 在成本空间里搜索,让模型预测的产量与真实产量误差最小:
#forecast.py:157-166 def costfunction(initial_guess): estimate = get_production(initial_guess) # 用当前成本猜解 QP actual = D[currentyear]['production'].tolist() # 真实产量 cost = sum(np.power(np.divide(np.subtract(actual, estimate), actual), 2)) # 归一化 SSE return cost
costfunction 是一个"QP 嵌套"——外层成本函数调用内层 QP 求解。归一化形式 Σ((actual−est)/actual)² 用实际值除一下,避免大量级作物(油棕几千万吨)淹没小量级作物(烟草几千吨)的贡献。
#forecast.py:173-199(节选) def ls_estimate(initial_guess, diagnosis=True): if diagnosis: sse_original = costfunction(initial_guess) print(f'Initial SSE: {round(sse_original,2)}') # Nelder-Mead 搜索最优成本 least_square = scipy.optimize.minimize(costfunction, x0=(initial_guess), method='Nelder-Mead') if least_square.success: if diagnosis: sse = costfunction(least_square.x) print(f'Result SSE: {round(sse,2)}') return least_square.x else: print(least_square) return [0]
Nelder-Mead 是无梯度优化算法(单纯形法),适合这种"目标函数不可导、调用一次很贵"的情形。每次迭代都要解一次 QP,所以这个搜索计算很重——这就是 find_init 要做"随机搜索 + 多次试"的原因。
#forecast.py:206-232(节选) def find_init(num=10): global currentyear dic = {} for _ in range(num): # 随机生成成本初值(用价格乘 [0,1] 随机数) initial_guess = pd.Series([i*rd.random() for i in D[currentyear]['price']]) ans = ls_estimate(initial_guess, diagnosis=False) if len(ans) > 1: sample = [] # 在所有回测年份上算平均 SSE for currentyear in range(beginyear, endyear): cost = costfunction(ans) sample.append(cost) dic[np.mean(sample)] = initial_guess return dict(sorted(dic.items()))
find_init 跑 num=10 次随机初值,每次让 Nelder-Mead 优化出对应的最优成本,然后在所有回测年份上算平均 SSE,把"SSE → 对应初值"排序返回。SSE 最小的那个初值就是全局最优成本猜测。
forecast.py:361-390 有一段突兀的 30 个硬编码数字:
#forecast.py:361-390 cost_optimal = [46.45261724887184, 25.771497795481803, 8.04132847940984, ... 27.02920135710488]
这就是作者用 find_init 跑出来、再手动固化下来的最优成本初值。30 维对应 30 类作物的单位成本(美元/吨)。这种"硬编码搜索结果"的做法不优雅(理想是脚本自动跑),但工程上很务实——Nelder-Mead 搜索很慢,固化结果后回测和前向测试都能直接用。
#forecast.py:430-467(节选) X = {} for currentyear in range(beginyear, endyear): # beginyear=2013, endyear=2019 x = get_production(cost_optimal) # 用 cost_optimal 解 QP X[currentyear] = x # 画每作物 Est vs Act for ii in range(len(X[currentyear])): Y = [D[i]['production'][ii] for i in range(beginyear, endyear)] fig = plt.figure(figsize=(10,5)) ... plt.plot(range(beginyear, endyear), [X[i][ii] for i in X], label='Est', color='#ef6466') plt.plot(range(beginyear, endyear), Y, label='Act', color='#9f65a2') plt.title(D[currentyear]['Item'][ii]+' Production')
6 年循环,每年解一次 QP,画每作物的 Est(红)vs Act(紫)对比图。compute_price 函数(L257-284)用回归系数算预测价格,同样画对比图。
#forecast.py:503 beginyear = 2019; endyear = 2025 # ① 接 IMF GDP 预测 gdp_extra = malay_gdp.iloc[:(endyear-beginyear)].copy() gdp_extra['Value'] = capita['Mid Price'][str(beginyear):str(endyear)].tolist() # IMF 预测 malay_gdp = malay_gdp.append(gdp_extra) # ② 假设可耕地年增 1% temp = [malay_land['Value'].iloc[-1]] for i in range(len(land_extra)): temp.append(temp[-1]*1.01) # 年增 1% land_extra['Value'] = temp malay_land = malay_land.append(land_extra) # ③ 前向预测循环 for currentyear in range(beginyear, endyear): D[currentyear] = D[currentyear-1].copy() D[currentyear]['production'] = get_production(cost_optimal) D[currentyear]['price'] = compute_price(np.mat(D[currentyear]['production']).T) D[currentyear]['area'] = np.multiply(D[currentyear]['production'], D[currentyear]['yield_i'])
注意每年 D[currentyear] = D[currentyear-1].copy()——前向时没有历史可参照,直接复用上一年的结构(单产逆、寿命、回归系数都假设不变),只有 production/price/area 每年重算。
estimate demand.py 后半段(L245-326)做棕榈油前向验证——把模型预测的 oil palm fruit FFB 价格与 Bursa Malaysia 的 CPO 期货.generic first 实际价格对比。
#estimate demand.py:277-282(节选) # 用林吉特→美元换算(0.23)拼历史与期货预测,再取年均 temp = palm['Palm oil'][str(beginyear):].apply(lambda x: x*0.23).append(palm_futures['Palm oil'][:str(endyear+5)]) palmoil = temp.resample('1A').mean() palmoil.index = [pd.to_datetime(str(i)[:5]+'01-01') for i in palmoil.index]
x*0.23 是林吉特→美元的近似汇率换算(CPO 期货以林吉特计价,FFB 价格以美元计价)。resample('1A').mean() 取年均,把日频期货转成年频对齐模型。
estimate demand.py:300-325 用 matplotlib 的 twinx() 画双轴图——左轴棕榈油期货价格,右轴油棕果模型价格(单位不同必须双轴):
fig = plt.figure(figsize=(10,5)) ax = fig.add_subplot(111) ax.set_xlabel('Date') ax.set_ylabel('Palm Oil Generic 1st', color='#45ADA8') ax.plot(palmoil.index, palmoil, color='#45ADA8', label='Palm Oil') ax2 = ax.twinx() # 双轴 ax2.set_ylabel('Oil Palm Fruit Price', color='#EC2049', rotation=270) ax2.plot(palmoil.index[:len(oilpalm_act)], oilpalm_act, color='#EC2049', label='Oil Palm Act') ax2.plot(palmoil.index[len(oilpalm_est)-1:], oilpalm_est, color='#fea5c4', label='Oil Palm Est', linestyle='-.') plt.title('Palm Oil vs Oil Palm')

check consistency.py(149 行)是数据层的"体检脚本",做三件事:
a.intersection(b) 检查同时有"Area harvested"和"Production"数据的作物。这是科研代码的好习惯——主 pipeline 之外另写一个 sanity check 脚本,不进生产但留作 review。
🎯 反直觉发现(本节思想高潮):前向测试远不如回测,模型预测被 COVID 的 W 型复苏打脸。回测时,作者站在 2020 年回看 2013-2018,所有输入(人口、GDP、可耕地)都是已发生的真实数据,等于"上帝视角"——所以回测漂亮,模型"看起来"在 65% 的作物上有效。但前向预测 2019-2025 时,作者用的是 IMF 和 UN 在 COVID 之前发布的人口与 GDP 预测,假设农地年增 1%、单产不变。模型给出"2018-2020 backwardation(近月 > 远月,现货偏紧)、2021+ contango(近月 < 远月,现货宽松)"的预测。现实是 COVID 在 2020 年砸了下来,各国 GDP 暴跌、人口增长受阻、供应链紊乱,棕榈油价格走出 W 型复苏——和模型的 backwardation/contango 节奏完全错位。作者引用 MSCI 研究主管 Dimitris Melas 的名言作为 Discussion 段的开篇:"I have never seen a bad backtest"。这句话道破量化研究的核心痛点——上帝视角的回测永远是好的,真正的考验是前向。如果 2018 年真的按模型做空棕榈油期货并持续滚动,2020 年基金会被赎回得精光。这个失败不是模型的错,是所有依赖外生预测的模型的共同命运——只要外生预测错,模型就错。Smart Farmers 的诚实在于把这次失败完整展示出来,而不是只秀回测。
quadratic_coeff = cvxopt.matrix(alpha) 这行,你应该立刻反应"这是价格内生带来的二次项"。Σ((actual−est)/actual)² 用实际值归一,避免量级悬殊的作物相互淹没。Discussion 段末尾,作者给出了模型的"通用版"——Ricardian 模型。当目标国是封闭系统(如 EU 自给自足),naïve 模型够用;但面对美国、加拿大这种农业出口大国,必须扩展成多国贸易系统。

Ricardian 模型来自 David Ricardo 1817 年《On the Principles of Political Economy and Taxation》第 7 章的比较优势理论(英葡葡萄酒-布匹贸易)。在 Smart Farmers 里,不同国家按各自成本和种植面积(比较优势)成比例地生产同种作物,所有国家共享一个国际价格(理想来自 CME/ICE 期货)。
推广版加强了两条假设:
作者最后很务实:"why bother fixing it when it's not broken?"naïve 模型在 65% 作物上有效,前向预测只要输入(GDP/人口/地/单产)靠谱就能用,没必要为了完美去搞一个十年工程。这是科研的成熟——承认边界,在边界内做到最好。
这种务实也呼应了全章的隐线:Smart Farmers 的价值不在"它是个能赚钱的交易策略",而在"它是个清晰可复现的研究框架"。你拿到这套代码,换成泰国、越南、印尼的数据,换个时间窗,就能复现一遍。这种可复现性,是 quant-trading 全书其他章节都达不到的——它是 Smart Farmers 作为"第二个高潮大项目★"的真正分量。
走到这里,我们读完了 Smart Farmers 的全部代码:cleanse data.py 把 FAO 14GB 数据变成 30 类作物的整洁表;country selection.py 用出口占比筛控制组;estimate demand.py 用工具变量 2SLS + constrained QP 估出 α/β/γ;forecast.py 把这些系数塞进 CVXOPT 解出最优产量,再用 Nelder-Mead 找最优成本,最后前向预测 2019-2025 并用棕榈油期货验证。
Smart Farmers 给我们留下三样东西:
旅程下一站:第 8 章 Monte Carlo 批判反思与总结。我们会用重采样、白噪声、过拟合诊断等工具,系统性地怀疑前面 7 章所有的"漂亮回测",并给整段旅程画上句号。Smart Farmers 的前向失败会再次出现,作为"回测好 ≠ 策略好"的头号案例。