用LINGO和MATLAB搞定投资组合优化:从数学建模到代码实现

最近在辅导几个学生准备数学建模竞赛,发现很多同学在面对投资组合优化这类经典题目时,常常卡在从理论模型到代码实现的“最后一公里”。纸上推公式头头是道,一打开软件就手足无措。这让我想起自己早年参赛的经历,当时也是对着LINGO和MATLAB的界面发愁,不知道如何把那些优美的数学符号变成计算机能理解的指令。

投资组合优化,说白了就是在收益和风险之间找平衡的艺术。给你一笔钱,面前摆着股票、债券等一堆资产,每个都有预期的回报率和潜在的风险。你的任务就是设计一个购买方案,让赚的钱尽可能多,同时把亏钱的可能性压到最低。这听起来像是基金经理的日常工作,但它本质上是一个标准的数学优化问题,非常适合用建模竞赛来考察学生的综合能力。

这篇文章,我想从一个“过来人”和实际使用者的角度,聊聊怎么用LINGO和MATLAB这两款风格迥异的工具,把这道经典赛题从题目描述变成可运行的代码。我不会照搬任何现成的论文或教材答案,而是结合我这些年带比赛和做项目的实战经验,拆解其中的关键思路、易错点,以及两种工具各自的“脾气”。无论你是正在备赛的学生,还是对量化投资初步感兴趣的爱好者,希望这些接地气的分享能帮你少走些弯路。

1. 问题拆解:把一道应用题变成一个数学模型

拿到题目,第一步不是急着打开软件,而是拿起笔,把问题翻译成数学语言。很多初学者失败就失败在跳过了这一步,直接去网上找代码,结果发现根本对不上。

1998年这道A题,条件给得比较细,我们一条条来梳理。

核心要素:

  • 资金总量:有一笔总额为 M 的资金。在具体算例中,M=10000元。
  • 资产选项:有 n 种风险资产(S1, S2, ..., Sn)和1种无风险资产(银行存款)。每种风险资产 Si 有三个关键参数:
    • ri:平均收益率(%)
    • qi:风险损失率(%)
    • pi:交易费率(%)
    • ui:交易费计算阈值(元)。这是本题一个容易忽略的细节:当购买金额不超过 ui 时,交易费按购买 ui 计算;超过则按实际购买金额计算。不买则不收费。
  • 无风险资产:银行存款,收益率 r0=5%,无风险 (q0=0),无交易费 (p0=0)。
  • 决策变量:我们最终要决定的是对每种资产(包括银行存款)的投资金额 xi(i=0,1,...,n,其中 i=0 代表银行存款)。

目标与约束: 题目要求“净收益尽可能大,总体风险尽可能小”。这是一个典型的双目标优化问题。在数学上,处理双目标问题常用两种方法:

  1. 效用函数法(加权求和法):引入一个权重系数 w (0 ≤ w ≤ 1),将两个目标合并为一个。w 代表对风险的厌恶程度,w 越大,越厌恶风险。
    • 目标函数可以设为:Minimize Z = w * 总风险 - (1-w) * 总净收益
    • 这样,通过调整 w,我们就能得到一系列权衡了收益和风险的不同解,也就是所谓的“有效前沿”。
  2. 约束法:固定一个目标(如风险不超过某个值),优化另一个目标(最大化收益),或者反过来。

本题的官方参考解答和大多数教材都采用了第一种方法,因为它能系统性地描绘出整个权衡曲线。

接下来,我们需要用数学公式定义“总净收益”和“总风险”。

  • 净收益计算:收益减去交易成本。

    • 对于资产 i,其投资收益为 ri * xi。
    • 其交易成本 c(xi) 是一个分段函数:
      如果 xi = 0: c(xi) = 0
      如果 0 < xi ≤ ui: c(xi) = pi * ui
      如果 xi > ui: c(xi) = pi * xi
      
    • 因此,总净收益 V = Σ (ri * xi - c(xi)),求和范围包括所有资产(含银行存款,其 r0=5%, c(x0)=0)。
  • 总体风险定义:题目明确说“总体风险可用所投资的Si中最大的一个风险来度量”。注意,这里“风险”指的是 qi * xi,即该资产可能造成的最大损失金额。

    • 所以,总风险 Q = max{ qi * xi },其中 i 取所有风险资产(银行存款风险为0,不参与最大值的比较)。
  • 资金约束:总投资额(本金+交易费)不能超过总资金 M。

    • Σ (xi + c(xi)) = M。注意这里是等号,意味着资金必须全部投入使用(可以存银行)。

注意:这个分段交易费函数 c(xi) 是建模和编程的第一个难点。它既不是线性的,也不是连续的(在 xi=0 处)。直接处理比较麻烦,一个常用的技巧是利用“大M法”引入0-1变量将其线性化,或者利用优化软件的非线性求解能力。

把以上所有文字描述汇总,我们就得到了一个完整的数学规划模型。对于 n=4 的算例,我们可以把具体数字代入。这个模型就是我们后续用LINGO或MATLAB求解的蓝图。

2. LINGO实战:专为优化而生的“直译”工具

如果你追求的是快速、直观地把数学模型“告诉”计算机,并得到答案,LINGO几乎是首选。它的语法非常贴近数学表达,特别适合处理线性、非线性和整数规划问题。

2.1 LINGO建模思想:集合与变量声明

LINGO的核心思想是基于集合的建模。我们不需要为每个资产单独写一行代码,而是先定义“资产”这个集合,然后在这个集合上定义属性。

对于我们的问题,可以这样定义集合:

sets:
    asset /1..5/: r, q, p, u, x, c;
    ! 定义了名为asset的集合,有5个成员(1:银行存款,2-5:四种风险资产)。
    ! 每个成员都有收益率r、风险率q、交易费率p、阈值u、投资额x、交易费c这些属性。
endsets

定义好集合和属性后,我们就可以用简洁的循环语句来描述目标函数和约束条件,这比在MATLAB里写循环矩阵优雅得多。

2.2 关键代码段解析与易错点

我们聚焦几个最关键的、容易出错的建模部分。

1. 分段交易费 c(i) 的实现: 这是模型非线性的来源。在LINGO中,我们可以利用 @if 函数和 @smax 函数来直接表达。

@for(asset(i):
    c(i) = @if(x(i) #eq# 0, 0, @smax(p(i)*u(i), p(i)*x(i)));
);

这行代码的意思是:对于每种资产 i,如果投资额 x(i) 等于0,则交易费为0;否则,交易费取 p(i)*u(i) 和 p(i)*x(i) 中的较大值。这完美对应了题目中的分段规则。

2. 总体风险 Q 的定义: 风险是最大损失,直接用 @max 函数。

Q = @max(asset(i) | i #gt# 1: q(i) * x(i)); 
! i#gt#1 表示i>1,即排除银行存款(i=1),因为其q(1)=0。

3. 双目标加权处理: 我们引入权重 w,构建单目标函数。

min = w * Q - (1-w) * V; 
! 目标是最小化这个综合指标。w越大,对风险Q的惩罚越大,优化结果会更倾向于低风险。
! V是总净收益,前面需要计算:V = @sum(asset(i): r(i)*x(i) - c(i));

4. 资金约束:

@sum(asset(i): x(i) + c(i)) = M; 
! 总投资额(本金+交易费)等于总资金M。

把所有这些片段组合起来,就构成了一个完整的LINGO模型。为了画出有效前沿(即不同 w 值下的最优解集),我们通常会把 w 作为一个参数,写一个循环,让LINGO自动求解一系列 w 取值下的问题,并记录结果。

2.3 完整LINGO脚本结构与输出处理

一个结构清晰的LINGO脚本可能如下所示:

model:
sets:
    asset /1..5/: r, q, p, u, x, c;
    scenario /1..11/: w_val, risk, profit; ! 定义11个场景,对应w从0到1,步长0.1
endsets

data:
! 输入数据,注意百分比转换为小数
    r = 0.05, 0.28, 0.21, 0.23, 0.25;
    q = 0,    0.025, 0.015, 0.055, 0.026;
    p = 0,    0.01,  0.02,  0.045, 0.065;
    u = 0,    103,   198,   52,    40;
    M = 10000;
enddata

calc:
    @for(scenario(s):
        w = (s-1)*0.1; ! 设置当前场景的权重w
        @solve(); ! 求解当前优化问题
        ! 记录结果
        w_val(s) = w;
        risk(s) = Q;
        profit(s) = V;
    );
    ! 将结果输出到文件或窗口
    @write("权重w, 风险, 净收益\n");
    @for(scenario(s): @write(@format(w_val(s), "#8.3f"), " ", @format(risk(s), "#12.2f"), " ", @format(profit(s), "#12.2f"), "\n"));
endcalc

! 以下是模型部分(目标函数和约束)
[OBJ] min = w*Q - (1-w)*V;
@for(asset(i): c(i) = @if(x(i) #eq# 0, 0, @smax(p(i)*u(i), p(i)*x(i))));
Q = @max(asset(i) | i #gt# 1: q(i)*x(i));
V = @sum(asset(i): r(i)*x(i) - c(i));
@sum(asset(i): x(i) + c(i)) = M;
end

运行这个模型,LINGO会输出11行数据,每行对应一个 w 下的最小风险和最大收益的组合。把这些点 (风险, 收益) 画在图上,就是有效前沿曲线。LINGO本身绘图功能不强,通常我们会把数据导出到Excel,再用MATLAB或Python绘图。

提示:在调试复杂模型时,可以先用 @pause 语句暂停,查看中间变量值;或者使用 @divert 将求解日志输出到文本文件,便于排查错误。

3. MATLAB实现:灵活强大的“编程式”求解

与LINGO的“声明式”风格不同,MATLAB要求我们以编程的方式定义目标函数和约束。这给了我们更大的灵活性,但也对编程能力提出了更高要求。MATLAB的优化工具箱 fmincon 是解决此类有约束非线性规划问题的利器。

3.1 问题转化:适应 fmincon 的格式

fmincon 求解的问题是:

min f(x)
s.t. A*x ≤ b, Aeq*x = beq, lb ≤ x ≤ ub, c(x) ≤ 0, ceq(x) = 0

我们需要把自己的模型“塞”进这个格式。

  • 决策变量 x:一个列向量 [x0; x1; x2; x3; x4],分别代表对银行存款和4种风险资产的投资额。
  • 目标函数 f(x):就是 w * Q - (1-w) * V。我们需要写一个函数 myObjective(x) 来计算它。
  • 线性约束:本题只有资金总额一个等式约束,但它非线性(因为包含交易费 c(x)),所以不能放在 Aeq, beq 中。
  • 非线性约束:我们需要写一个函数 myConstraint(x),返回资金约束 Σ(xi + c(xi)) - M = 0 作为等式约束 ceq。
  • 边界约束 lb:投资额不能为负,所以 lb = zeros(5,1)。没有上限,ub 设为空 []。

3.2 核心函数编写与坑点指南

在MATLAB中,我们通常需要创建三个独立的函数文件:主脚本、目标函数文件、非线性约束函数文件。交易费计算函数可以单独写,也可以嵌套在内。

主脚本 (main.m) 框架:

clear; clc;
global w asset M % 声明全局变量,方便在各个函数间传递

% 1. 输入数据
asset = [0.05, 0,    0,    0;   % 银行存款
         0.28, 0.025, 0.01, 103;
         0.21, 0.015, 0.02, 198;
         0.23, 0.055, 0.045,52;
         0.25, 0.026, 0.065,40];
M = 10000;
num_assets = size(asset, 1);

% 2. 定义优化选项和问题结构
options = optimoptions('fmincon', 'Display', 'iter', 'Algorithm', 'sqp');
% 使用‘sqp’算法通常对这类问题效果较好

% 3. 循环求解不同w下的问题
results = zeros(11, 3); % 存储 [w, 风险, 收益]
x_optimal = zeros(num_assets, 11); % 存储每个w下的最优投资方案

for i = 1:11
    w = (i-1) * 0.1;
    
    % 定义优化问题
    problem.objective = @myObjective; % 目标函数句柄
    problem.x0 = ones(num_assets, 1) * (M / num_assets); % 初始解:资金平均分配
    problem.Aineq = []; problem.bineq = [];
    problem.Aeq = []; problem.beq = [];
    problem.lb = zeros(num_assets, 1);
    problem.ub = [];
    problem.nonlcon = @myConstraint; % 非线性约束函数句柄
    problem.solver = 'fmincon';
    problem.options = options;
    
    % 求解
    [x, fval] = fmincon(problem);
    
    % 计算当前解的实际风险和收益
    [current_risk, current_profit] = calculateMetrics(x);
    
    % 记录结果
    results(i, :) = [w, current_risk, current_profit];
    x_optimal(:, i) = x;
end

% 4. 输出结果并绘图
disp('权重w, 风险, 净收益');
disp(results);
plot(results(:,2), results(:,3), 'o-');
xlabel('总体风险 Q (元)'); ylabel('净收益 V (元)');
title('投资组合有效前沿');
grid on;

目标函数文件 (myObjective.m):

function f = myObjective(x)
    global w asset
    % 计算交易费
    c = calcTransactionFee(x);
    % 计算总风险(最大损失)
    risk = max(asset(2:end, 2) .* x(2:end)); % 排除银行存款
    % 计算总净收益
    profit = sum(asset(:,1) .* x - c);
    % 加权目标函数
    f = w * risk - (1-w) * profit;
end

交易费计算函数 (calcTransactionFee.m): 这是最容易出错的地方。分段函数的处理需要非常小心。

function c = calcTransactionFee(x)
    global asset
    num = length(x);
    c = zeros(num, 1);
    for i = 1:num
        if x(i) == 0
            c(i) = 0;
        elseif x(i) > 0 && x(i) <= asset(i, 4) % 0 < xi <= ui
            c(i) = asset(i, 3) * asset(i, 4);
        else % xi > ui
            c(i) = asset(i, 3) * x(i);
        end
    end
end

注意:在浮点数计算中,严格判断 x(i) == 0 可能不稳定。有时一个极小的数(如1e-10)在优化过程中会被视为0。一个更稳健的做法是设置一个容差 tol = 1e-6,判断 x(i) < tol。这也是很多参考代码中设置 x(i) <= 0.06 之类奇怪阈值的原因——那可能是在特定迭代下为了绕过数值问题而采取的临时措施,并非普适解法。更好的方法是重构问题,避免这种不连续的分段判断。

非线性约束函数 (myConstraint.m):

function [c, ceq] = myConstraint(x)
    global M
    % 计算交易费
    fee = calcTransactionFee(x);
    % 没有不等式约束
    c = [];
    % 等式约束:总投资额(本金+交易费)等于M
    ceq = sum(x + fee) - M;
end

3.3 调试技巧与结果分析

运行上述MATLAB代码,你可能会遇到一些问题:

  • 初始点敏感:fmincon 对初始猜测 x0 比较敏感。如果初始点设得不好,可能会收敛到局部最优解,甚至不收敛。可以尝试多个不同的初始点(如随机生成),或者使用 MultiStart、GlobalSearch 等全局优化算法来寻找更好的解。
  • 数值不稳定:分段交易费函数在 x(i)=0 和 x(i)=ui 处不可导,这会给基于梯度的优化器带来困难。‘sqp’ 或 ‘interior-point’ 算法通常比 ‘active-set’ 更能处理这类问题。
  • 结果验证:将MATLAB得到的结果与LINGO的结果进行对比。如果两者在相同 w 下得到的风险和收益值非常接近,那么可以相互印证。如果差异较大,需要仔细检查模型的一致性,特别是交易费的计算逻辑。

当程序成功运行后,你会得到一系列 (风险, 收益) 点。将它们绘制成散点图或连线图,就能清晰地看到那条经典的、向右上方倾斜的“有效前沿”曲线。曲线上每一点都代表在给定风险水平下能获得的最大收益,或者在给定收益目标下能承受的最小风险。

4. 工具对比与场景选择:LINGO vs MATLAB

通过上面的实践,你应该能感受到LINGO和MATLAB在解决同一问题时的不同哲学。下面这张表总结了它们的主要区别,帮你决定在什么情况下用哪个。

特性维度LINGOMATLAB (with Optimization Toolbox)
建模范式声明式建模。直接描述问题是什么(目标、约束),接近数学语言。命令式编程。需要编写函数来定义目标、约束,告诉计算机怎么做。
学习曲线相对平缓。掌握基本集合、函数语法后,可以快速建立优化模型。相对陡峭。需要熟悉MATLAB编程、函数句柄、优化工具箱API。
模型表达力对线性、非线性、整数规划的原生支持很好,语法简洁。处理复杂分段函数、逻辑约束相对方便。极其灵活。任何能用代码描述的函数和约束都能处理,适合高度定制化、算法嵌入式的复杂模型。
求解能力内置强大的求解器,对常见优化问题开箱即用,用户无需关心算法细节。提供多种算法选择(如 interior-point, sqp, active-set),需要用户根据问题特性进行选择和调参。
调试与可视化调试功能相对简单,主要靠查看变量窗口和输出日志。可视化能力弱。调试工具强大(断点、变量查看器等)。可视化能力极强,可轻松绘制有效前沿、收敛过程等图形。
结果分析与扩展擅长快速求解并给出结果。但进行后续的灵敏度分析、批量处理、集成到更大系统时不如MATLAB方便。易于将优化模块作为一部分,集成到数据分析、机器学习、仿真等更大的工作流中。结果处理和分析非常方便。
适用场景数学建模竞赛、快速原型验证、教学演示。当你需要快速验证一个优化模型的想法,并得到可靠解时,LINGO是高效的选择。学术研究、算法开发、复杂系统仿真、产品级应用。当你的问题需要定制化算法、与其他分析工具深度集成,或者需要复杂的后处理时,MATLAB更胜一筹。

我的个人经验是:

  • 在数学建模竞赛的48小时内,如果问题核心是一个清晰的优化模型,我会首选LINGO。它能让我在几分钟内把思路变成可运行的代码,把宝贵的时间留给结果分析和论文写作。它的求解日志也便于我们理解模型的规模和解的状态。
  • 如果竞赛问题需要我自己设计优化算法(比如遗传算法、模拟退火),或者优化只是整个模型的一小部分,前后有大量的数据预处理和结果可视化,那么MATLAB 的综合优势就体现出来了。
  • 在日常研究和工作中,面对更复杂、更动态的优化问题(比如参数随时间变化、需要与仿真耦合),我几乎总是使用MATLAB 或 Python,因为它们的生态系统和灵活性无可替代。

最后,无论选择哪个工具,深刻理解问题本身的数学本质永远是第一位的。工具只是帮你计算的“笔”,而模型和思路才是你要表达的“思想”。在真正动手编程前,花时间在草稿纸上清晰地列出所有变量、目标、约束,定义好每一个函数,这个好习惯的价值远超任何编程技巧。当你对模型了如指掌时,用LINGO翻译或者用MATLAB实现,都只是水到渠成的事情。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐