第 5 章 · 03 自定义目标函数


文档摘要

第 5 章 · 03 自定义目标函数 本节摘要:本节是本章收官,讲如何完全定制优化目标——写一个自定义目标函数并传给 PyPortfolioOpt。当内置的五种目标(maxsharpe、minvolatility、efficientrisk、efficientreturn、maxquadraticutility)以及 L2 正则、交易成本都不够用时,你可以用 (凸目标,cvxpy 后端)或 (非凸目标,scipy 后端)接入任意目标。本节拆解两条路径: 要求目标用 cvxpy 原子函数构建、保持凸性,可复用所有约束 API; 用 scipy.optimize,支持任意目标但可能陷局部最优、且与模块化约束 API 不兼容。

第 5 章 · 03 自定义目标函数

本节摘要:本节是本章收官,讲如何完全定制优化目标——写一个自定义目标函数并传给 PyPortfolioOpt。当内置的五种目标(max_sharpe、min_volatility、efficient_risk、efficient_return、max_quadratic_utility)以及 L2 正则、交易成本都不够用时,你可以用 convex_objective(凸目标,cvxpy 后端)或 nonconvex_objective(非凸目标,scipy 后端)接入任意目标。本节拆解两条路径:convex_objective 要求目标用 cvxpy 原子函数构建、保持凸性,可复用所有约束 API;nonconvex_objective 用 scipy.optimize,支持任意目标但可能陷局部最优、且与模块化约束 API 不兼容。用 cookbook 3 的「对数障碍目标」(凸)与「偏差风险平价」(非凸)两个真实案例收尾,把 PyPortfolioOpt 的扩展点讲透。

内容来源:原项目源码 pypfopt/base/_base_optimizer.py(convex_objectivenonconvex_objective)、pypfopt/objective_functions.py、文档 docs/GeneralEfficientFrontier.rst(Custom optimization problems 专节)、cookbook/3-Advanced-Mean-Variance-Optimisation.ipynb,汉化并套用体系化模板。

学习目标

阅读完本节,你应当能够:

  1. 区分 convex_objectivenonconvex_objective 两条扩展路径。
  2. 写一个凸目标函数(用 cvxpy 原子函数),传给 convex_objective
  3. 写一个非凸目标函数(用 numpy),传给 nonconvex_objective,理解 scipy 约束格式。
  4. 知道凸路径可复用所有约束 API,非凸路径不兼容
  5. 复述 objective_functions.py 的扩展点设计哲学。

一、何时需要自定义目标

内置目标覆盖了多数均值-方差场景,但有些需求不在此列:

  1. 跟踪误差最小化:构造一个复制指数的组合,minimize (w - w_b)ᵀ Σ (w - w_b)
  2. 对数障碍目标:Kolm et al. (2014) 的 wᵀ Σ w - k Σ log(w_i),鼓励权重为正。
  3. 风险平价:让每只资产对组合风险的贡献相等。
  4. Kelly 准则:最大化对数财富期望。
  5. 自定义效用:你的机构有特殊的效用函数。

docs/GeneralEfficientFrontier.rst 的「Custom optimization problems」专节专门讲这两条路径。

二、objective_functions 模块的设计

回顾 objective_functions.py 的模块注释——它揭示了扩展点的设计哲学:

这些方法主要供 EfficientFrontier 内部优化使用,每个需要不同的签名(因此未封装成类)。 任何目标函数必须接受 weights 作为参数,且至少接受 expected_returns 或 cov_matrix 之一。 目标函数在 w 是 ndarray 时计算目标值,在 w 是 cp.Variable 时返回 cvxpy 表达式—— 同一个函数既可内部优化,也可外部计算。objective_functions 默认是最小化;明显应最大化的 目标(夏普、收益)返回负值,因最小化负值 = 最大化正值。

内置目标一览(供自定义时参考):

函数 数学形式 用途
portfolio_variance wᵀ Σ w 方差
portfolio_return wᵀ μ 收益
sharpe_ratio (wᵀ μ - R_f)/σ 夏普
L2_reg γ wᵀ w 正则
quadratic_utility wᵀ μ - ½ δ wᵀ Σ w 二次效用
transaction_cost k‖w - w_prev‖₁ 换手成本
ex_ante_tracking_error (w-w_b)ᵀ Σ (w-w_b) 事前跟踪误差
ex_post_tracking_error Var(r - r_b) 事后跟踪误差

自定义目标就是「按这个签名,写一个新函数」。

三、convex_objective:凸目标路径

BaseConvexOptimizer.convex_objective 是凸目标的入口。源码(_base_optimizer.py 439-478 行):

def convex_objective(self, custom_objective, weights_sum_to_one=True, **kwargs): """ Optimize a custom convex objective function. Constraints should be added with ef.add_constraint(). """ # custom_objective 必须有签名 (w, **kwargs) -> cp.Expression self._objective = custom_objective(self._w, **kwargs) for obj in self._additional_objectives: self._objective += obj if weights_sum_to_one: self.add_constraint(lambda w: cp.sum(w) == 1) return self._solve_cvxpy_opt_problem()

要求:

  1. 签名:custom_objective(w, **kwargs),w 是 cvxpy 变量,返回 cvxpy 表达式。
  2. 必须凸:用 cvxpy 原子函数构建,满足 DCP。
  3. 要最小化:库是最小化框架,要最大化就加负号。
  4. 约束用 add_constraint 加:与标准 API 完全兼容。

3.1 案例:对数障碍目标(cookbook 3)

Kolm et al. (2014) 的对数障碍函数:

f(w, S, k) = wᵀ S w - k Σ log(w_i)

用 cvxpy 实现:

import cvxpy as cp def logarithmic_barrier_objective(w, cov_matrix, k=0.1): log_sum = cp.sum(cp.log(w)) # cvxpy 原子:log 是凹的,-log 是凸的 var = cp.quad_form(w, cov_matrix) # 凸 return var - k * log_sum # 凸 - k×凹 = 凸(当 k > 0)

调用:

ef = EfficientFrontier(mu, S, weight_bounds=(0.01, 0.2)) ef.convex_objective(logarithmic_barrier_objective, cov_matrix=S, k=0.001) weights = ef.clean_weights() ef.portfolio_performance(verbose=True)

可以与所有约束叠加——比如限制 JD 不超过 15%:

ef = EfficientFrontier(mu, S, weight_bounds=(0.01, 0.2)) jd_index = ef.tickers.index("JD") ef.add_constraint(lambda w: w[jd_index] <= 0.15) ef.convex_objective(logarithmic_barrier_objective, cov_matrix=S, k=0.001)

💡 关键约束:weight_bounds=(0.01, 0.2)——下界 0.01 保证 log(w) 有定义(不能 log 0 或负数)。这是对数目标的隐含要求。

3.2 案例:跟踪误差最小化

文档 GeneralEfficientFrontier.rst 的官方示例——构造复制指数的组合:

from pypfopt.base import BaseConvexOptimizer from pypfopt.objective_functions import ex_post_tracking_error historic_rets = ... # 历史资产收益 DataFrame benchmark_rets = ... # 历史基准收益 Series opt = BaseConvexOptimizer( n_assets=len(historic_returns.columns), tickers=historic_returns.columns, weight_bounds=(0, 1) ) opt.convex_objective( ex_post_tracking_error, historic_returns=historic_rets, benchmark_returns=benchmark_rets, ) weights = opt.clean_weights()

这里直接用 BaseConvexOptimizer(不经过 EfficientFrontier),因为跟踪误差与 μ、Σ 无关——这是「完全脱离均值-方差范式」的入口。

四、nonconvex_objective:非凸路径

若目标真的非凸(无法用 cvxpy 原子表达),用 nonconvex_objective,它走 scipy.optimize 后端。源码(_base_optimizer.py 480-558 行):

def nonconvex_objective( self, custom_objective, objective_args=None, weights_sum_to_one=True, constraints=None, solver="SLSQP", initial_guess=None, ): # scipy 的 bounds 与 constraints 格式 bound_array = np.vstack((self._lower_bounds, self._upper_bounds)).T bounds = list(map(tuple, bound_array)) if initial_guess is None: initial_guess = np.array([1 / self.n_assets] * self.n_assets) final_constraints = [] if weights_sum_to_one: final_constraints.append({"type": "eq", "fun": lambda w: np.sum(w) - 1}) if constraints is not None: final_constraints += constraints result = sco.minimize( custom_objective, x0=initial_guess, args=objective_args, method=solver, bounds=bounds, constraints=final_constraints, ) self.weights = result["x"] return self._make_output_weights()

与凸路径的关键差异:

维度 convex_objective nonconvex_objective
后端 cvxpy scipy.optimize
目标构建 cvxpy 原子(凸) 任意 numpy 函数
约束 API add_constraint(lambda) scipy 字典格式
全局最优 保证(凸问题) 不保证(可能局部最优)
与模块化约束兼容

4.1 scipy 约束格式

scipy 的约束是字典列表,函数须重排成 = 0(等式)或 ≥ 0(不等式):

constraints = [ # 等式约束:w[1] - 0.2 = 0 (即 w[1] == 0.2) {"type": "eq", "fun": lambda w: w[1] - 0.2}, # 不等式约束:w[0] - 0.5 ≥ 0 (即 w[0] ≥ 0.5) {"type": "ineq", "fun": lambda w: w[0] - 0.5}, ]

这与 cvxpy 的 lambda w: w[0] == 0.2 风格完全不同,是 scipy 的历史包袱。

4.2 案例:偏差风险平价(cookbook 3)

风险平价让每只资产对组合风险的贡献相等。Kolm et al. (2014) 的偏差风险平价目标:

def deviation_risk_parity(w, cov_matrix): diff = w * np.dot(cov_matrix, w) - (w * np.dot(cov_matrix, w)).reshape(-1, 1) return (diff ** 2).sum().sum()

调用:

ef = EfficientFrontier(mu, S, weight_bounds=(0.01, 0.12)) ef.nonconvex_objective(deviation_risk_parity, ef.cov_matrix) weights = ef.clean_weights()

4.3 加约束的版本

要求 JD 恰好 10%:

ef = EfficientFrontier(mu, S, weight_bounds=(0.01, 0.12)) jd_index = ef.tickers.index("JD") ef.nonconvex_objective( deviation_risk_parity, objective_args=ef.cov_matrix, weights_sum_to_one=True, constraints=[ {"type": "eq", "fun": lambda w: w[jd_index] - 0.10}, ], ) weights = ef.clean_weights()

⚠️ scipy 格式的反直觉:cookbook 3 明确抱怨「scipy 的格式不直观且难解释」。type="eq" 的函数要 = 0,type="ineq" 的函数要 ≥ 0——与人类直觉的「w[0] == 0.2」或「w[0] ≤ 0.5」都对不上,需要手动重排。

五、更多非凸示例

cookbook 3 还提供了几个示例,展示 scipy 后端的灵活性:

5.1 市场中性 + 有效风险(非凸版)

target_risk = 0.19 ef = EfficientFrontier(mu, S, weight_bounds=(None, None)) weight_constr = {"type": "eq", "fun": lambda w: np.sum(w)} # 权重和 = 0 risk_constr = { "type": "eq", "fun": lambda w: target_risk ** 2 - np.dot(w.T, np.dot(ef.cov_matrix, w)), } constraints = [weight_constr, risk_constr] ef.nonconvex_objective( lambda w, mu: -w.T.dot(mu), # min 负收益 = max 收益 objective_args=(ef.expected_returns), weights_sum_to_one=False, constraints=constraints, )

文档提醒:严肃用途请用 ef.efficient_risk()

5.2 Kelly 准则 + 边界约束

def kelly_objective(w, e_returns, cov_matrix, k=3): variance = np.dot(w.T, np.dot(cov_matrix, w)) objective = variance * 0.5 * k - np.dot(w, e_returns) return objective ef = EfficientFrontier(mu, S) ef.nonconvex_objective( kelly_objective, objective_args=(ef.expected_returns, ef.cov_matrix, 1000), constraints=[ {"type": "eq", "fun": lambda w: np.sum(w) - 1}, {"type": "ineq", "fun": lambda w: w[0] - 0.01}, # w[0] ≥ 0.01 {"type": "ineq", "fun": lambda w: 0.3 - w[0]}, # w[0] ≤ 0.3 ], )

六、两条路径的选择

💡 作者的提醒:文档明确写「非凸优化不真正被鼓励」。原因是:(1) 可能陷局部最优,结果不可复现;(2) 与模块化约束 API 不兼容,失去 PyPortfolioOpt 的核心便利;(3) scipy 的约束格式难用。除非你的目标真的非凸(如风险平价),否则优先用凸路径或重新建模为凸问题。

七、扩展点设计哲学

回顾 objective_functions.py 的设计,可以总结 PyPortfolioOpt 的扩展哲学:

  1. 函数即目标:目标不封装成类,而是裸函数——签名灵活,易于组合。
  2. 双模式:_objective_value 让同一函数既可计算具体值(ndarray 输入),又可构造优化表达式(cp.Variable 输入)。
  3. 统一最小化框架:所有目标都写成「最小化」,要最大化就取负——降低认知负担。
  4. 凸优先:主推 cvxpy 凸路径,保证全局最优与模块化;非凸路径作为逃生口。
  5. BaseConvexOptimizer 作为终极入口:若你的问题与均值-方差无关,直接用 BaseConvexOptimizer 而非 EfficientFrontier

这是 PyPortfolioOpt 「易扩展」承诺的最终落点——从「换一层组件」到「换整个问题」,都有清晰的扩展路径。

本节要点回顾

  1. 两条路径:convex_objective(凸,cvxpy 后端)与 nonconvex_objective(非凸,scipy 后端)。
  2. 凸路径要求:签名 (w, **kwargs),用 cvxpy 原子函数,保持凸性,要最小化;可复用 add_constraint/add_objective
  3. 非凸路径要求:签名 (w, *args),任意 numpy 函数;约束用 scipy 字典格式(type="eq" 函数 =0,type="ineq" 函数 ≥ 0);不兼容模块化约束 API。
  4. 案例:凸路径的对数障碍、跟踪误差;非凸路径的偏差风险平价、Kelly、市场中性有效风险。
  5. 选择:优先凸路径(全局最优 + 模块化);非凸作为逃生口,作者明确「不鼓励」。
  6. 设计哲学:函数即目标、双模式、统一最小化、凸优先、BaseConvexOptimizer 是终极入口。

至此,进阶均值方差三章讲完。下一章(第 6 章)我们换风险度量——把方差换成 CVaR、Semivariance、CDaR,看更广义的「有效前沿」。


发布者: 作者: 灏天文库 转发
评论区 (0)
U