第 10 章 · 02 自定义优化器 BaseOptimizer 本节摘要:PyPortfolioOpt 内置的优化器(EfficientFrontier、HRPOpt、CLA、BlackLittermanModel)都继承自 或 这两个抽象基类。它们提供统一的「后处理」接口( 、 、 ),让你的自定义优化器无缝接入四层架构(收益/风险/约束/目标)。本节讲清两个基类的职责划分、关键方法、以及如何继承 BaseConvexOptimizer 用 cvxpy 写一个自定义优化器(以跟踪误差最小化为例)。读完本节,你能扩展 PyPortfolioOpt 实现任意自定义目标。 内容来源:源码 、文档 、 ,汉化并套用体系化模板。
本节摘要:PyPortfolioOpt 内置的优化器(EfficientFrontier、HRPOpt、CLA、BlackLittermanModel)都继承自
BaseOptimizer或BaseConvexOptimizer这两个抽象基类。它们提供统一的「后处理」接口(clean_weights、save_weights_to_file、portfolio_performance),让你的自定义优化器无缝接入四层架构(收益/风险/约束/目标)。本节讲清两个基类的职责划分、关键方法、以及如何继承 BaseConvexOptimizer 用 cvxpy 写一个自定义优化器(以跟踪误差最小化为例)。读完本节,你能扩展 PyPortfolioOpt 实现任意自定义目标。
内容来源:源码
pypfopt/base/_base_optimizer.py、文档docs/OtherOptimizers.rst、docs/GeneralEfficientFrontier.rst,汉化并套用体系化模板。
阅读完本节,你应当能够:
源码定义两个基类,职责严格分层:
| 基类 | 职责 | 用 cvxpy | 子类 |
|---|---|---|---|
| BaseOptimizer | 后处理、权重管理 | 否 | HRPOpt、CLA、BlackLittermanModel |
| BaseConvexOptimizer | + cvxpy 优化变量、约束、目标 | 是 | EfficientFrontier 及其子类 |
BaseOptimizer 极简——只管 n_assets、tickers、weights,以及三个工具方法。BaseConvexOptimizer 在此之上加了 cvxpy 的 _w 变量、约束列表、目标函数。
💡 选哪个继承:你的优化器若能用 cvxpy 表达,继承 BaseConvexOptimizer;若是完全自定义算法(遗传、模拟退火、聚类等),继承 BaseOptimizer。
源码 __init__:
class BaseOptimizer: def __init__(self, n_assets, tickers=None): self.n_assets = n_assets if tickers is None: self.tickers = list(range(n_assets)) else: self.tickers = tickers self._risk_free_rate = None self.weights = None # 优化结果,由子类填
三个核心方法:
def set_weights(self, input_weights): self.weights = np.array([input_weights[ticker] for ticker in self.tickers])
def clean_weights(self, cutoff=1e-4, rounding=5): if self.weights is None: raise AttributeError("Weights not yet computed") clean_weights = self.weights.copy() clean_weights[np.abs(clean_weights) < cutoff] = 0 # 小于 cutoff 置 0 if rounding is not None: clean_weights = np.round(clean_weights, rounding) return self._make_output_weights(clean_weights)
cutoff=1e-4:绝对值小于 0.0001 的权重置 0(避免 1e-17 这种数值垃圾)。rounding=5:保留 5 位小数。def save_weights_to_file(self, filename="weights.csv"): clean_weights = self.clean_weights() ext = filename.split(".")[-1].lower() if ext == "csv": pd.Series(clean_weights).to_csv(filename, header=False) elif ext == "json": with open(filename, "w") as fp: json.dump(clean_weights, fp) elif ext == "txt": with open(filename, "w") as f: f.write(str(dict(clean_weights))) else: raise NotImplementedError("Only supports .txt .json .csv")
支持 csv / json / txt 三种格式。
💡 API 统一的价值:因为所有内置优化器都继承自这两个基类,无论你用 EfficientFrontier 还是 HRPOpt,
clean_weights/save_weights_to_file/portfolio_performance的签名完全一致。这让上层代码可以无缝替换优化器。
class BaseConvexOptimizer(BaseOptimizer): def __init__(self, n_assets, tickers=None, weight_bounds=(0, 1), solver=None, verbose=False, solver_options=None): super().__init__(n_assets, tickers) self._w = cp.Variable(n_assets) # 权重变量 self._objective = None self._additional_objectives = [] self._constraints = [] # ... self._map_bounds_to_constraints(weight_bounds)
关键私有变量:
self._w:cvxpy 的权重变量,所有目标/约束都引用它。self._objective:主目标。self._additional_objectives:额外目标(L2 正则等)。self._constraints:约束列表。ef.add_constraint(lambda w: w[0] >= 0.2) # 单资产下界 ef.add_constraint(lambda w: w[3] + w[4] <= 0.10) # 组合上界 ef.add_objective(objective_functions.L2_reg) # L2 正则
源码:
def add_objective(self, new_objective, **kwargs): if self._opt is not None: raise InstantiationError( "Adding objectives to an already solved problem might have unintended consequences. " "A new instance should be created for the new set of objectives." ) self._additional_objectives.append(new_objective(self._w, **kwargs)) def add_constraint(self, new_constraint): if not callable(new_constraint): raise TypeError("New constraint must be provided as a callable (e.g lambda function)") if self._opt is not None: raise InstantiationError(...) self._constraints.append(new_constraint(self._w))
⚠️ 优化后不可再 add:一旦调用过
max_sharpe/min_volatility等,self._opt就非 None,继续 add 会抛InstantiationError。要加新约束/目标,必须新建实例。
sector_mapper = {"GOOG": "tech", "FB": "tech", "XOM": "Oil/Gas", ...} sector_lower = {"tech": 0.1} # tech 至少 10% sector_upper = {"tech": 0.4, "Oil/Gas": 0.1} ef.add_sector_constraints(sector_mapper, sector_lower, sector_upper)
如果你的目标能用 cvxpy 原子函数表达,用 convex_objective:
def logarithmic_barrier(w, cov_matrix, k=0.1): # Kolm et al (2014) return cp.quad_form(w, cov_matrix) - k * cp.sum(cp.log(w)) w = ef.convex_objective(logarithmic_barrier, cov_matrix=ef.cov_matrix)
源码:
def convex_objective(self, custom_objective, weights_sum_to_one=True, **kwargs): 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()
文档里的跟踪误差最小化示例——直接用 BaseConvexOptimizer 而非 EfficientFrontier:
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:
def nonconvex_objective( self, custom_objective, objective_args=None, weights_sum_to_one=True, constraints=None, solver="SLSQP", initial_guess=None, ): # ... 构造 scipy 约束 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()
示例——市场中性下的有效风险:
constraints = [ {"type": "eq", "fun": lambda w: np.sum(w)}, # 权重和为 0(市场中性) {"type": "eq", "fun": lambda w: target_risk**2 - w.T @ ef.cov_matrix @ w}, ] ef.nonconvex_objective( lambda w, mu: -w.dot(mu), # 最小化负收益 = 最大化收益 objective_args=(ef.expected_returns,), weights_sum_to_one=False, constraints=constraints, )
⚠️ scipy 后端的局限:可能陷入局部最优、约束需手写 scipy 字典格式、速度比 cvxpy 慢。官方明确不推荐除非真非凸。
完全自定义算法时,继承 BaseOptimizer:
import numpy as np import pandas as pd from pypfopt.base import BaseOptimizer class MyEqualWeightOptimizer(BaseOptimizer): """最简单的示例:等权分配""" def __init__(self, tickers): super().__init__(n_assets=len(tickers), tickers=tickers) def optimize(self): w = np.ones(self.n_assets) / self.n_assets self.weights = w return self._make_output_weights()
由于继承自 BaseOptimizer,自动获得 clean_weights、save_weights_to_file、set_weights,可被 plotting.plot_weights 直接消费,与其他模块无缝拼接。
opt = MyEqualWeightOptimizer(["AAPL", "MSFT", "GOOG"]) weights = opt.optimize() print(opt.clean_weights()) opt.save_weights_to_file("eq_weights.json") from pypfopt import plotting plotting.plot_weights(weights)
set_weights、clean_weights(cutoff, rounding)、save_weights_to_file 在所有内置优化器签名一致,可无缝替换。InstantiationError,要改需新建实例。optimize 填 self.weights,即可获得全部后处理与生态对接能力。下一节,我们看 PyPortfolioOpt 的测试规范与贡献指南——把这个库当成可参与的开放项目。