有些错误会自己暴露,有些不会。
半衰期估计属于后者。你从一条序列里估出一个衰减速度,用它去平滑、去设判据、去决定这个信号「值不值得做」。估错了,代码照样跑完,图照样画出来,判据照样给出结论------只是这个结论指向了反方向。而这条序列自己不会告诉你「你估错了」。
这篇文章讲三件事:半衰期怎么从 AR(1) 反解出来、估计会踩哪三个坑、以及估错之后一条判据会怎么把好信号判死。全部场景是合成数据,随机种子固定,文中每个数字都能用文末脚本逐位复算。
一、先把半衰期写成一个可以算的东西
半衰期这个概念通常说得含糊:「信号衰减一半需要多久」。要让它可计算,得先假设一个衰减模型。最常用的是 AR(1):
xt=ϕxt−1+εt
这个模型只有一个参数 φ。它的一个性质是:滞后 k 期的自相关正好是 φ 的 k 次方。
corr(xt,xt+k)=ϕk
于是「衰减到一半」就是解 φ^k = 0.5:
半衰期=lnϕln0.5
一行代码:
python
import math
LN2 = math.log(0.5)
def half_life_from_phi(phi: float) -> float:
"""AR(1) 反解半衰期:x_t = φx_{t-1} + ε ⇒ corr(k) = φ^k
解 φ^k = 0.5 ⇒ k = ln(0.5) / ln(φ)
"""
if not (0.0 < phi < 1.0):
raise ValueError(f"phi 必须在 (0,1) 开区间内,收到 {phi}")
return LN2 / math.log(phi)
三个数字可以立刻验证这个公式的手感:φ=0.9 给出 6.58 期,φ=0.95 给出 13.51 期,φ=0.99 给出 68.97 期。
记一下 13.51 这个数------后面所有的误差演示都以它作真值。
到这里一切都很干净。坑在下一步:φ 从来不是已知的,你得从数据里估。 而估 φ 这件事,比公式看起来难得多。
二、第一个坑:这个映射在高 φ 区域极陡
反解公式本身没问题,但它是个非线性映射,误差在不同区域被放大的倍数完全不同。
把 φ 提高 1%(相对),看半衰期怎么变:
scss
演示 1:AR(1) 反解 ------ φ 到半衰期的映射有多陡
φ 半衰期(期) φ×1.01 后 变化
------------------------------------------------------
0.50 1.00 1.01 +0.01
0.70 1.94 2.00 +0.06
0.80 3.11 3.25 +0.14
0.90 6.58 7.26 +0.69
0.95 13.51 16.77 +3.25
0.98 34.31 67.61 +33.30
0.99 68.97 692.80 +623.83
在 φ=0.50 附近,把 φ 抬高 1%,半衰期只动 0.01 期,几乎察觉不到。在 φ=0.98 附近,同样 1% 的抬高让半衰期从 34 期跳到 68 期,翻了一倍。在 φ=0.99 处更夸张:一个 1% 的误差换来 623 期的偏差。
这个现象有一个直接的后果:半衰期的估计误差不是均匀的,它随 φ 升高而爆炸。 而真实世界里,需要关心半衰期的信号往往正是 φ 接近 1 的那些------衰减快的信号你根本不会去算半衰期。
所以「半衰期估得挺准,误差不到一成」这种说法在 φ 高的区域是不可靠的。φ 上的一个小误差,映射到半衰期上可能变成几十个百分点------看上表 φ=0.98 那一行,1% 的 φ 偏差换来 97% 的半衰期偏差。
用 delta 法可以把这个放大倍数写出来:
python
def half_life_delta_se(phi: float, n: int) -> float:
"""半衰期估计的标准误(delta 法)。
se(φ) ≈ sqrt((1-φ²)/n);dHL/dφ = -ln(0.5) / (φ·ln(φ)²)
"""
se_phi = math.sqrt(max(1e-15, (1.0 - phi * phi) / n))
d_hl = abs(LN2) / (phi * math.log(phi) ** 2)
return d_hl * se_phi
导数项 dHL/dφ 在 φ→1 时发散,这就是上表的代数形式。固定样本长度 n=1000,看三档 φ:
scss
delta 法的标准误(n=1000,φ 估得越接近 1,误差越大):
φ se(φ) 半衰期 se(半衰期) 相对误差
--------------------------------------------------
0.90 0.014 6.58 0.96 15%
0.95 0.010 13.51 2.74 20%
0.98 0.006 34.31 10.91 32%
注意 se(φ) 那一列在下降------φ 越大,φ 的估计本身反而越精确(0.014 降到 0.006)。但半衰期的标准误从 0.96 期涨到 10.91 期,相对误差从 15% 升到 32%。同样的样本量,同样的估计质量,半衰期的可信度差了三倍。
这是 delta 法的意义:它把「φ 估得准不准」和「半衰期估得准不准」分开。前者在改善,后者在恶化,中间隔着那个非线性映射。
这还只是「φ 估得准不准」这一层。真正麻烦的是下一层。
三、第二个坑:含噪观测下,回归估 φ 系统性偏低
这是最致命的一个,也是最不直观的。
假设你要估的信号本身看不见,你看到的是它加上了噪声:
观测=信号+噪声
用最小二乘回归观测的当期对滞后一期,估出来的 φ 会怎样?
答案:系统性偏低,而且偏得很厉害。
原因在计量经济学里叫「变量误差」(errors-in-variables)。回归 y = φx 时,如果 x 本身带测量误差,最小二乘的估计量会向 0 收缩,收缩的幅度取决于噪声占总方差的比例。而这里的情况更糟:噪声加在序列上,滞后项也带着噪声,于是 corr(obs_t, obs_{t-1}) 被噪声按 σ_s²/(σ_s²+σ_n²) 的比例衰减,回归系数跟着衰减。
真值 φ=0.95(半衰期 13.51 期),序列长度 2000,各档噪声下重复 200 次:
lua
演示 2:观测含噪声,直接回归估 φ 会系统性偏低
真信号:φ=0.95 半衰期=13.51 期 序列长度=2000 重复=200
噪声σ 信噪比 朴素估 φ 朴素估半衰期 比值法半衰期
----------------------------------------------------------------
0.0 inf 0.948 13.08 12.70
0.5 4.00 0.750 2.41 12.88
1.0 1.00 0.466 0.91 11.75
2.0 0.25 0.183 0.41 6.59
没有噪声时(σ=0),朴素法给出 13.08 期,贴着真值。这是所有单元测试会覆盖的情形,也是你本地调试时的情形。
一旦加上噪声,崩溃开始。σ=0.5,信噪比 4:1,看起来还挺干净,朴素法给出 2.41 期------只有真值 13.51 期的 18%。σ=1.0,信噪比 1:1,朴素法给出 0.91 期,只有真值的 7%。σ=2.0 时更离谱,0.41 期,真值的 3%。
而右侧一列是另一种估法,它在所有噪声档下都贴着真值。
修法:相邻两阶自相关相除
既然噪声对自相关的衰减是等比例的,那就把两阶相除,比例因子自然约掉:
观测的自相关是 ρ_obs(k) = c · φ^k,其中 c = σ_s²/(σ_s²+σ_n²)。那么:
ρobs(1)ρobs(2)=cϕcϕ2=ϕ
噪声强度 c 完全消失了。写成代码:
python
def acf(x, k):
"""样本自相关,滞后 k 期。"""
xc = x - x.mean()
den = float(xc @ xc)
if den < 1e-15:
return float("nan")
return float(xc[:-k] @ xc[k:]) / den
def estimate_phi_ratio(x):
"""比值法:ρ(2)/ρ(1) = φ,噪声项在相除时约掉。"""
r1, r2 = acf(x, 1), acf(x, 2)
if not (r1 > 1e-9) or not np.isfinite(r2):
return float("nan")
return float(r2 / r1)
看上表最右一列:σ=0.5 给 12.88,σ=1.0 给 11.75,都贴着 13.51。噪声再大(σ=2.0)开始失真,给 6.59------但它仍然是朴素法 0.41 的十六倍。
这里要诚实说明比值法的边界:它假设噪声是白噪声 (各期独立)。如果噪声本身有自相关,ρ_obs(k) 就不是干净的 c·φ^k,相除的推导不成立,比值法也会偏。所以这个方法不是「免检」,它把一个假设换成了另一个假设。但相比之下,「噪声是白的」这个假设比「观测没有噪声」要现实得多。
四、第三个坑:平滑长度不等于信号半衰期
估出半衰期之后,最常见的用法是拿它去设平滑窗口。这一步也有坑,而且和直觉相反。
先明确两件事不是一回事:
- 信号半衰期:描述信号自己衰减多快,是数据的一个属性;
- 平滑半衰期:EWMA 权重衰减到一半所需的期数,是你选的一个参数。
两者量纲相同、含义不同。用半衰期 hl 做因果平滑的公式是:
python
def ewma_half_life(x, hl):
"""半衰期加权平滑:滞后 k 期权重 0.5^(k/hl),逐点归一化。
这是因果平滑:t 时刻只用到 t 及之前的观测。
"""
n = len(x)
w = 0.5 ** (np.arange(n) / hl)
num = np.convolve(x, w)[:n]
den = np.convolve(np.ones(n), w)[:n]
return num / den
现在造一个真值半衰期 13 期的信号,加上 σ=0.8 的白噪声,扫描不同平滑长度,看平滑结果与真信号的相关:
lua
演示 3:平滑长度不等于信号半衰期 ------ 因果平滑有滞后
真信号:φ=0.9481 半衰期=13.00 期,序列长度=4000
观测 = 真信号 + 白噪声(σ=0.8)
不做平滑时,观测与真信号相关 = 0.761
平滑半衰期 倍数 与真信号相关 相对不做平滑
----------------------------------------------------
1.0 0.08x 0.880 1.16x
2.0 0.15x 0.892 1.17x
4.0 0.31x 0.856 1.12x
6.5 0.50x 0.803 1.05x
13.0 1.00x 0.690 0.91x
26.0 2.00x 0.540 0.71x
52.0 4.00x 0.381 0.50x
104.0 8.00x 0.252 0.33x
扫描区间内最优:平滑半衰期 2.0 期(信号真值 13.0 期),相关 0.892
最反直觉的一行是 13.0 那一行:平滑半衰期取信号的真值,相关 0.690,比完全不做平滑的 0.761 还低。
这不是代码写错了。原因是因果平滑只能往回看。设 hl=13,意味着 t 时刻的输出是过去十几期的加权平均,而这个平均的中心落在 t−13 附近------输出滞后于信号十几期。信号在半衰期内已经变化了很多,滞后 13 期的平均自然和当前的信号对不上。平滑压住了噪声,但引入的滞后损失更大,净效果是负的。
最优点落在 2 期,只有真值的 0.15 倍。这不是「估计错了」,是一个不同的量:给定噪声水平,最优平滑长度由「压噪声」和「滞后」两股力量的平衡决定,但它和信号自相关长度没有必然相等关系。
所以这里有一个必须分开的认知:
半衰期回答「信号衰减多快」,平滑长度回答「我要压多少噪声、能容忍多少滞后」。用前者直接当后者,是让数据属性替你做一个参数决策。
如果你的目的是「估计当前信号的真实水平」,正确的做法是卡尔曼滤波或类似的模型化方法,不是拿半衰期当 EWMA 参数。如果你的目的是「搭一个简单的稳健基线」,那用半衰期当平滑长度是可以的,但要清楚它是一个便利选择,不是最优选择。
五、误差传导:一条判据怎样误杀全部信号
前面三个坑都不致命,只要你知道它们存在。致命的是它们串起来之后。
设想一个常见的判据设计:要求信号的半衰期不低于某个门槛,否则认为它衰减太快、不值得投入。 这个判据本身的逻辑没问题------衰减太快的东西确实难以执行。问题在于,判据用的是估计出来的半衰期,而估计有偏。
真值 13.00 期,判据门槛设为 6.0 期(演示用的合成门槛,与任何真实业务配置无关)。正确答案显然是「过」。现在用两种估计法各跑 300 次:
python
# 核心循环,完整版见文末脚本
for _ in range(n_rep):
signal = simulate_ar1(phi, n, rng) # phi 对应半衰期 13 期
obs = signal + rng.normal(0.0, sigma, size=n)
p = estimate_phi(obs) # 或 estimate_phi_ratio(obs)
if 0.0 < p < 1.0:
ests.append(half_life_from_phi(p))
if half_life_from_phi(p) >= gate:
hits += 1
erlang
演示 4:误差传导到判据 ------ 低估会把慢信号判成快噪声
真值:半衰期 13.00 期,序列长度 4000,重复 300
判据(演示用合成门槛):半衰期 >= 6.0 期 才算过
真实答案:13.00 >= 6.0,应当判「过」
估计法 中位估计 判过占比 误判率
------------------------------------------------
朴素法 1.25 0.0% 100.0%
比值法 12.80 99.7% 0.3%
朴素法 300 次试验,一次都没过闸,误判率 100%。中位估计 1.25 期,真值 13.00 期,低了 10.4 倍。同样的信号、同样的观测、同样的判据,只换了估计方式,结论从「全部拒绝」变成「几乎全部通过」。
这个演示揭示的机制值得单独拎出来:估计偏差不会停留在数字层面,它会经过判据放大成决策错误。 而且放大的方向是固定的------低估让判据变严,高估让判据变松。前者更常见(因为变量误差总是让估计向 0 收缩),后果也更容易被忽视:你不会收到「我判错了」的报警,你只会得到一批「这个方向不行」的结论,然后继续找下一个方向。
六、这件事对「判据先行」意味着什么
上面这套机制有一个正面的用法。
回到一个现实场景:你的数据还不够,只够跑个试样。这时候「这个信号将来能不能用」通常是没法回答的。但半衰期这类结构指标不一样------它不依赖全量样本,几百期的试样就能给出一个读数,而且是偏向保守的读数。
python
def precheck(obs, min_sample=250, gate=6.0):
"""试样期结构预检:样本够不够、结构指标是否已经注定过不了闸。"""
if len(obs) < min_sample:
return "BLOCKED", f"window_short:{len(obs)}<{min_sample}"
phi_hat = estimate_phi_ratio(obs) # 用比值法,避开含噪低估
if not (0.0 < phi_hat < 1.0):
return "BLOCKED", "phi_unidentifiable"
hl = half_life_from_phi(phi_hat)
if hl < gate:
return "STRUCTURAL_RISK", f"half_life:{hl:.2f}<{gate}"
return "PASS", f"half_life:{hl:.2f}"
这个函数的返回值值得注意:它区分了 BLOCKED(样本不够,判不了)和 STRUCTURAL_RISK(样本够,结构指标已经不行)。这两个状态的「将来」完全不同:
BLOCKED的将来是等数据------数据到货,闸大概率能过;STRUCTURAL_RISK的将来是换信号------数据补齐也翻不了,因为半衰期是信号的属性,不是样本的属性。
「数据不足」这四个字背后至少藏着这两种情况,混在一起登记,会导致一个具体的浪费:把所有卡都当成「等数据」,于是花力气去扩一批永远不会翻盘的样本。
这条经验的更一般形式是:判据不是一道闸,是一组仪表。 有的仪表读的是当前能不能跑(窗口够不够、字段全不全),有的读的是跑起来像不像样(半衰期、独立性),有的读的是值不值得单独跑。它们挂在同一张卡上,但挂掉的含义完全不同------把「挂在哪条闸」记清楚,比记「挂了」有价值得多。
顺带一个方法论上的提醒:上面的 precheck 里,估计用的是比值法而不是朴素回归法,这不是洁癖。如果你在试样期用朴素法估半衰期,含噪低估会让结构闸误报------把一个本来可持续的信号登记成结构风险。样本越短、噪声越大,这个误报越严重。第一道防线错了,后面的判据再严谨也没用。
七、可带走的三条
如果只记三件事:
一、半衰期是从 φ 反解出来的,不是直接测出来的。 这个中间步骤引入了两个独立误差源:φ 的估计误差,以及非线性映射对误差的放大。报告半衰期时,如果不同时报告 φ 的置信区间和样本长度,这个数字的可信度无从判断。
二、观测含噪声时,别用朴素回归估 φ。 它会系统性偏低,偏差量级随信噪比恶化而爆炸------信噪比 1:1 时真值 13.5 期的信号估出 0.91 期。相邻两阶自相关相除可以约掉噪声项,代价是要求噪声为白噪声。这个修法只多两行代码。
三、平滑长度 ≠ 信号半衰期。 用信号半衰期直接当 EWMA 参数,在演示里比不做平滑还差(0.690 对 0.761)。因果平滑有滞后,最优平滑长度由噪声水平和可容忍滞后共同决定,不是信号属性的直接读出。
最后补一句边界:这三个坑都关于「估得准不准」,不关于「估出来该不该用」。半衰期长不代表信号有价值------一个衰减很慢但没有预测力的序列,半衰期可以估得很漂亮。估计方法是必要条件,不是充分条件。
本文场景全部为合成数据(AR(1) 过程 + 白噪声),判据门槛为演示用合成参数,不涉及任何真实数据源、因子构造、业务配置或回测业绩。文中每个数字均为 half_life_demo.py --seed 42 的真实输出,可逐位复算。本文为统计方法与软件工程讨论,不构成投资建议,不构成对任何机构或产品的评价。
若需完整脚本与半衰期估计的自检清单,可在评论区留言「验真」。