第 5 章 · 03 自定义目标函数 本节摘要:本节是本章收官,讲如何完全定制优化目标——写一个自定义目标函数并传给 PyPortfolioOpt。当内置的五种目标(maxsharpe、minvolatility、efficientrisk、efficientreturn、maxquadraticutility)以及 L2 正则、交易成本都不够用时,你可以用 (凸目标,cvxpy 后端)或 (非凸目标,scipy 后端)接入任意目标。本节拆解两条路径: 要求目标用 cvxpy 原子函数构建、保持凸性,可复用所有约束 API; 用 scipy.optimize,支持任意目标但可能陷局部最优、且与模块化约束 API 不兼容。
本节摘要:本节是本章收官,讲如何完全定制优化目标——写一个自定义目标函数并传给 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_objective、nonconvex_objective)、pypfopt/objective_functions.py、文档docs/GeneralEfficientFrontier.rst(Custom optimization problems 专节)、cookbook/3-Advanced-Mean-Variance-Optimisation.ipynb,汉化并套用体系化模板。
阅读完本节,你应当能够:
convex_objective。nonconvex_objective,理解 scipy 约束格式。objective_functions.py 的扩展点设计哲学。内置目标覆盖了多数均值-方差场景,但有些需求不在此列:
(w - w_b)ᵀ Σ (w - w_b)。wᵀ Σ w - k Σ log(w_i),鼓励权重为正。docs/GeneralEfficientFrontier.rst 的「Custom optimization problems」专节专门讲这两条路径。
回顾 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) |
事后跟踪误差 |
自定义目标就是「按这个签名,写一个新函数」。
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()
要求:
custom_objective(w, **kwargs),w 是 cvxpy 变量,返回 cvxpy 表达式。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 或负数)。这是对数目标的隐含要求。
文档 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),因为跟踪误差与 μ、Σ 无关——这是「完全脱离均值-方差范式」的入口。
若目标真的非凸(无法用 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 字典格式 |
| 全局最优 | 保证(凸问题) | 不保证(可能局部最优) |
| 与模块化约束兼容 | 是 | 否 |
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 的历史包袱。
风险平价让每只资产对组合风险的贡献相等。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()
要求 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 后端的灵活性:
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()。
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 的扩展哲学:
_objective_value 让同一函数既可计算具体值(ndarray 输入),又可构造优化表达式(cp.Variable 输入)。BaseConvexOptimizer 而非 EfficientFrontier。这是 PyPortfolioOpt 「易扩展」承诺的最终落点——从「换一层组件」到「换整个问题」,都有清晰的扩展路径。
convex_objective(凸,cvxpy 后端)与 nonconvex_objective(非凸,scipy 后端)。(w, **kwargs),用 cvxpy 原子函数,保持凸性,要最小化;可复用 add_constraint/add_objective。(w, *args),任意 numpy 函数;约束用 scipy 字典格式(type="eq" 函数 =0,type="ineq" 函数 ≥ 0);不兼容模块化约束 API。至此,进阶均值方差三章讲完。下一章(第 6 章)我们换风险度量——把方差换成 CVaR、Semivariance、CDaR,看更广义的「有效前沿」。