第 6 章 · 01 半方差优化 本节摘要:均值-方差优化把「涨」和「跌」一视同仁地惩罚,但投资者真正怕的是跌,而不是涨。半方差(Semivariance)只惩罚低于目标收益的「下行波动」,更贴合真实风险偏好。本节讲清 类:它继承自 ,但不再喂协方差矩阵,而是喂历史收益 DataFrame,内部用 cvxpy 引入 个变量求解一个凸的均值-半方差规划。配套指标从 Sharpe 换成 Sortino。读完本节,你能用半方差替换方差出权重,并说清它与均值-方差在「下行偏差」上的本质差异。 内容来源:原项目源码 、文档 ,汉化并套用体系化模板。 学习目标 阅读完本节,你应当能够: 用 EfficientSemivariance 替换 做半方差优化。
本节摘要:均值-方差优化把「涨」和「跌」一视同仁地惩罚,但投资者真正怕的是跌,而不是涨。半方差(Semivariance)只惩罚低于目标收益的「下行波动」,更贴合真实风险偏好。本节讲清
EfficientSemivariance类:它继承自EfficientFrontier,但不再喂协方差矩阵,而是喂历史收益 DataFrame,内部用 cvxpy 引入2T+N个变量求解一个凸的均值-半方差规划。配套指标从 Sharpe 换成 Sortino。读完本节,你能用半方差替换方差出权重,并说清它与均值-方差在「下行偏差」上的本质差异。
内容来源:原项目源码
pypfopt/efficient_frontier/efficient_semivariance.py、文档docs/GeneralEfficientFrontier.rst,汉化并套用体系化模板。
阅读完本节,你应当能够:
EfficientFrontier 做半方差优化。经典马科维茨模型用方差 wᵀΣw 度量风险,把「向上偏离」也当成坏事。但真实世界里:
半方差只统计「低于某个基准 benchmark 的偏差」,数学定义是:
semivariance = (1/T) Σ max(benchmark - rₜ, 0)² × frequency semideviation = √semivariance
对应到 PyPortfolioOpt 的源码逻辑(portfolio_performance 中):
portfolio_returns = self.returns @ self.weights drops = np.fmin(portfolio_returns - self.benchmark, 0) # 只留低于 benchmark 的部分 semivariance = np.sum(np.square(drops)) / self._T * self.frequency semi_deviation = np.sqrt(semivariance)
💡 核心心法:方差 = 全体偏离的平方均值;半方差 = 仅负偏离的平方均值。半方差是把方差中「正向」那一半置零,因此永远 ≤ 方差。它更精确地刻画了「最坏情况下的波动」。
EfficientSemivariance 继承自 EfficientFrontier,但构造函数签名变了:不要协方差矩阵,要历史收益。
from pypfopt import expected_returns, EfficientSemivariance mu = expected_returns.mean_historical_return(df) returns = expected_returns.returns_from_prices(df) # 关键:历史日收益 DataFrame es = EfficientSemivariance( expected_returns=mu, returns=returns, frequency=252, benchmark=0, # 区分上下行的阈值,默认 0(日级),要与 returns 同频率 weight_bounds=(0, 1), solver=None, verbose=False, solver_options=None, )
源码 __init__ 干了两件关键事:
super().__init__( expected_returns=expected_returns, cov_matrix=np.zeros((returns.shape[1],) * 2), # 占位用的假协方差 weight_bounds=weight_bounds, ... ) self.returns = self._validate_returns(returns) self.benchmark = benchmark self.frequency = frequency self._T = self.returns.shape[0] # 记录样本天数 T
⚠️ 重要差异:它给父类塞了个全零的假协方差矩阵——父类的协方差通路被绕开,真正算风险用的是
self.returns(T×N 的历史收益)。所以你不必、也不应该自己提供协方差。
benchmark 是区分「上下行」的收益阈值,默认 0,意思是「日收益 < 0 就算下行」。它必须与 returns 同频率——日收益就配日级 benchmark,年化收益就配年化 benchmark。
由于风险度量换了,父类的 min_volatility / max_sharpe 在子类里直接抛 NotImplementedError,你得改用半方差版的方法:
def min_volatility(self): raise NotImplementedError("Please use min_semivariance instead.") def max_sharpe(self, risk_free_rate=0.0): raise NotImplementedError("Method not available in EfficientSemivariance")
下表对应四个可用方法:
| 方法 | 含义 | 对应 EfficientFrontier |
|---|---|---|
min_semivariance() |
最小化组合半方差 | min_volatility() |
max_quadratic_utility(risk_aversion) |
最大化下行二次效用 | max_quadratic_utility() |
efficient_risk(target_semideviation) |
给定半偏差下最大化收益 | efficient_risk() |
efficient_return(target_return) |
给定收益下最小化半偏差 | efficient_return() |
源码用「正负偏差分解」技巧把半方差写成凸规划。引入两个非负变量 p(正向超出)和 n(负向超出):
p = cp.Variable(self._T, nonneg=True) n = cp.Variable(self._T, nonneg=True) self._objective = cp.sum(cp.square(n)) # 只惩罚负向 B = (self.returns.values - self.benchmark) / np.sqrt(self._T) self.add_constraint(lambda w: B @ w - p + n == 0) # 每日的正负分解 self._make_weight_sum_constraint(market_neutral)
物理意义:对每个历史日 t,B[t] @ w = p[t] - n[t],要么正向要么负向,半方差就是 Σ n²,只惩罚负的部分。
💡 变量数翻倍:半方差优化变量数是
2T + N(T 个 p、T 个 n、N 个权重)。50 个资产 + 3 年日线 ≈ 1500 个变量。规模一上来,solver 容易报错,保持问题小而干净。
优化完调 portfolio_performance(verbose=True),输出三件套:
mu, semi_deviation, sortino_ratio = es.portfolio_performance(verbose=True) # Expected annual return: 20.0% # Annual semi-deviation: 9.1% # Sortino Ratio: 2.18
Sortino 是 Sharpe 的「下行版」,分母从标准差换成 semideviation:
Sortino = (μ - R_f) / semideviation
| 指标 | 分子 | 分母 | 适用 |
|---|---|---|---|
| Sharpe | 超额收益 | 全体标准差 | 收益对称正态 |
| Sortino | 超额收益 | 下行 semideviation | 收益偏斜、只怕跌 |
from pypfopt import expected_returns, EfficientSemivariance df = ... # 你的价格 DataFrame mu = expected_returns.mean_historical_return(df) hist_rets = expected_returns.returns_from_prices(df) es = EfficientSemivariance(mu, hist_rets) es.efficient_return(target_return=0.20) # 给定 20% 年化,最小化半方差 weights = es.clean_weights() print(weights) es.portfolio_performance(verbose=True)
输出类似:
Expected annual return: 20.0% Annual semi-deviation: 9.1% Sortino Ratio: 2.18
| 对比维度 | EfficientFrontier(方差) | EfficientSemivariance(半方差) |
|---|---|---|
| 输入 | 协方差矩阵 | 历史收益 DataFrame |
| 风险定义 | 全体偏离 | 仅下行偏离 |
| 变量数 | N | 2T + N(规模大) |
| 比率指标 | Sharpe | Sortino |
| 求解难度 | 容易 | 较难,易触发 solver 错误 |
| 适用场景 | 收益对称、追求稳定 | 关注下行、怕大跌 |
⚠️ 常见坑:
benchmark必须与returns同频率。你用日级 returns 却传年化 benchmark 0.05,等于把所有日子都判成下行,结果毫无意义。
EfficientFrontier,构造时要喂历史收益而非协方差;父类的协方差被假零矩阵绕开。min_volatility→min_semivariance、max_sharpe不可用、efficient_risk/return 仍可用但参数变为 semideviation。portfolio_performance 返回 (期望收益, 半偏差, Sortino)。下一节,我们看 CVaR 条件风险价值——直接刻画「最坏那 5% 日子里的平均损失」,把目光从「整体波动」推向「尾部极端」。