从零构建路径签名:用Python打造你的可视化计算与洞察引擎

如果你曾经盯着一段股票价格曲线、一段手写笔迹的轨迹,或者任何在时间维度上蜿蜒前行的数据,思考过如何用一个数学上“优雅”且计算上“高效”的特征来概括它的全部精髓,那么“路径签名”这个概念,很可能就是你一直在寻找的答案。它不像傅里叶变换那样关注频率,也不像简单统计量那样丢失了顺序信息。路径签名所做的,是捕捉路径的几何形状时间演化顺序本身,将其编码成一个结构化的、层次化的特征序列。听起来很抽象?别担心,我们今天的目标就是亲手拆解它,用Python从零开始,构建一个不仅能计算、更能可视化每一步过程的签名计算器,让你直观地感受一阶位移、二阶面积乃至更高阶特征背后的几何意义。

想象一下,你手里有两段股价走势,一段是平滑的上升趋势,另一段是剧烈波动的布朗运动。传统的均值、方差可能相差无几,但它们的“形状”天差地别。路径签名能精确地量化这种形状差异,并将其转化为机器学习模型可以直接“消化”的固定长度向量。这不仅仅是理论上的优美,更在金融时间序列分析、手写识别、动作识别乃至医疗信号处理等领域展现了强大的实用性。本文面向的,正是那些有编程基础、对数学概念有好奇心,但可能被“迭代积分”、“张量代数”等术语吓退的开发者。我们将避开繁复的公式推导,用代码和动态图表作为向导,一步步走进路径签名的世界。

为了让体验更沉浸,我强烈建议你打开一个Jupyter Notebook环境(比如Google Colab),跟着文中的代码块一起动手。我们将使用numpy进行核心计算,用matplotlib实现动态可视化,并利用ipywidgets创建交互式滑块,让你实时调整路径参数,亲眼观察签名如何随之变化。准备好了吗?让我们开始这段从几何直觉到代码实现的旅程。

1. 核心概念:路径签名究竟是什么?

在深入代码之前,我们需要建立一些最基础的直觉。所谓“路径”,在这里可以简单理解为一个从时间区间 [a, b] 映射到 d 维空间 R^d 的连续函数。比如,一个二维平面上的笔画轨迹(d=2),或者一个包含温度、湿度、气压的三维气象数据流(d=3)。路径签名,就是这个路径的一个“指纹”或“DNA序列”。

1.1 从一阶签名开始:捕捉位移

最直观的签名是一阶签名。对于一个 d 维路径 X(t) = (X^1(t), X^2(t), ..., X^d(t)),它的一阶签名有 d 个分量,每个分量就是路径在该坐标轴上的净位移

import numpy as np

def first_order_signature(path):
    """
    计算路径的一阶签名(位移)。
    参数:
        path: 一个形状为 (N, d) 的numpy数组,表示有N个时间点的d维路径。
    返回:
        一个长度为d的一维数组,表示每个维度上的位移。
    """
    # 位移 = 终点坐标 - 起点坐标
    displacement = path[-1] - path[0]
    return displacement

看,这很简单。对于一条从 (0, 0) 画到 (1, 2) 的直线,它的一阶签名就是 [1, 2]。一阶签名丢弃了路径中间所有的曲折过程,只记住了起点和终点。这显然丢失了大量信息。

1.2 进入二阶签名:揭示面积与“旋转”

二阶签名开始变得有趣。它有 d×d 个分量,通常记为 S^{i,j},其中 ij 是维度索引(从1到d)。它的几何意义与有符号面积密切相关。

考虑一个二维路径。S^{1,2} 近似于路径在 (X^1, X^2) 平面上投影所扫过的有符号面积(更准确地说,是Levy面积)。如果路径是顺时针绕圈,这个面积是负的;逆时针则是正的。而 S^{2,1} 则与 S^{1,2} 存在一个简单的关系:S^{1,2} + S^{2,1} = (位移^1) * (位移^2)。这意味着在二维中,独立的二阶信息其实只有一个(比如 S^{1,2}),另一个可以通过位移计算出来。

提示:这个关系是“Shuffle积”恒等式在二阶情况下的特例,它是路径签名代数结构的基础之一,保证了签名分量之间并非完全独立。

如何计算这个面积?对于一条由线段组成的路径(这是我们数值计算的基础),二阶签名可以通过累加每个小线段与坐标轴围成的梯形面积来近似。

def second_order_signature(path):
    """
    计算路径的二阶签名(面积项)。
    参数:
        path: 一个形状为 (N, d) 的numpy数组。
    返回:
        一个形状为 (d, d) 的二维数组,S[i-1, j-1] 对应 S^{i,j}。
    """
    N, d = path.shape
    S2 = np.zeros((d, d))
    
    # 数值积分近似:S^{i,j} ≈ Σ [ (X^i_{k} - X^i_{0}) * (ΔX^j_{k}) ]
    # 其中 ΔX^j_{k} = X^j_{k+1} - X^j_{k}
    for i in range(d):
        for j in range(d):
            integral = 0.0
            for k in range(N-1):
                # 从起点到当前点k在一维i上的累积位移
                increment_i = path[k, i] - path[0, i]
                # 在维度j上的微小增量
                delta_j = path[k+1, j] - path[k, j]
                integral += increment_i * delta_j
            S2[i, j] = integral
    return S2

让我们用一个经典例子来感受一下:单位圆。我们将圆离散成很多个点。

import matplotlib.pyplot as plt

# 生成一个逆时针的单位圆路径
theta = np.linspace(0, 2*np.pi, 100)  # 100个点
circle_path = np.column_stack([np.cos(theta), np.sin(theta)])  # 形状 (100, 2)

# 计算签名
S1_circle = first_order_signature(circle_path)
S2_circle = second_order_signature(circle_path)

print("一阶签名 (位移):", S1_circle)  # 应接近 [0, 0],因为起点终点重合
print("二阶签名 S^{1,2}:", S2_circle[0, 1])  # 应接近 π (圆的面积)
print("二阶签名 S^{2,1}:", S2_circle[1, 0])  # 应接近 -π
print("验证 S^{1,2} + S^{2,1} = ΔX^1 * ΔX^2:", 
      S2_circle[0, 1] + S2_circle[1, 0], "≈", S1_circle[0] * S1_circle[1])

运行这段代码,你会发现 S^{1,2} 接近 π(约3.1416),而 S^{2,1} 接近 。它们的和是0,正好等于位移的乘积(因为位移都是0)。这个 π 就是单位圆的面积!二阶签名成功地捕捉到了路径的“旋转”信息,即使净位移为零。

1.3 高阶签名与截断

理论上,签名可以无限延伸至三阶、四阶……第 n 阶签名有 d^n 个分量,捕捉路径中越来越精细的“摆动”和“缠绕”模式。例如,三阶签名与路径的“扭力”或“非平面性”有关。然而,维数灾难随之而来:对于一个 d=5 的路径,计算到5阶签名,分量总数将超过3000个。

因此,实践中我们总是使用截断签名,即只取前 m 阶的所有分量。一个 m 阶截断签名的总维度是: 总维度 = 1 + d + d^2 + ... + d^m 其中第一项的“1”代表零阶签名,它被约定为标量1。截断签名构成了一个固定长度的特征向量,非常适合作为机器学习模型的输入。

2. 构建交互式签名计算与可视化工具

理解了核心概念后,我们开始搭建一个可以玩起来的工具。我们将创建一个函数,能够绘制路径、实时计算并展示其各阶签名的数值和几何意义。

2.1 核心计算函数

首先,我们需要一个更通用、更高效的计算截断签名的函数。我们将实现一个基于迭代的算法。

def compute_truncated_signature(path, order=2):
    """
    计算路径的截断签名(直到指定阶数)。
    使用迭代算法,效率高于多重循环。
    参数:
        path: (N, d) 数组。
        order: 截断阶数。
    返回:
        sig_dict: 一个字典,键为表示阶数和维度的元组,值为签名值。
        例如,sig_dict[(1,)] 是一阶签名的numpy数组,
        sig_dict[(1,2)] 是 S^{1,2}。
    """
    N, d = path.shape
    # 初始化签名字典,包含零阶签名
    sig_dict = {(): 1.0}
    
    # 计算一阶签名(位移)
    for i in range(d):
        sig_dict[(i+1,)] = path[-1, i] - path[0, i]
    
    # 如果只需要一阶,就此返回
    if order == 1:
        return sig_dict
    
    # 准备增量:ΔX^k_j = X^k_{j+1} - X^k_j
    increments = np.diff(path, axis=0)  # 形状 (N-1, d)
    
    # 计算二阶及更高阶签名
    # 我们使用动态规划思想:S^{I}_{0,t} 的增量更新
    # 其中 I 是一个多重索引,如 (1,2,1)
    # 维护一个列表,其第k个元素是当前所有k阶签名的字典
    prev_sigs = [{} for _ in range(order+1)]
    # 初始化:0阶签名在任意时刻都是1
    prev_sigs[0][()] = np.ones(N)
    
    # 对于每个维度i,初始化一阶签名随时间的变化
    for i in range(d):
        # 计算累积位移:S^{i}_{0,t}
        cum_displacement = np.cumsum(np.insert(increments[:, i], 0, 0))
        # 注意:在时间0处,位移为0,所以插入0
        prev_sigs[1][(i+1,)] = cum_displacement
    
    # 迭代计算更高阶签名
    for k in range(2, order+1):
        current_sigs = {}
        # 生成所有可能的k阶索引
        # 简单方法:遍历所有k-1阶索引,并附加一个维度
        for idx in prev_sigs[k-1].keys():
            sig_series = prev_sigs[k-1][idx]  # 这是一个长度为N的时间序列
            for j in range(d):
                new_idx = idx + (j+1,)
                # 计算迭代积分: S^{new_idx}_{0,t} = ∫_0^t S^{idx}_{0,s} dX^j_s
                # 数值积分:使用梯形法则或简单的累加
                integral = np.zeros(N)
                for t in range(1, N):
                    # 增量dX^j在区间[t-1, t]上近似为 increments[t-1, j]
                    # 用S^{idx}在t-1时刻的值乘以增量
                    integral[t] = integral[t-1] + sig_series[t-1] * increments[t-1, j]
                current_sigs[new_idx] = integral
        prev_sigs[k] = current_sigs
    
    # 从prev_sigs中提取最终时刻(t=N-1)的签名值,存入sig_dict
    for k in range(2, order+1):
        for idx, series in prev_sigs[k].items():
            sig_dict[idx] = series[-1]  # 取最后一个值,即从0到T的积分
    
    return sig_dict

这个函数返回一个字典,方便我们查询任意阶的签名分量。现在,让我们用它来验证一下圆的签名。

# 重新计算圆的签名,直到2阶
sig_circle = compute_truncated_signature(circle_path, order=2)
print("零阶签名(约定):", sig_circle[()])
print("一阶签名 S1:", sig_circle[(1,)])  # 应接近0
print("一阶签名 S2:", sig_circle[(2,)])  # 应接近0
print("二阶签名 S^{1,2}:", sig_circle[(1, 2)])  # 应接近 π
print("二阶签名 S^{1,1}:", sig_circle[(1, 1)])  # 与路径在维度1上的“能量”有关

2.2 可视化:让签名“活”起来

静态的数字不够直观。我们将创建一个动态绘图函数,分步展示路径的生成和签名分量的累积过程。

def visualize_signature_accumulation(path, sig_dict, order=2):
    """
    动态展示路径及其签名分量的累积过程。
    """
    N, d = path.shape
    if d != 2:
        print("可视化目前仅支持二维路径。")
        return
    
    fig, axes = plt.subplots(2, 3, figsize=(15, 10))
    fig.suptitle('路径签名计算过程可视化', fontsize=16)
    
    # 子图1: 原始路径
    ax1 = axes[0, 0]
    ax1.set_title('原始二维路径')
    ax1.set_xlabel('X^1')
    ax1.set_ylabel('X^2')
    ax1.grid(True, alpha=0.3)
    ax1.set_aspect('equal', adjustable='datalim')
    
    # 子图2: 一阶签名 S^1 随时间变化
    ax2 = axes[0, 1]
    ax2.set_title('一阶签名 S^1(t)')
    ax2.set_xlabel('时间点')
    ax2.set_ylabel('S^1')
    ax2.grid(True, alpha=0.3)
    
    # 子图3: 一阶签名 S^2 随时间变化
    ax3 = axes[0, 2]
    ax3.set_title('一阶签名 S^2(t)')
    ax3.set_xlabel('时间点')
    ax3.set_ylabel('S^2')
    ax3.grid(True, alpha=0.3)
    
    # 子图4: 二阶签名 S^{1,2} 随时间变化
    ax4 = axes[1, 0]
    ax4.set_title('二阶签名 S^{1,2}(t)')
    ax4.set_xlabel('时间点')
    ax4.set_ylabel('S^{1,2}')
    ax4.grid(True, alpha=0.3)
    
    # 子图5: 二阶签名 S^{2,1} 随时间变化
    ax5 = axes[1, 1]
    ax5.set_title('二阶签名 S^{2,1}(t)')
    ax5.set_xlabel('时间点')
    ax5.set_ylabel('S^{2,1}')
    ax5.grid(True, alpha=0.3)
    
    # 子图6: 签名分量关系 (S^{1,2} vs S^{2,1})
    ax6 = axes[1, 2]
    ax6.set_title('S^{1,2} 与 S^{2,1} 的关系')
    ax6.set_xlabel('S^{1,2}')
    ax6.set_ylabel('S^{2,1}')
    ax6.grid(True, alpha=0.3)
    
    # 为了动态绘制,我们需要计算签名随时间变化的序列
    # 这里我们复用之前compute_truncated_signature函数的思想,但记录全过程
    # 为了简化,我们直接调用一个能返回时间序列的版本(假设已实现)
    # 下面我们用之前实现的算法思路进行模拟计算
    time_points = np.arange(N)
    S1_1_series = np.cumsum(np.insert(np.diff(path[:, 0]), 0, 0))
    S1_2_series = np.cumsum(np.insert(np.diff(path[:, 1]), 0, 0))
    
    # 计算二阶签名的时间序列(简化计算,使用之前定义的second_order_signature的思路,但逐点计算)
    S2_12_series = np.zeros(N)
    S2_21_series = np.zeros(N)
    integral_12 = 0.0
    integral_21 = 0.0
    for t in range(1, N):
        delta_x = path[t, 0] - path[t-1, 0]
        delta_y = path[t, 1] - path[t-1, 1]
        # 更新二阶积分
        integral_12 += (path[t-1, 0] - path[0, 0]) * delta_y
        integral_21 += (path[t-1, 1] - path[0, 1]) * delta_x
        S2_12_series[t] = integral_12
        S2_21_series[t] = integral_21
    
    # 开始动态绘制
    from matplotlib.animation import FuncAnimation
    from IPython.display import HTML
    
    line_path, = ax1.plot([], [], 'b-', lw=2, alpha=0.7)
    scatter_path, = ax1.plot([], [], 'ro', markersize=4)
    
    line_s1_1, = ax2.plot([], [], 'g-', lw=2)
    line_s1_2, = ax3.plot([], [], 'r-', lw=2)
    line_s2_12, = ax4.plot([], [], 'c-', lw=2)
    line_s2_21, = ax5.plot([], [], 'm-', lw=2)
    scatter_s2, = ax6.plot([], [], 'ko', markersize=3)
    
    def init():
        ax1.set_xlim(path[:, 0].min() - 0.5, path[:, 0].max() + 0.5)
        ax1.set_ylim(path[:, 1].min() - 0.5, path[:, 1].max() + 0.5)
        ax2.set_xlim(0, N)
        ax2.set_ylim(S1_1_series.min() - 0.5, S1_1_series.max() + 0.5)
        ax3.set_xlim(0, N)
        ax3.set_ylim(S1_2_series.min() - 0.5, S1_2_series.max() + 0.5)
        ax4.set_xlim(0, N)
        ax4.set_ylim(S2_12_series.min() - 0.5, S2_12_series.max() + 0.5)
        ax5.set_xlim(0, N)
        ax5.set_ylim(S2_21_series.min() - 0.5, S2_21_series.max() + 0.5)
        ax6.set_xlim(S2_12_series.min() - 0.5, S2_12_series.max() + 0.5)
        ax6.set_ylim(S2_21_series.min() - 0.5, S2_21_series.max() + 0.5)
        return (line_path, scatter_path, line_s1_1, line_s1_2, line_s2_12, line_s2_21, scatter_s2)
    
    def update(frame):
        # 更新路径图
        line_path.set_data(path[:frame+1, 0], path[:frame+1, 1])
        scatter_path.set_data([path[frame, 0]], [path[frame, 1]])
        
        # 更新一阶签名图
        line_s1_1.set_data(time_points[:frame+1], S1_1_series[:frame+1])
        line_s1_2.set_data(time_points[:frame+1], S1_2_series[:frame+1])
        
        # 更新二阶签名图
        line_s2_12.set_data(time_points[:frame+1], S2_12_series[:frame+1])
        line_s2_21.set_data(time_points[:frame+1], S2_21_series[:frame+1])
        
        # 更新关系图
        scatter_s2.set_data(S2_12_series[:frame+1], S2_21_series[:frame+1])
        
        return (line_path, scatter_path, line_s1_1, line_s1_2, line_s2_12, line_s2_21, scatter_s2)
    
    ani = FuncAnimation(fig, update, frames=N, init_func=init, blit=True, interval=50, repeat=False)
    plt.tight_layout()
    plt.close(fig)  # 防止在notebook中显示静态图
    return HTML(ani.to_jshtml())

现在,让我们对单位圆路径运行这个可视化函数。

# 生成更平滑的圆路径用于演示
theta_smooth = np.linspace(0, 2*np.pi, 50)
circle_smooth = np.column_stack([np.cos(theta_smooth), np.sin(theta_smooth)])

# 计算签名
sig_circle_smooth = compute_truncated_signature(circle_smooth, order=2)

# 生成动画 (在Jupyter Notebook中运行)
# html_anim = visualize_signature_accumulation(circle_smooth, sig_circle_smooth)
# display(html_anim)

运行这段代码(在支持动画的Notebook环境中),你会看到一个动态过程:红点沿着蓝色圆轨迹移动,同时四个图表分别展示两个一阶签名和两个二阶签名如何随时间累积。最终,S^{1,2} 趋向于 πS^{2,1} 趋向于 ,而它们的关系图会沿着对角线 y = -x 移动(因为 S^{1,2} + S^{2,1} = 0)。

2.3 交互式探索:对比不同路径

静态的圆固然经典,但签名的威力在于对比不同类型的路径。我们将创建一个交互式控件,允许你生成并对比几种典型路径:直线、圆、布朗运动(随机游走)和利萨如图形。

首先,定义路径生成器。

def generate_line_path(N=100):
    """生成一条二维直线路径"""
    t = np.linspace(0, 1, N)
    x = t
    y = 2 * t  # 斜率为2
    return np.column_stack([x, y])

def generate_circle_path(N=100):
    """生成单位圆路径"""
    theta = np.linspace(0, 2*np.pi, N)
    x = np.cos(theta)
    y = np.sin(theta)
    return np.column_stack([x, y])

def generate_brownian_path(N=100, seed=42):
    """生成二维布朗运动(随机游走)路径"""
    np.random.seed(seed)
    steps = np.random.randn(N-1, 2) * 0.1  # 步长标准差为0.1
    path = np.cumsum(steps, axis=0)
    # 从原点开始
    path = np.vstack([np.zeros(2), path])
    return path

def generate_lissajous_path(N=100, a=3, b=2, delta=np.pi/2):
    """生成利萨如图形路径"""
    t = np.linspace(0, 2*np.pi, N)
    x = np.sin(a * t + delta)
    y = np.sin(b * t)
    return np.column_stack([x, y])

接下来,我们创建一个对比函数,一次性计算并展示多种路径的签名。

def compare_paths_and_signatures(path_generators, names, order=2):
    """
    对比多种路径及其签名。
    参数:
        path_generators: 路径生成函数列表。
        names: 对应的路径名称列表。
        order: 计算的签名阶数。
    """
    num_paths = len(path_generators)
    fig, axes = plt.subplots(num_paths, 3, figsize=(15, 4*num_paths))
    if num_paths == 1:
        axes = axes.reshape(1, -1)
    
    for idx, (gen_func, name) in enumerate(zip(path_generators, names)):
        path = gen_func()
        sig = compute_truncated_signature(path, order=order)
        
        # 子图1: 路径本身
        ax1 = axes[idx, 0]
        ax1.plot(path[:, 0], path[:, 1], 'b-', lw=2, alpha=0.7)
        ax1.scatter(path[0, 0], path[0, 1], color='green', s=100, zorder=5, label='Start')
        ax1.scatter(path[-1, 0], path[-1, 1], color='red', s=100, zorder=5, label='End')
        ax1.set_title(f'{name} 路径')
        ax1.set_xlabel('X^1')
        ax1.set_ylabel('X^2')
        ax1.legend()
        ax1.grid(True, alpha=0.3)
        ax1.set_aspect('equal', adjustable='datalim')
        
        # 子图2: 一阶签名(位移)的条形图
        ax2 = axes[idx, 1]
        if order >= 1:
            S1_values = [sig.get((i+1,), 0) for i in range(path.shape[1])]
            bars = ax2.bar(range(1, len(S1_values)+1), S1_values, color=['skyblue', 'lightcoral'])
            ax2.set_title(f'{name} - 一阶签名 (位移)')
            ax2.set_xlabel('维度')
            ax2.set_ylabel('位移值')
            ax2.set_xticks(range(1, len(S1_values)+1))
            # 在柱子上标注数值
            for bar, val in zip(bars, S1_values):
                height = bar.get_height()
                ax2.text(bar.get_x() + bar.get_width()/2., height,
                         f'{val:.3f}', ha='center', va='bottom' if height >=0 else 'top')
        else:
            ax2.text(0.5, 0.5, '未计算一阶签名', ha='center', va='center', transform=ax2.transAxes)
            ax2.set_title(f'{name} - 一阶签名')
        
        # 子图3: 二阶签名的热图(如果维度为2)
        ax3 = axes[idx, 2]
        if order >= 2 and path.shape[1] == 2:
            # 构建二阶签名矩阵
            S2_matrix = np.zeros((2, 2))
            for i in range(2):
                for j in range(2):
                    S2_matrix[i, j] = sig.get((i+1, j+1), 0)
            im = ax3.imshow(S2_matrix, cmap='RdBu', vmin=-abs(S2_matrix).max(), vmax=abs(S2_matrix).max())
            ax3.set_title(f'{name} - 二阶签名矩阵')
            ax3.set_xticks([0, 1])
            ax3.set_yticks([0, 1])
            ax3.set_xticklabels(['dim1', 'dim2'])
            ax3.set_yticklabels(['dim1', 'dim2'])
            # 添加数值文本
            for i in range(2):
                for j in range(2):
                    text = ax3.text(j, i, f'{S2_matrix[i, j]:.3f}',
                                   ha="center", va="center", color="black", fontweight='bold')
            plt.colorbar(im, ax=ax3)
        else:
            ax3.text(0.5, 0.5, '无二阶签名或维度不为2', ha='center', va='center', transform=ax3.transAxes)
            ax3.set_title(f'{name} - 二阶签名')
    
    plt.tight_layout()
    plt.show()
    
    # 在控制台打印关键的签名值对比
    print("\n" + "="*60)
    print("路径签名对比摘要 (阶数={})".format(order))
    print("="*60)
    header = f"{'路径名称':<15} {'S1^1':<10} {'S1^2':<10} {'S2^{1,2}':<12} {'S2^{2,1}':<12} {'S2^{1,1}':<12} {'S2^{2,2}':<12}"
    print(header)
    print("-"*len(header))
    for gen_func, name in zip(path_generators, names):
        path = gen_func()
        sig = compute_truncated_signature(path, order=order)
        S1_1 = sig.get((1,), 0)
        S1_2 = sig.get((2,), 0) if path.shape[1] > 1 else 0
        S2_12 = sig.get((1,2), 0) if path.shape[1] > 1 else 0
        S2_21 = sig.get((2,1), 0) if path.shape[1] > 1 else 0
        S2_11 = sig.get((1,1), 0)
        S2_22 = sig.get((2,2), 0) if path.shape[1] > 1 else 0
        print(f"{name:<15} {S1_1:<10.3f} {S1_2:<10.3f} {S2_12:<12.3f} {S2_21:<12.3f} {S2_11:<12.3f} {S2_22:<12.3f}")

现在,让我们运行对比。

# 定义要对比的路径
generators = [generate_line_path, generate_circle_path, generate_brownian_path, generate_lissajous_path]
names = ['直线', '单位圆', '布朗运动', '利萨如图 (a=3,b=2)']

compare_paths_and_signatures(generators, names, order=2)

观察输出,你会立刻发现不同路径的签名特征:

路径类型 一阶签名 (位移) 二阶签名 S^{1,2} 关键洞察
直线 (1, 2) 接近 0 没有包围面积,所以二阶面积项很小。S^{1,1} 和 S^{2,2} 可能非零,反映了路径在各自坐标轴上的“能量”。
单位圆 (0, 0) 接近 π 净位移为零,但二阶签名清晰地揭示了旋转方向和面积大小。S^{1,2} ≈ -S^{2,1}。
布朗运动 随机值 随机值 一阶和二阶签名都是随机的,但它们的统计特性(如分布)可能在不同类型的随机过程中保持稳定。
利萨如图 (0, 0) 特定值 复杂的周期性轨迹产生特定的面积值,签名能捕捉其独特的几何模式。

这个对比清晰地展示了路径签名如何成为路径形状的“指纹”。即使位移相同(比如都是零),不同的形状也会产生截然不同的二阶签名。

3. 签名在机器学习中的应用模式与实战技巧

理解了如何计算和可视化签名后,一个自然的问题是:这玩意儿到底怎么用?尤其是在机器学习中。路径签名的核心优势在于它将变长、不规则采样的序列数据,转化为固定长度、顺序敏感的特征向量。这使得它可以无缝接入标准的机器学习管道(如SVM、随机森林甚至神经网络)。

3.1 基础特征工程流程

假设你有一组时间序列数据,每条序列长度不一。使用路径签名作为特征的基本流程如下:

  1. 路径嵌入:将原始时间序列转化为路径。最简单的方式是直接将时间作为一维,观测值作为另一维(对于单变量序列),或者将多个观测通道直接作为路径的各个维度。但通常,我们会进行一些变换来增强特征。
  2. 计算截断签名:选择一个合适的截断阶数 m(通常2到5阶就足够了),计算每条路径的截断签名。
  3. 特征向量化:将签名字典展开成一个一维向量。注意,由于Shuffle关系,有些分量是冗余的。实践中,我们常使用对数签名(Log Signature),它能进一步减少维度并具有更好的代数性质,但计算稍复杂。作为入门,我们可以先使用完整的截断签名。
  4. 送入模型:将得到的特征向量用于分类、回归或聚类任务。

让我们用一个简单的模拟例子来演示:区分正弦波和方波。

from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report
import warnings
warnings.filterwarnings('ignore')

def generate_sine_wave(N=100, freq=1, noise=0.05):
    """生成带噪声的正弦波路径"""
    t = np.linspace(0, 2*np.pi, N)
    # 路径:时间t作为第一维,正弦值作为第二维
    x = t / (2*np.pi)  # 归一化时间到[0,1]
    y = np.sin(freq * t) + noise * np.random.randn(N)
    return np.column_stack([x, y])

def generate_square_wave(N=100, freq=1, noise=0.05):
    """生成带噪声的方波路径"""
    t = np.linspace(0, 2*np.pi, N)
    x = t / (2*np.pi)
    # 生成方波
    y = np.sign(np.sin(freq * t)) + noise * np.random.randn(N)
    return np.column_stack([x, y])

def path_to_signature_feature(path, order=2):
    """将一条路径转换为其截断签名特征向量(简化版,仅用于演示)"""
    sig = compute_truncated_signature(path, order=order)
    # 将签名字典展平为向量,按固定顺序(例如先一阶,再二阶...)
    feature = []
    # 零阶签名(总是1,通常省略,因为它不提供区分信息)
    # feature.append(sig[()])
    
    # 一阶签名
    d = path.shape[1]
    for i in range(1, d+1):
        feature.append(sig.get((i,), 0))
    
    # 二阶签名
    if order >= 2:
        for i in range(1, d+1):
            for j in range(1, d+1):
                feature.append(sig.get((i, j), 0))
    # 更高阶类似...
    return np.array(feature)

# 生成数据集
num_samples_per_class = 200
X = []
y = []
for _ in range(num_samples_per_class):
    # 正弦波类,标签0
    path_sine = generate_sine_wave(N=np.random.randint(80, 120), freq=np.random.uniform(0.8, 1.2))
    feat_sine = path_to_signature_feature(path_sine, order=2)
    X.append(feat_sine)
    y.append(0)
    
    # 方波类,标签1
    path_square = generate_square_wave(N=np.random.randint(80, 120), freq=np.random.uniform(0.8, 1.2))
    feat_square = path_to_signature_feature(path_square, order=2)
    X.append(feat_square)
    y.append(1)

X = np.array(X)
y = np.array(y)

# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

# 训练一个简单的分类器
clf = RandomForestClassifier(n_estimators=100, random_state=42)
clf.fit(X_train, y_train)

# 预测并评估
y_pred = clf.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"测试集准确率: {accuracy:.4f}")
print("\n分类报告:")
print(classification_report(y_test, y_pred, target_names=['正弦波', '方波']))

# 查看特征重要性(如果特征维度不高)
if X.shape[1] <= 10:
    print("\n特征重要性(对应签名分量):")
    for i, imp in enumerate(clf.feature_importances_):
        print(f"  特征 {i}: {imp:.4f}")

在这个简单的例子中,仅使用到二阶的签名(对于二维路径,共2个一阶 + 4个二阶 = 6个特征),随机森林就能达到很高的分类准确率。这证明了签名特征的有效性。

3.2 提升表现的关键技巧:路径变换与增强

直接对原始时间序列路径计算签名可能不是最优的。研究社区发展出多种路径变换技术,旨在在计算签名前,将原始路径映射到另一个空间,以提取更丰富或更稳健的特征。以下是一些常用技巧:

  • 时间嵌入:将时间本身作为一个独立的维度加入路径。这对于非均匀采样的数据尤为重要。
  • 增量变换:考虑路径的增量 ΔX_t = X_t - X_{t-1} 构成的路径。这能聚焦于局部变化。
  • 累积和变换:对于取值非负的序列(如笔划的累积长度),这很有效。
  • Lead-Lag变换:这是签名应用中一个非常强大的技巧。对于一维序列 (x_t),我们构造一个二维路径 (x_t, x_{t-1})。这能将时间依赖性显式地编码到路径的几何中,从而让签名捕捉到自相关等动态特性。

让我们实现一个Lead-Lag变换,并看看它如何影响签名。

def lead_lag_transform(one_dim_series):
    """
    对一维时间序列应用lead-lag变换,生成二维路径。
    参数:
        one_dim_series: 一维数组,形状 (N,)
    返回:
        二维数组,形状 (N, 2),其中第一列是x_t,第二列是x_{t-1}(起始点用0或第一个值填充)。
    """
    N = len(one_dim_series)
    path_2d = np.zeros((N, 2))
    path_2d[:, 0] = one_dim_series
    path_2d[1:, 1] = one_dim_series[:-1]  # lag
    path_2d[0, 1] = one_dim_series[0]  # 或 0,取决于定义
    return path_2d

# 示例:对比原始一维正弦波和其Lead-Lag变换后的签名
t = np.linspace(0, 4*np.pi, 50)
sine_1d = np.sin(t)
# 原始路径:时间 vs 正弦值
path_original = np.column_stack([t/t.max(), sine_1d])  # 时间归一化
path_leadlag = lead_lag_transform(sine_1d)

sig_orig = compute_truncated_signature(path_original, order=2)
sig_ll = compute_truncated_signature(path_leadlag, order=2)

print("原始路径(时间-正弦值)的签名(部分):")
print(f"  一阶: S1 = ({sig_orig.get((1,), 0):.3f}, {sig_orig.get((2,), 0):.3f})")
print(f"  二阶 S^{{1,2}}: {sig_orig.get((1,2), 0):.3f}")
print(f"  二阶 S^{{2,1}}: {sig_orig.get((2,1), 0):.3f}")
print("\nLead-Lag变换后路径的签名(部分):")
print(f"  一阶: S1 = ({sig_ll.get((1,), 0):.3f}, {sig_ll.get((2,), 0):.3f})")
print(f"  二阶 S^{{1,2}}: {sig_ll.get((1,2), 0):.3f}")
print(f"  二阶 S^{{2,1}}: {sig_ll.get((2,1), 0):.3f}")

你会发现,Lead-Lag变换后的路径,其签名包含了原始序列与其滞后版本之间相互作用的信息,这对于捕捉时间序列的动态模式(如动量、均值回归)非常有用。

3.3 处理实际数据的注意事项

在实际项目中应用路径签名时,有几个坑需要注意:

  1. 时间重参数化不变性:签名的一个关键数学性质是,它不依赖于路径的速度,只依赖于路径的形状。这意味着如果你对时间轴进行单调变换(比如把数据点挤在一起或拉开),签名保持不变。这既是优点(对采样不规则性鲁棒),也可能是缺点(如果你关心速度信息)。解决方案是显式地将时间作为一个维度加入路径。
  2. 数值稳定性与精度:高阶迭代积分可能对数值误差敏感,尤其是当路径变化剧烈时。使用双精度浮点数,并考虑使用专门的库(如 esigiisignature)进行高精度计算。
  3. 维度灾难:如前所述,签名维度随路径维度和截断阶数指数增长。对于高维数据(如视频关节点序列),直接计算完整签名不现实。此时需要:
    • 使用对数签名,它能将维度从 O(d^m) 减少到 O(d^m / m!) 量级。
    • 进行特征选择,只保留信息量最大的签名分量。
    • 采用分层或分块计算,先对路径的局部片段计算签名,再聚合。
  4. 缺失值处理:签名理论要求路径是连续的。对于有缺失值的时间序列,需要进行合理的插值(如线性插值)后再计算签名。

4. 超越二维:高维路径与实战项目构思

我们之前的例子都集中在二维路径上,以便可视化。但路径签名的真正威力在于处理高维数据流。例如:

  • 金融:一只股票的多因子时间序列(动量、波动率、成交量等)可以构成一个高维路径。
  • 动作识别:人体骨架的关节点在3D空间中的运动轨迹。
  • 手写识别:笔尖的 (x, y, pressure, tilt) 序列。
  • 传感器网络:多个传感器的读数随时间变化。

计算高维路径的签名在原理上完全相同,只是张量的分量更多。让我们构思一个更接近真实世界的迷你项目:基于签名的手写数字分类简化版

我们将使用经典的MNIST数据集,但这里我们假设已经获得了笔划的轨迹数据(而不是像素图像)。实际上,MNIST有笔划轨迹的子集(如MNIST Sequential)。为了演示,我们模拟生成一些简单的笔划轨迹。

def simulate_digit_stroke(digit):
    """
    模拟手写数字的笔划轨迹(极度简化版,仅用于演示概念)。
    返回一个二维路径数组 (N, 2)。
    """
    np.random.seed(digit)  # 用数字作为随机种子,使生成可重复
    if digit == 0:
        # 画一个椭圆
        theta = np.linspace(0, 2*np.pi, 80)
        x = 1.5 * np.cos(theta) + np.random.randn(80)*0.03
        y = np.sin(theta) + np.random.randn(80)*0.03
    elif digit == 1:
        # 画一条竖线,顶部带一个小勾
        x = np.array([0, 0, 0.2])
        y = np.array([0, 1, 0.8])
        # 插值使路径更平滑
        from scipy.interpolate import interp1d
        t_orig = np.linspace(0, 1, len(x))
        t_new = np.linspace(0, 1, 60)
        f_x = interp1d(t_orig, x, kind='quadratic')
        f_y = interp1d(t_orig, y, kind='quadratic')
        x = f_x(t_new) + np.random.randn(60)*0.02
        y = f_y(t_new) + np.random.randn(60)*0.02
    elif digit == 2:
        # 画一个“2”字形
        t = np.linspace(0, 1, 70)
        x = np.cos(2*np.pi*t*0.8) * 0.7 + 0.5
        y = np.sin(2*np.pi*t) * 0.5 + 0.5
        # 打乱顺序以模拟书写笔顺
        # 此处简化,直接使用
    else:
        # 对于其他数字,生成随机游走作为占位符
        steps = np.cumsum(np.random.randn(50, 2) * 0.1, axis=0)
        x = steps[:, 0]
        y = steps[:, 1]
    
    path = np.column_stack([x, y])
    # 归一化到[-1,1]区间附近
    path = (path - path.mean(axis=0)) / (path.std(axis=0) + 1e-8)
    return path

def extract_signature_features_for_digits(digits_list, order=2, apply_leadlag=False):
    """为模拟的数字笔划提取签名特征"""
    features = []
    labels = []
    for digit in digits_list:
        for _ in range(30):  # 每个数字生成30个略有噪声的样本
            path = simulate_digit_stroke(digit)
            # 可选:应用Lead-Lag变换
            if apply_leadlag:
                # 注意:我们的路径已经是二维,我们需要对每个维度分别做Lead-Lag吗?
                # 一个常见做法是先将路径展平为一维序列(按时间交错x,y),再应用Lead-Lag。
                # 这里为了简化,我们只对x坐标做Lead-Lag,然后与y坐标合并成一个新路径。
                # 但这会改变维度。更标准的做法是使用“时间-增量”或“累积和”变换。
                # 我们跳过这个复杂性,仅作为可选功能演示。
                pass
            feat = path_to_signature_feature(path, order=order)
            features.append(feat)
            labels.append(digit)
    return np.array(features), np.array(labels)

# 生成一个简单的0和1的分类数据集
digits = [0, 1]
X_digits, y_digits = extract_signature_features_for_digits(digits, order=2, apply_leadlag=False)

print(f"特征矩阵形状: {X_digits.shape}")  # (样本数, 特征数)
print(f"标签分布: {np.bincount(y_digits)}")

# 快速训练一个分类器看看效果
from sklearn.svm import SVC
from sklearn.preprocessing import StandardScaler

X_train_d, X_test_d, y_train_d, y_test_d = train_test_split(X_digits, y_digits, test_size=0.3, random_state=42)

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train_d)
X_test_scaled = scaler.transform(X_test_d)

svm_clf = SVC(kernel='rbf', random_state=42)
svm_clf.fit(X_train_scaled, y_train_d)
y_pred_d = svm_clf.predict(X_test_scaled)
acc_d = accuracy_score(y_test_d, y_pred_d)
print(f"\n数字0 vs 1分类准确率 (使用二阶签名): {acc_d:.4f}")

这个极度简化的例子展示了如何将签名特征用于序列分类。在真实场景中,你需要:

  1. 获取真实的笔划轨迹数据。
  2. 可能需要进行重采样,使所有轨迹具有相同数量的点(或使用能够处理变长序列的模型)。
  3. 应用更丰富的路径变换(如增量、累积和、Lead-Lag)。
  4. 尝试更高的截断阶数(3或4阶)。
  5. 使用更强大的模型(如梯度提升树或神经网络)。

路径签名提供了一个强大且理论扎实的特征提取框架,尤其适用于那些顺序至关重要的数据。它不像RNN或Transformer那样是端到端的黑箱,而是给出了一个可解释的、基于几何的特征集。你可以清楚地知道,分类器做出决策时,是考虑了路径的位移(一阶签名)、面积(二阶签名)还是更复杂的缠绕模式(高阶签名)。

我在实际项目中处理传感器数据时,发现将原始信号与其一阶差分拼接成路径(即 (value, delta) 路径),再计算到三阶的签名,往往能稳定地提升树模型几个百分点的性能。这种将时间序列“几何化”的视角,为特征工程打开了一扇新的大门。当然,签名的计算成本需要权衡,对于超长序列,可能需要分段计算签名后再聚合。不过,有了esig这样的优化库,计算效率在大多数实际应用中已经不是瓶颈。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐