简介:面向需要分析非线性时间序列因果关系的科研人员、数据分析师与算法学习者,这份资源以“MATLAB实现代码+经典论文PDF”双件套形式,完整呈现Sugihara等人提出的CCM(Convergent Cross Mapping)算法。传统相关性与格兰杰因果检验在复杂系统中常因非线性、非平稳和高维特征而失真,CCM则基于状态空间重建与交叉预测能力,不依赖特定数据模型,能有效识别变量间潜在因果作用,适用于生态、环境、金融等跨学科数据研究。压缩包内共2个文件:1个.m脚本负责核心计算流程,涵盖数据预处理、延时嵌入、状态空间重构、逆映射构建与预测误差评估等关键步骤,便于在MATLAB中直接运行、断点调试或二次开发;1篇PDF为论文Detecting Causality in Complex Ecosystems的原版,可逐句对照公式与案例,深入理解收敛交叉映射的数学原理。通过运行脚本,读者还能以自身数据调整嵌入维数、步长等参数,观察预测误差变化,从而更直观地把握CCM的适用条件与边界。资源整体仅651KB,轻量易用。已有816人学习/下载,对希望从理论到代码完整掌握CCM的读者来说,是一份高性价比的入门与实践资料。
1. 拿到 Sugi_CCM_因果_ 先别跑数据:它指向的是 Sugihara 的收敛交叉映射
如果你的项目笔记里出现过Sugi_CCM_因果_这个标题,它指向的不是某个神秘代码库,而是 Sugihara 等人在 2012 年提出的收敛交叉映射(Convergent Cross Mapping,CCM)。这套方法解决的是非线性时间序列里的因果推断:你手上有两条观测序列 X 和 Y,想知道到底是谁在驱动谁,以及驱动强度有多大。传统做法首先想到 Granger 因果,但 Granger 建立在向量自回归上,遇到非线性耦合很容易给出互相矛盾的结果。CCM 不一样,它不假设具体方程,只依赖状态空间重构,用「Y 的影子流形能不能重建 X」来判断 X 是否驱动 Y。适合手里有较长观测序列、数据天生非线性、又不想被严密的生成模型绑住的从业者。下面按原理、最小实现、参数和踩坑四个层次拆开讲。
2. 交叉映射为什么能判因果:影子流形、反直觉方向与收敛性
2.1 延迟嵌入建影子流形:观测序列里的状态轨迹
先说 CCM 的地基:Takens 延迟嵌入定理。一个标量观测序列X(t),取嵌入维度 E 和时间延迟 τ,可以构造出 E 维向量:
X(t) = [X(t), X(t-τ), X(t-2τ), …, X(t-(E-1)τ)]
当 E 足够大时,这些向量在 E 维空间里形成一条有结构的轨迹,叫影子流形。Takens 定理的保证是:这个影子流形和真实动力系统的状态空间在拓扑上是一一对应的。换句话说,即使你没有直接观测到系统里的全部变量,这些隐藏变量的信息也会留在每个观测变量的历史轨迹里。这就是 CCM 不怕变量缺失的原因。
实际操作时,我们不会去验证系统是否满足定理的严格条件,而是把这个嵌入当作工程前提。比如一段 1000 天的温度序列,取 E=3、τ=2,影子流形就是三维空间里大约 994 个点连成的曲线。后来的交叉映射质量优劣,很大程度上取决于这段轨迹填得够不够密、流形结构够不够清晰。如果你的序列长度少于 300 个点,影子流形通常稀稀拉拉,CCM 的收敛性很难看,这个后面还会反复提到。
2.2 交叉映射的方向:判断 X 驱动 Y,用的是 Y 的流形去重建 X
CCM 最反直觉的一点是方向和直觉相反。假设 X 驱动 Y,即 X 的当前值影响了 Y 的未来演化,那么 X 的信息会被编码在 Y 的历史轨迹里。所以验证方式不是「用 X 预测 Y」,而是「用 Y 的影子流形去重建 X」,看重建值和真实值的相关性 ρ 有多高。
具体做法是:对 Y 的影子流形上的每个点Y(t),找到它在流形上的 E+1 个最近邻。由于流形上相邻的点代表相似的系统状态,这些邻居在过去和未来的行为应该也相似。取邻居对应的 X 值做加权平均,得到X̂(t),再计算X̂与真实X的皮尔逊相关系数 ρ。ρ 越高,说明 X 的信息确实被编码在 Y 的轨迹里,判定为 X 驱动 Y。
这里新手最容易搞反。我见过不少人的第一个版本代码写成「用 X 重建 Y」,结果因果方向整个反了。判断规则只有一个:目标驱动的源,也就是要证明 X→Y,就用 Y 的影子流形重建 X。把这句话贴在代码文件第一行注释里。
2.3 收敛性才是因果判据:ρ(L) 随库长 L 上升
单独一个 ρ 值说明不了因果问题。两个随机序列之间也可能算出不低的交叉映射相关,因为流形上的最近邻结构天然会带来一定的重建能力。CCM 的判别标准是收敛性:把用于搜索邻居的样本量,也就是库长 L,从较小值逐步增加到较大值,如果因果存在,那么流形填得越密,最近邻距离越小,重建 X 的精度应该越高,ρ(L) 呈现单调上升并趋于平台;如果没有因果,ρ(L) 不会随 L 表现出明显的上升趋势。
这个设计是 CCM 和普通滞后相关、互信息最大的区别:它给的不是一个静态数值,而是一条随样本量变化的曲线。判断因果时,你关注的是曲线形态,而不是末端单点。很多误用 CCM 的案例,都是只报了一个 ρ=0.7 就下结论,完全没展示收敛性,这在正式报告里基本站不住脚。
2.4 与 Granger、DCM 的适用边界
| 方法 | 核心假设 | 典型输出 | 适用场景 | 主要限制 |
|---|---|---|---|---|
| Granger 因果 | 线性或弱非线性、平稳性 | p 值与回归系数 | 经济、金融等平稳序列 | 非线性耦合容易误判方向 |
| CCM | 非线性确定性系统、序列足够长 | 双向 ρ(L) 收敛曲线 | 生态、气候、非线性观测数据 | 需要几百个点以上,强噪声下退化 |
| DCM | 显式生成模型:状态方程、观测方程、先验 | 后验有效连接强度 | 神经影像任务态有效连接 | 模型规格敏感,假设强 |
选型时先问自己:能不能写出一条可信的状态方程和观测方程?能,优先 DCM;只有一堆观测序列,没有可假设的机制模型,用 CCM 或 Granger。实际项目里我见过太多把 CCM 当黑匣子、把 DCM 当万能回归的人,这两个工具互换使用基本都要翻车,第 5 章会展开讲选型细节。
3. 用 Python 跑通 CCM 最小实现:耦合 logistic 映射与收敛曲线
3.1 构造含真实因果方向的合成数据
要验证代码写对了,先得有已知答案的数据。用耦合 logistic 映射作为实验台:两个物种竞争,X 影响 Y 但 Y 不影响 X,即只有 X→Y。方程形式为:
import numpy as np def simulate_coupled_logistic(n=600, rx=3.8, ry=3.5, bxy=0.0, byx=0.2, seed=42): rng = np.random.default_rng(seed) x = np.zeros(n) y = np.zeros(n) x[0], y[0] = 0.4, 0.2 for t in range(1, n): x[t] = x[t-1] * (rx - rx * x[t-1] - bxy * y[t-1]) y[t] = y[t-1] * (ry - ry * y[t-1] - byx * x[t-1]) x[t] = min(max(x[t], 0.0), 1.0) y[t] = min(max(y[t], 0.0), 1.0) burn = 200 return x[burn:], y[burn:]这里bxy=0.0表示 X 的下一步演化不受 Y 影响,byx=0.2表示 X 的当前值会压低 Y 的下一步增长,所以因果方向是 X→Y。rx=3.8、ry=3.5都在混沌区间内,保证两条序列都不是简单周期,适合测试非线性因果推断。前 200 步作为暂态丢弃,避免初始值影响流形结构。
3.2 影子流形与交叉映射核心函数
先实现影子流形嵌入。注意一个工程细节:嵌入后流形的长度比原序列短(E-1)*τ,必须把目标序列也做同样的截断,保证流形第 i 行对应目标序列的第 i 个值。这个对齐错误是新手最常见的代码 bug。
from scipy.spatial import cKDTree def shadow_manifold(series, E=3, tau=1): offset = (E - 1) * tau m = len(series) - offset if m <= E + 1: raise ValueError("序列太短,无法嵌入") emb = np.array([series[i:i + offset + 1:tau] for i in range(m)]) return emb, series[offset:]emb的每一行是一个 E 维嵌入向量,series[offset:]是与流形对齐后的目标序列。判断 X→Y 时传入的是 Y 的流形和 X 的目标序列,两个数组长度完全一致,后续索引不会错位。
接下来是交叉映射函数cross_map_rho。它以库索引lib_idx为单位抽样,在库内建 KD 树,为全流形上每个点找最近邻,再用距离指数权重做加权平均重建目标序列:
def cross_map_rho(manifold, target, E=3, lib_idx=None, num_neighbors=None, exclusion=0): m, _ = manifold.shape if lib_idx is None: lib_idx = np.arange(m) num_neighbors = num_neighbors if num_neighbors else E + 1 tree = cKDTree(manifold[lib_idx]) k = min(num_neighbors + 1, len(lib_idx)) dists, pos = tree.query(manifold, k=k) pred = np.zeros(m) for i in range(m): nb_global = lib_idx[pos[i]] d = dists[i] if exclusion > 0: keep = np.abs(nb_global - i) > exclusion nb_global, d = nb_global[keep], d[keep] if len(nb_global) < num_neighbors: nb_global = lib_idx[pos[i]] d = dists[i] w = np.exp(-d / (d[0] + 1e-12)) w /= w.sum() pred[i] = np.sum(w * target[nb_global]) return np.corrcoef(pred, target)[0, 1]几个参数说明:exclusion是排除时间窗,后面避坑章会详细讲它的作用;d[0]是最小距离,权重按距离的负指数衰减,距离越近的邻居贡献越大;lib_idx决定了「库」是哪些点,收敛性检验本质上就是反复改变lib_idx的大小。KD 树查询时取num_neighbors+1个邻居,是因为第一个邻居往往就是目标点自身,后面在权重计算中实际使用num_neighbors个。如果exclusion过滤后邻居数不够,回退到未过滤的结果,这是为了保持代码在短序列下仍然可跑。
3.3 收敛性计算:ρ(L) 随库长 L 上升才算因果
有了核心函数,下一步就是对不同库长 L 做循环采样,看交叉映射技能 ρ 是否随 L 收敛。注意方向命名:rho_x_to_y表示「用 Y 的流形重建 X 得到的相关性」,也就是 X 驱动 Y 的证据强度。
def convergence_curve(x, y, E=3, tau=1, L_ratios=None, repeats=20, seed=1): My, tx = shadow_manifold(y, E, tau) # Y 流形重建 X Mx, ty = shadow_manifold(x, E, tau) # X 流形重建 Y n = My.shape[0] L_ratios = L_ratios if L_ratios is not None else np.linspace(0.1, 0.8, 8) rows = [] for ratio in L_ratios: L = int(np.ceil(n * ratio)) r_x_to_y = [] r_y_to_x = [] for s in range(repeats): rng = np.random.default_rng(seed + s) lib = rng.choice(n, size=L, replace=False) r_x_to_y.append(cross_map_rho(My, tx, E, lib_idx=lib)) r_y_to_x.append(cross_map_rho(Mx, ty, E, lib_idx=lib)) rows.append((L, np.mean(r_x_to_y), np.std(r_x_to_y), np.mean(r_y_to_x), np.std(r_y_to_x))) return np.array(rows)这段代码的逻辑是:对每个库长比例ratio,从全流形中无放回抽取 L 个点作为库,重复 20 次,计算双向交叉映射 ρ 的均值和标准差。每个 L 都重复多次的原因在于,L 较小时抽样方差很大,单次抽样的曲线会抖动剧烈,收敛性的趋势被噪声淹没。L_ratios取 0.1 到 0.8,是因为如果最大取 1.0,每次抽样都是全库,bootstrap 的标准差会恒为 0,看起来精度极高,实际上是假象。
跑完后输出表格,判断标准简单直接:rho_x_to_y这一列的均值随 L 单调上升并趋于平台,且显著高于rho_y_to_x,结论就是 X 驱动 Y。反过来则相反。如果两条曲线都上升且几乎重合,说明要么数据长度不够,要么两者耦合太强、存在双向驱动,要么你的数据里有严重的自相关干扰,这时候需要进入第 4 章的参数调优和第 5 章的排查流程。
4. 调好 CCM 的三个参数:E、τ、库长 L 决定结论是否可信
4.1 嵌入维度 E:用假近邻思路和重建技能双重校验
E 太小,影子流形会把不相邻的状态投影折叠在一起,产生大量「假邻居」;E 太大,流形被拉伸得过分稀疏,最近邻距离变大,交叉映射收敛变慢,还容易过拟合噪声。常见做法是先试 E 从 2 到 6,观察两个指标:一是真因果方向的收敛曲线形状,二是双向 ρ 的区分度。
def select_E(x, y, E_candidates=range(2, 8), tau=1): for E in E_candidates: My, tx = shadow_manifold(y, E, tau) Mx, ty = shadow_manifold(x, E, tau) r_x_to_y = cross_map_rho(My, tx, E) r_y_to_x = cross_map_rho(Mx, ty, E) print(f"E={E} X->Y: {r_x_to_y:.3f} Y->X: {r_y_to_x:.3f}")选择标准有两个:第一,真实因果方向的 ρ 要明显高于反方向;第二,在相邻 E 之间结果要稳定,不要 E=3 显示强因果、E=4 突然消失。一个数值实验中常见的情况是:E=2 时由于流形折叠,双向 ρ 都高;E=3 之后方向区分度开始清晰;E=7 以上噪声被当作结构学进去,两方向都开始回升。真实数据没有标准答案,我一般以 E=3 为起点做一轮敏感性分析,把结论写成「在 E∈[2,5] 范围内方向一致」,而不是死守单个 E 值。
4.2 时间延迟 τ:自相关 1/e 法和互信息法怎么取
τ 控制嵌入向量中相邻元素的间隔。τ 太小,连续嵌入点高度相关,流形挤在一条对角线附近;τ 太大,流形散开但可能丢失短时间尺度的耦合信息。经验上,先用自相关函数选一个候选值:找自相关首次降到1/e的滞后作为 τ。
def suggest_tau(series, max_lag=50): x = series - series.mean() acf = [np.corrcoef(x[:-lag], x[lag:])[0, 1] for lag in range(1, max_lag + 1)] drop = np.where(np.array(acf) <= 1 / np.e)[0] return int(drop[0]) + 1 if len(drop) else 1| 数据采样类型 | 常见 τ 取值范围 | 备注 |
|---|---|---|
| 逐日连续采样、混沌系统 | 1 或 2 | 自相关衰减快,取小值 |
| 逐月采样、气候指数 | 1~3 | 先用自相关法计算,再人工复核 |
| 高频金融数据 | 5~20 | 存在微结构噪声,τ 偏大更稳 |
| 强周期数据 | 避开整数周期附近 | 否则流形会沿周期方向折叠 |
需要提醒的是,互信息法在理论上比自相关法更适合非线性数据,但计算成本和参数选择本身会引入新的主观因素。对于工程落地,自相关 1/e 法已经够用,关键是最后做一次敏感性检查:把 τ 从 1 变到 3,如果因果方向和收敛形态没变,这个参数就不是决定性的;如果变了,说明结论对 τ 很敏感,需要重新审视数据是否满足 CCM 前提。
4.3 库长 L 与 bootstrap 区间:收敛判断的地基
CCM 对序列长度的要求往往被低估。理想情况下,影子流形上的点密度要足够支撑最近邻搜索,太少会导致所有 L 下的 ρ 都不收敛。我个人的最低经验值是 400 到 500 个有效数据点;低于 300 个点时,即便有真因果,ρ(L) 也很难呈现清晰的单调上升,只会看到一条上下抖动的线。
选择 L 的区间有两个原则:下限要保证 KD 树查询不到邻居,一般不少于 E+2;上限不要取序列全长。第 3.3 节的convergence_curve用了 0.1 到 0.8 的比例,理由是 L 接近全长时,抽样集合之间高度重叠,bootstrap 方差被低估,收敛曲线末端的置信区间反而「完美」,这个完美是假的。
解读收敛曲线时,别只看末端均值。正确方法是看整条曲线:因果方向对应的 ρ(L) 是否从低到高单调上升,末端是否进入平台,以及末端均值是否大于起始均值加两倍标准差。更严格的做法是搭配替代数据检验,这正好是第 5 章要讲的第一个坑,也是很多审稿人一定会问的问题。
5. CCM 结果排查:伪因果、自相关干扰和与 DCM 的选型边界
5.1 随机序列也画出「收敛曲线」:先做替代数据检验
现象:你拿两列完全无关的白噪声或随机游走数据跑convergence_curve,发现 ρ(L) 也在随 L 上升,看起来和真因果的曲线没什么区别。这时候如果直接下结论,后面基本全错。
原因:影子流形本身是平滑的,L 增大后最近邻距离系统性减小,重建相关天然会上升。这个上升是流形几何带来的,不是因果编码带来的。尤其是做过插值、滑动平均去噪的数据,平滑性更强,伪收敛更明显。
解决:做替代数据检验。常见做法是相位随机化,它保留原始序列的功率谱但打散非线性结构,然后生成一批替代序列跑同样的 CCM 流程,取 95 分位数作为阈值。原数据的 ρ 曲线必须显著高于阈值,因果结论才成立。
def surrogate_threshold(y, E, tau, n_surr=100, seed=7): My, tx = shadow_manifold(y, E, tau) obs = cross_map_rho(My, tx, E) nulls = [] for s in range(n_surr): fy = np.fft.fft(y) phase = np.exp(2j * np.pi * np.random.default_rng(seed + s).random(len(y))) y_surr = np.real(np.fft.ifft(fy * phase)) My_s, _ = shadow_manifold(y_surr, E, tau) nulls.append(cross_map_rho(My_s, tx, E)) return obs, np.percentile(nulls, 95)这里用相位随机化打散非线性结构,比简单洗牌更能保留序列的频谱特征,是替代检验里比较实用的版本。如果 obs 低于阈值,那这个因果方向就当没有。记住一条血泪经验:在正式报告里,替代检验的结果比 ρ 本身更重要。
5.2 高自相关数据:排除近邻时间窗后技能骤降
现象:ρ(L) 上升得很漂亮,但你尝试把exclusion参数从 0 调大,比如设成E * τ,交叉映射技能明显下降,甚至不再随 L 收敛,因果方向也跟着反转。
原因:高自相关序列的相邻时间点在嵌入空间里天然靠得很近,它们能成为最近邻不是因为动力学状态相似,而是因为时间上相邻。这时重建 X 用的邻居其实是在复制 X 自己的惯性趋势,和 Y 的信息无关,属于虚假因果。
解决:在cross_map_rho里增加排除窗口,把距离目标时间点太近的邻居排除掉。具体窗口宽度从E * τ开始尝试,必要时加大到序列特征周期的十分之一。如果排除后 ρ 掉得多,说明原来的结论很大程度来自自相关;掉得少,结论才可信。注意排除窗口不能太大,否则最近邻的有效数量不足,所以序列长度要足够支撑这种舍弃,短序列在这类问题上基本无解。
5.3 样本太短:ρ(L) 的置信区间和起点
现象:序列只有 200 个点,convergence_curve输出 8 个 L 节点,但 ρ(L) 从头到尾都在上升,没有平台;把 repeats 加大也没用,标准差还是大得吓人。
原因:200 个点嵌入后流形上只有不到 200 个点,库长最大也就 160 左右。最近邻密度的变化幅度太小,收敛曲线还在上升通道里就被截断了,根本没有机会看到平台。CCM 判因果靠的是「收敛」,不是「相关」,曲线没进平台就不能确认收敛。
解决:扩大采样窗口,把序列补到 500 点以上,或者降低数据的时间分辨率让有效动力学信息更密。如果数据实在短,可以尝试把 L 节点取对数间隔,让曲线中段多几个点,但这只是缓解视觉判断,救不了根本问题。另一个辅助手段是同时对两个方向做检验:短数据下如果两条曲线都上升且不分离,就不要强行解释方向。
5.4 CCM 和 DCM 怎么选:先有生成模型还是先有观测序列
现象:拿功能磁共振 BOLD 信号跑 CCM 做有效连接,结果和任务设计的预期完全对不上;反过来,拿金融时间序列去套 DCM,模型要么不收敛,要么收敛后参数后验分布宽到没法解释。
原因:CCM 和 DCM 的「因果」定义不是一回事。DCM 是假设驱动的贝叶斯生成模型,常见形式写成dx/dt = f(x,u,θ)和y = h(x,θ) + ε,其中 θ 是要估计的连接强度,u 是实验输入。它回答的是「在给定模型结构下,任务输入如何通过某条通路影响另一个脑区」。CCM 则是纯数据驱动,回答的是「从观测序列的状态空间里,能否找到另一个变量影响它的证据」。拿 BOLD 信号跑 CCM,观测空间和神经活动之间隔了一层血流动力学响应函数,收敛性很容易被这一层非线性模糊掉。
解决:动手前先问自己两个问题。第一,你能不能写出可信的状态方程、观测方程和先验分布?能,选 DCM。第二,你的目标是解释实验任务引起的定向影响,还是发现观测变量之间是否存在非线性动力学耦合?前者选 DCM,后者选 CCM。如果你只有观测序列、没有可假设的生成模型,就别硬上 DCM。做神经影像但只想做探索性分析时,把 CCM 结果称作「动力学耦合证据」,不要加上「有效连接」这种暗示假设机制的词,避免在术语层面被审稿人挑出问题。
6. 滚动窗口 CCM:追踪因果强度随时间的变化
6.1 静态 CCM 的盲区与滚动窗口的思路
前面所有方案输出的都是一个时间区间内的平均因果强度。但实际场景里,气候系统中的耦合强度会随季节变化,金融市场中的领先滞后关系会随政策发布而突变,生态系统中的种间关系也会随环境变化。静态 CCM 处理不了这类时变因果,滚动窗口 CCM 补上这个缺口:把时间序列切成重叠的窗口,在每一个窗口内单独计算双向收敛末值,得到因果强度随时间变化的曲线。
实现时控制两个参数:窗口长度 W 和步长 s。窗口太小,流形太稀疏,收敛性无法保证;步长太大,时间分辨率丢失。工程上 W 至少要覆盖系统的特征周期的 5 到 10 倍,同时不低于 200 个点,否则第 5 章的短样本问题会直接复现。
def rolling_ccm(x, y, W=400, step=50, E=3, tau=1, L_ratio=0.8, repeats=10): ts = [] for start in range(0, len(x) - W, step): wx = x[start:start + W] wy = y[start:start + W] # 在窗口内建立双向收敛曲线,取 L_ratio 对应节点的均值 curve = convergence_curve(wx, wy, E, tau, L_ratios=[L_ratio], repeats=repeats) r_x_to_y = curve[0][1] r_y_to_x = curve[0][3] ts.append((start, r_x_to_y, r_y_to_x)) return np.array(ts)6.2 实现细节与解读红线
解读滚动窗口结果时,有两条红线。第一,因果强度随时间上升,不一定说明耦合增强,也可能是窗口内数据变平稳、噪声降低。所以在每个窗口里要同时记录噪声水平或残差标准差,不然你看到的可能是信噪比变化而不是机制变化。第二,窗口间的因果方向有可能「翻转」,这种翻转往往是窗口太短或数据非平稳造成的假象,不要急着解释成机制反转。我的习惯是对每个窗口都跑一遍替代数据检验,替代检验不过的窗口直接置为不显著,这样画出来的时序图才敢给别人看。
滚动窗口 CCM 是我在气候指数和经济时间序列上使用频率最高的进阶变体。它的好处是给出因果强度随时间演化的动态视角,坏处是参数从三个变成五个,每个窗口还可能给出不同结论,解释难度成倍上升。
最后分享一个个人习惯:跑 CCM 之前,我会把 E、τ、库长区间、替代检验阈值、窗口参数全部写进一个 yaml 配置文件;每换一批数据,先花十分钟跑替代检验,不通过的一切后续分析直接停止。这样做的好处是三个月后翻出结果还能复现,不至于对着一个不知参数的 ρ=0.6 发呆。希望帮到你。
本文还有配套的精品资源,点击获取