图神经网络:ST-GCN、AS-GCN、2S-AGCN、MS-G3D、CTR-GCN

图神经网络:GCN、ST-GCN、AS-GCN、2S-AGCN、MS-G3D、CTR-GCN

一、GCN:Semi-supervised Classification with Graph Convolutional Networks

1.1 符号

对于给定的无向图G=(V,E):

  • V中包含网络中所有的N个结点
  • E代表结点之间的边
  • 邻接矩阵A ,大小:[N,N]
  • 度矩阵D,大小:[N,N]
  • 结点的特征向量矩阵为X,大小:[N,C]在这里插入图片描述

1.2 如何得到结点的邻居特征

将A与X进行相乘
在这里插入图片描述
问题一:遗漏了所分析的结点本身的信息
A A A的基础上加上一个单位阵得到新的邻接矩阵   A ~   ~ \tilde A ~  A~ ,当λ的取值为1时,意味着结点本身特征的重要性与其邻居的重要性一样

  A ~ = A + λ I N   ~ \tilde{A}=A+\lambda I_N~  A~=A+λIN 

问题二:应该使用更好的方法(平均值函数、加权平均值函数)而非直接加和来处理邻居的特征向量

  • 当使用加和函数的时候,具有较大度值的结点会有很大的表示向量,而较低度的结点会有较小的聚合向量,这可能会导致梯度爆炸或梯度消失的问题。
  • 此外,神经网络对于输入数据的标度是非常敏感的,我们需要将这些向量进行标准化以消除潜在的问题。

使用   D ~ − 1 A ~ D ~ − 1 X   ~ \tilde{D}^{-1}\tilde{A}\tilde{D}^{-1}X~  D~1A~D~1X 进行标准化,这种标准化的策略所提供的是一种加权平均
在这里插入图片描述
如上图所示,要得到结点B的聚合特征时,会分配较大的权重给B(其自身,度为3),而给E(度为5)更小的权重。
更进一步,为进一步优化标准化的策略,避免上述方法中的两次标准化,可以采用 D ~ − 1 / 2 A ~ D ~ − 1 / 2 X \tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}X D~1/2A~D~1/2X进行标准化。
在这里插入图片描述

1.3 图卷积计算公式

多层图卷积网络GCN:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

二、ST-GCN

主要贡献:

  • 将图卷积扩展到了时域上,从而更好的发掘动作的运动特征,而不仅仅是空间特征
  • 设计了新的权重分配策略,能更加差异化地学习不同节点的特征。

过程:

  • 基于openpose实现人体骨骼提取(18个关节)
  • 骨架图构建:节点结合V、边集E由两个子集ES(每一帧的骨架连接)EF(连续帧的相同关节)
  • 在输入数据上应用多层ST-GCN
  • 经过一个readout操作输入softmax进行分类

2.1 骨架图构建

在这里插入图片描述
人体关节对应图的节点,人体身体结构的连通性和时间上的连通性对应图的两类边。
节点集合:V,骨骼序列上的所有的关节点,数量为N
边集:

  • E S E_S ES:每一帧帧内骨骼点的自然连接
  • E F E_F EF:同一骨骼点在相邻帧之间的连接

2.2 模型原理

2.2.1 单帧内的GCN模型

在CNN中,对一个以x为中心,宽为w,高为h的区域做卷积,其输出可以表达为以下形式:当h=3,w=3,步长、padding都为1时,它就是一个3*3卷积核的计算公式
在这里插入图片描述
将上面的公式扩展到图中,对图中一个节点做卷积可通过以下步骤实现:

2.2.2 采样函数

采样函数就是负责指定对每个节点进行图卷积操作时,所涉及到的相邻节点范围
在本文中D = 1,即一阶相邻节点(直接相连的节点),用公式表示如下,其中d表示两个节点间的距离。
在这里插入图片描述
对于D=1,采样函数p可以写为下式,即只采样直接相邻的节点
在这里插入图片描述

2.2.3 权重函数

  • 传统神经网络中,权重函数可通过按照空间顺序(如从左到右、从上到下)索引一个(c,K,K)维张量(即c *K *K的卷积核)进行来实现。
  • 对于graph,没有这种默认的空间排列,所以需要自定义一种排列方式。
  • 本文采用方法:对graph中某node的相邻node进行子集划分,每个子集都有一个label,即实现映射:
    在这里插入图片描述
  • 根据采样区域所属子集分配权重
    在这里插入图片描述

2.2.4 子集划分策略(分区策略)

所谓的分区策略,其实就是对采样的区域划分子集,对应的是上文中提到的 l t i l_{ti} lti,同一个子集内节点共享权重
在这里插入图片描述

  • (a) 输入骨骼序列的示意图,红色节点为本次卷积计算的中心节点,红色虚线内蓝色节点为其采样的相邻节点。
  • (b) Uni-labeling 单一标签

把节点的邻域节点全划为一个子集(包括自身)

  • © Distance partitioning 距离划分

中心节点为一类,相邻节点(不包括自身)为另一类

  • (d) Spatial configuration partitioning 空间结构划分

本文真正采用的方法,r表示节点到重心的距离,将节点的1邻域划分为3个子集:根节点本身、空间位置上比根节点更远离整个骨架的邻居节点、更靠近中心的邻居节点、
在这里插入图片描述

2.2.5 空域图卷积

传统卷积公式更新为图上的卷积公式:
在这里插入图片描述

其中在这里插入图片描述
是标准化项,即相应子集的基数,用于平衡不同子集对输出的贡献。
将采样函数、权重函数更新至公式:
在这里插入图片描述

2.2.6 时空建模

在时间-空间graph上定义相邻节点:Γ表示临近图包含的时间范围,称为时间核大小
在这里插入图片描述

相邻节点的定义是“在空间距离上小于K,在帧距离上前后小于Γ/2,即在空间邻域的定义上加入了时间约束。

时空上的图卷积也需要一套采样函数、权重函数。其基本原理一致,只是重新定义一下标签分组的映射函数,其余计算方式相同

在这里插入图片描述

2.2.7 可学习的边重要性权重

人体在运动时,某几个关节会成群移动,关节的建模应包含有不同的重要性。

ST-GCN为每层添加了一个可学习的掩码M,为 E S E_S ES中的每个边都赋予了一个值,用于衡量这条边所连接的两个节点间相互影响大小的值,而这个值是通过训练学习得到的

2.2.8 ST-GCN公式

在这里插入图片描述

之前一直不理解上面这个公式和下面这个基于频域的图卷积公式有何关系,现在终于明白了:

  • 上面这个公式是对Graph中单个节点做卷积操作,下面这个公式是对整个图的所有节点做卷积操作
  • 对于一个节点来说计算它的输出特征分为三步:找到相邻节点、划分子集、不同子集中的节点的特征乘以对应的权重,其中由于人体骨骼的连接是预定义的,找到单个节点的相邻节点以及划分子集在模型初始化时就可以完成,权重在训练中学习。
  • 将上述思想对应到下面这个公式:通过邻接矩阵来实现找到单个节点的相邻节点以及划分子集,具体来讲,先找到单个节点的相邻节点得到邻接矩阵A,然后对于所有节点来说,每个节点的子集只有k类,那么就将邻接矩阵划分为k个。(例如:2是1的相邻节点,那么在A中,A[1,2]=1,2被划分为1的第k类子集,那么对应的 A k A_k Ak[1,2]=1,其余的k-1个矩阵对应位置则为0)
  • 这样的话,卷积的计算公式就可以表示为: A k f i n W A_kf_{in}W AkfinW,然后将k个子集的结果相加得到最终输出,这样下面的公式就能理解了。
  • 实际上A是先经过归一化处理后才拆成k组的

基于频域的图卷积公式:

在这里插入图片描述
其中
在这里插入图片描述

  • A是graph的邻接矩阵
  • I是单位矩阵,
  • W是可学习权重。
  • fin是输入的特征图,其维度为(c,V,T),其中V为节点数,T为帧数

上述公式只适用于单一子集划分的方法,即所有的W都一样,对于有多个子集的划分策略,邻接矩阵A被分为多个矩阵 A j A_j Aj,其中
在这里插入图片描述
因此,之前的频域图卷积公式变成了
在这里插入图片描述
在这里插入图片描述

这里加个α是为了不让Λ存在全为0的一行,不然会没有办法求 Λ − 1 / 2 Λ^{-1/2} Λ1/2

可学习的边重要性权重实现
ST-GCN为每个邻接矩阵(表示了graph的内部连接关系)配有一个可学习的M
分别把其中的 A + I A+I A+I A j A_j Aj改为: ⨂ \bigotimes 表示元素相乘

在这里插入图片描述

2.3 ST-GCN实现

2.3.1 ST-GCN网络架构

在这里插入图片描述
在这里插入图片描述
1、输入数据时先进行一次批归一化,保持输入数据规模一致

2、ST-GCN由9层ST-GCN模块组成

  • 前三层输出64维特征
  • 中间3层输出128维特征
  • 最后3层输出256维特征
  • 每一层的时间核Γ都为9,并且每一层都有resnet机制

3、为了避免过拟合,每层都加入dropout=0.5,第4、7层的池化stride=2

4、最后是对graph进行readout操作,即将图嵌入,将graph数据转为n维向量,送入softmax进行图分类

5、为了防止过拟合,在数据方面进行增强。首先对骨架序列进行仿射变换(模拟相机移动);再在原始序列中随机抽取片段进行训练。

2.3.2 代码实现

核心代码共分3个文件,在net文件夹下,分别为graph.py, tgcn.py, st-gcn.py

  • graph.py中包含邻接矩阵的建立和结点分组策略
  • st-gcn.py包含整个网络部分的结构和前向传播方法、
  • tgcn.py主要是空间域卷积的结构和前向传播方法
2.3.2.1 graph.py

graph.py主要用于构建Graph类,Graph类的构建主要分为四个部分:self.init、self.get_edge、self.get_hop_distance、self.get_adjacency
1、初始化变量

class Graph():
    def __init__(self,
                 layout='openpose',#骨骼点序列类型
                 strategy='uniform',#分区策略
                 max_hop=1,#最大跳数,ST-GCN中默认为1
                 dilation=1):
        self.max_hop = max_hop
        self.dilation = dilation

        self.get_edge(layout)#根据骨骼点序列类型创造边
        self.hop_dis = get_hop_distance(
            self.num_node, self.edge, max_hop=max_hop)#获取
        self.get_adjacency(strategy)#创建邻接矩阵

2、self.get_edge: 根据layout参数创建邻接矩阵

def get_edge(self, layout):
        if layout == 'openpose':
            self.num_node = 18
            self_link = [(i, i) for i in range(self.num_node)]
            neighbor_link = [(4, 3), (3, 2), (7, 6), (6, 5), (13, 12), (12,
                                                                        11),
                             (10, 9), (9, 8), (11, 5), (8, 2), (5, 1), (2, 1),
                             (0, 1), (15, 0), (14, 0), (17, 15), (16, 14)]
            self.edge = self_link + neighbor_link
            self.center = 1
        elif layout == 'ntu-rgb+d':
            self.num_node = 25
            self_link = [(i, i) for i in range(self.num_node)]
            neighbor_1base = [(1, 2), (2, 21), (3, 21), (4, 3), (5, 21),
                              (6, 5), (7, 6), (8, 7), (9, 21), (10, 9),
                              (11, 10), (12, 11), (13, 1), (14, 13), (15, 14),
                              (16, 15), (17, 1), (18, 17), (19, 18), (20, 19),
                              (22, 23), (23, 8), (24, 25), (25, 12)]
            neighbor_link = [(i - 1, j - 1) for (i, j) in neighbor_1base]
            self.edge = self_link + neighbor_link
            self.center = 21 - 1
        elif layout == 'ntu_edge':
            self.num_node = 24
            self_link = [(i, i) for i in range(self.num_node)]
            neighbor_1base = [(1, 2), (3, 2), (4, 3), (5, 2), (6, 5), (7, 6),
                              (8, 7), (9, 2), (10, 9), (11, 10), (12, 11),
                              (13, 1), (14, 13), (15, 14), (16, 15), (17, 1),
                              (18, 17), (19, 18), (20, 19), (21, 22), (22, 8),
                              (23, 24), (24, 12)]
            neighbor_link = [(i - 1, j - 1) for (i, j) in neighbor_1base]
            self.edge = self_link + neighbor_link
            self.center = 2
        # elif layout=='customer settings'
        #     pass
        else:
            raise ValueError("Do Not Exist This Layout.")

3、self.get_hop_distance



def get_hop_distance(num_node, edge, max_hop=1):
    # 初始化一个全零的邻接矩阵 A
    A = np.zeros((num_node, num_node))
    
    # 根据边的信息填充邻接矩阵 A,其中有边的地方设为 1
    for i, j in edge:
        A[j, i] = 1
        A[i, j] = 1

    # 初始化跳跃距离矩阵,所有元素初始为无穷大
    hop_dis = np.zeros((num_node, num_node)) + np.inf
    
    # 创建一个列表,存储表示不同跳跃长度(从 0 到 max_hop)的路径的矩阵
    transfer_mat = [np.linalg.matrix_power(A, d) for d in range(max_hop + 1)]
    
    # 创建一个布尔矩阵,表示每个跳跃距离下节点之间是否存在路径
    arrive_mat = (np.stack(transfer_mat) > 0)
    
    # 将可达节点的跳跃距离赋值为对应的跳数
    for d in range(max_hop, -1, -1):
        hop_dis[arrive_mat[d]] = d

    # 返回跳跃距离矩阵
    return hop_dis

4、归一化邻接矩阵


#在ST-GCN中使用的是第一种归一化方法
def normalize_digraph(A):
    # 计算每个节点的出度,即每列的和
    Dl = np.sum(A, 0)
    
    # 获取节点数目
    num_node = A.shape[0]
    
    # 初始化度矩阵 Dn,大小与 A 相同
    Dn = np.zeros((num_node, num_node))
    
    # 填充度矩阵的对角线元素,对于出度大于 0 的节点,设置为 1/Dl[i]
    for i in range(num_node):
        if Dl[i] > 0:
            Dn[i, i] = Dl[i]**(-1)
    
    # 计算归一化后的邻接矩阵 AD
    AD = np.dot(A, Dn)
    
    # 返回归一化后的有向图邻接矩阵
    return AD


def normalize_undigraph(A):
    # 计算每个节点的度,即每列的和
    Dl = np.sum(A, 0)
    
    # 获取节点数目
    num_node = A.shape[0]
    
    # 初始化度矩阵 Dn,大小与 A 相同
    Dn = np.zeros((num_node, num_node))
    
    # 填充度矩阵的对角线元素,对于度大于 0 的节点,设置为 1/√Dl[i]
    for i in range(num_node):
        if Dl[i] > 0:
            Dn[i, i] = Dl[i]**(-0.5)
    
    # 计算归一化后的邻接矩阵 DAD
    DAD = np.dot(np.dot(Dn, A), Dn)
    
    # 返回归一化后的无向图邻接矩阵
    return DAD

5、self.get_adjacency
在self.get_edge中指定了哪个顶点是中心顶点,在分区时根据距离中心顶点的跳数来划分子集



def get_adjacency(self, strategy):
    # 计算有效的跳跃距离范围,间隔由 self.dilation 决定
    valid_hop = range(0, self.max_hop + 1, self.dilation)
    
    # 初始化一个邻接矩阵 adjacency
    adjacency = np.zeros((self.num_node, self.num_node))
    
    # 对于每个有效的跳跃距离,将 hop_dis 等于该跳跃距离的矩阵位置设为 1
    for hop in valid_hop:
        adjacency[self.hop_dis == hop] = 1
    
    # 归一化邻接矩阵
    normalize_adjacency = normalize_digraph(adjacency)

    # 根据不同的策略来构造不同的 A 矩阵
    if strategy == 'uniform':
        # 'uniform' 策略下,A 是一个三维矩阵,其中每一层都是相同的归一化邻接矩阵
        A = np.zeros((1, self.num_node, self.num_node))
        A[0] = normalize_adjacency
        self.A = A

    elif strategy == 'distance':
        # 'distance' 策略下,A 的每一层对应不同的跳跃距离
        A = np.zeros((len(valid_hop), self.num_node, self.num_node))
        for i, hop in enumerate(valid_hop):
            # 每一层只保存对应跳跃距离的邻接关系
            A[i][self.hop_dis == hop] = normalize_adjacency[self.hop_dis == hop]
        self.A = A

    elif strategy == 'spatial':
        # 'spatial' 策略下,A 分为三个部分:a_root、a_close 和 a_further
        A = []
        for hop in valid_hop:
            a_root = np.zeros((self.num_node, self.num_node))
            a_close = np.zeros((self.num_node, self.num_node))
            a_further = np.zeros((self.num_node, self.num_node))
            for i in range(self.num_node):
                for j in range(self.num_node):
                    if self.hop_dis[j, i] == hop:# 如果结点j和结点i是邻结点
                    # 比较结点i和结点j分别到中心点的距离,中心点默认为为openpose输出的1结点
                        if self.hop_dis[j, self.center] == self.hop_dis[i, self.center]:
                            # 如果 j 和 i 离中心节点的距离相同,放入 a_root
                            a_root[j, i] = normalize_adjacency[j, i]
                        elif self.hop_dis[j, self.center] > self.hop_dis[i, self.center]:
                            # 如果 j 离中心节点的距离比 i 大,放入 a_close
                            a_close[j, i] = normalize_adjacency[j, i]
                        else:
                            # 如果 j 离中心节点的距离比 i 小,放入 a_further
                            a_further[j, i] = normalize_adjacency[j, i]
            if hop == 0:
                A.append(a_root)# A的第一维第1个矩阵:self distance matrix 对角阵
            else:
                A.append(a_root + a_close)# A的第一维第2个矩阵:列对结点到中心点的距离比行对应点到中心点的距离近或者相等(都为inf)
                A.append(a_further)#A的第一维第3个矩阵:列对应结点到中心点的距离比行对应点到中心点的距离远
        A = np.stack(A)
        self.A = A
        # 输出A的shape(3,18,18)

    else:
        # 如果策略不符合预期,抛出错误
        raise ValueError("Do Not Exist This Strategy")

2.3.2.2 st-gcn.py

st-gcn.py下包含两个类:class Model(nn.Module)和class st_gcn(nn.Module)

1、class Model的前向传播
整个网络的输入是一个[N,C,T,V,M]的tensor

  • N=batch_size,视频个数
  • C = 3, (X,Y,S)代表一个点的信息(位置+预测的可能性)
  • T = 300,一个视频的帧数paper规定是300帧,不足的重头循环,多的clip
  • V=18,根据不同的skeleton获得的节点数而定,coco是18个节点
  • M = 2 ,人数,paper中将人数限定在最大2个人
def forward(self, x):

    # 数据归一化处理
    N, C, T, V, M = x.size()  # 获取输入张量的尺寸
    x = x.permute(0, 4, 3, 1, 2).contiguous()  # NCTVM -->NMVCT
    x = x.view(N * M, V * C, T)  # 重新调整张量的形状,合并NM、VC,
    x = self.data_bn(x)  # 对数据进行批量归一化
    x = x.view(N, M, V, C, T)  # 恢复张量形状
    x = x.permute(0, 1, 3, 4, 2).contiguous()  # 再次调整维度顺序,NMVCT --> NMCTV
    x = x.view(N * M, C, T, V)  # 最终调整为图卷积操作所需的形状

    # 前向传播
    for gcn, importance in zip(self.st_gcn_networks, self.edge_importance):
        x, _ = gcn(x, self.A * importance)  # 依次通过多个时空图卷积网络,并应用边的重要性权重

    # 全局池化
    #x.size()返回张量的尺寸,N * M, C, T, V
    #x.size()[2:] 提取 T 和 V 两个维度的尺寸,作为池化窗口的大小。
    #avg_pool2d 会在 x 的最后两个维度 T 和 V 上应用池化操作。
    #因为池化窗口的大小正好是 T × V,池化操作会把这两个维度缩小到 1×1,即对所有时间步长和节点的特征取一个全局的平均值。
    x = F.avg_pool2d(x, x.size()[2:]) 
    #-1 表示自动推断这一维度的大小,使得总元素数保持不变。
    #1, 1 将 T 和 V 维度分别压缩成单个值,使 x 的最后两个维度为 1x1。
    #mean(dim=1) 在维度 1 上取平均值。这里的维度 1 是对应的 M,
    #这行代码首先将张量 x 的形状调整为 (N, M, C, 1, 1),然后在身体部位维度 M 上取平均值,得到形状为 (N, C, 1, 1) 的张量。
    x = x.view(N, M, -1, 1, 1).mean(dim=1)  # 恢复批量大小维度,并对身体部位维度取平均

    # 预测
    x = self.fcn(x)  # 通过全连接网络进行分类或回归预测
    x = x.view(x.size(0), -1)  # 将输出展平为二维张量,便于输出

    return x  # 返回最终的预测结果

2、class Model的网络结构

留下一个问题:解释明白其中的过程
x = self.data_bn(x)
self.data_bn = nn.BatchNorm1d(in_channels * A.size(1))


class Model(nn.Module):
    def __init__(self, in_channels, num_class, graph_args,
                 edge_importance_weighting, **kwargs):
        super().__init__()

        # 加载图结构
        self.graph = Graph(**graph_args)  # 使用传入的参数创建图对象
        A = torch.tensor(self.graph.A, dtype=torch.float32, requires_grad=False)  # 将图的邻接矩阵转换为不可训练的张量
        self.register_buffer('A', A)  # 注册邻接矩阵 A 为模型的缓冲区,避免被优化器更新

        # 构建网络结构
        spatial_kernel_size = A.size(0)  # 空间卷积核的大小,等于邻接矩阵 A 的深度(通道数)
        temporal_kernel_size = 9  # 时间卷积核的大小,通常设置为 9
        kernel_size = (temporal_kernel_size, spatial_kernel_size)  # 卷积核的大小,由时间和空间两个维度组成
        self.data_bn = nn.BatchNorm1d(in_channels * A.size(1))  # 对输入数据进行批量归一化,输入通道数乘以节点数
        kwargs0 = {k: v for k, v in kwargs.items() if k != 'dropout'}  # 提取除 'dropout' 之外的其他参数

        # 创建时空图卷积网络模块列表,每个 st_gcn 层是一个时空图卷积网络层
        self.st_gcn_networks = nn.ModuleList((
            st_gcn(in_channels, 64, kernel_size, 1, residual=False, **kwargs0),  # 第一层,输入通道数到 64 的转换,不使用残差连接
            st_gcn(64, 64, kernel_size, 1, **kwargs),  # 第二层到第四层,保持通道数为 64
            st_gcn(64, 64, kernel_size, 1, **kwargs),
            st_gcn(64, 64, kernel_size, 1, **kwargs),
            st_gcn(64, 128, kernel_size, 2, **kwargs),  # 第五层,通道数增加到 128,并进行步幅为 2 的下采样
            st_gcn(128, 128, kernel_size, 1, **kwargs),  # 第六层到第七层,保持通道数为 128
            st_gcn(128, 128, kernel_size, 1, **kwargs),
            st_gcn(128, 256, kernel_size, 2, **kwargs),  # 第八层,通道数增加到 256,并进行步幅为 2 的下采样
            st_gcn(256, 256, kernel_size, 1, **kwargs),  # 第九层到第十层,保持通道数为 256
            st_gcn(256, 256, kernel_size, 1, **kwargs),
        ))

        # 初始化边重要性加权的参数
        if edge_importance_weighting:
            # 如果启用了边重要性加权,为每个 st_gcn 层创建一个可训练的边重要性权重参数
            self.edge_importance = nn.ParameterList([
                nn.Parameter(torch.ones(self.A.size()))  # 初始化为全 1 的张量,大小与邻接矩阵 A 相同
                for i in self.st_gcn_networks
            ])
        else:
            # 否则,边重要性权重默认为 1,不进行加权
            self.edge_importance = [1] * len(self.st_gcn_networks)

        # 用于最终预测的全连接层 (1x1 卷积),将通道数从 256 降到类别数
        self.fcn = nn.Conv2d(256, num_class, kernel_size=1)

        



3、class st_gcn的前向传播

def forward(self, x, A):

        res = self.residual(x)
        x, A = self.gcn(x, A)
        x = self.tcn(x) + res

        return self.relu(x), A

4、class st_gcn的网络结构

class st_gcn(nn.Module):

    def __init__(self,
                 in_channels,
                 out_channels,
                 kernel_size,
                 stride=1,
                 dropout=0,
                 residual=True):
        super().__init__()

        # 检查 kernel_size 的长度和时间卷积核大小是否为奇数
        assert len(kernel_size) == 2  # kernel_size 应该有两个元素,分别表示时间和空间维度的卷积核大小
        assert kernel_size[0] % 2 == 1  # 时间维度的卷积核大小应该是奇数,以便在卷积后保持时间维度不变

        # 计算时间维度的填充大小,以保持卷积操作后时间维度不变
        padding = ((kernel_size[0] - 1) // 2, 0)

        # 图卷积层 (GCN),用来在空间维度上处理图结构数据
        self.gcn = ConvTemporalGraphical(in_channels, out_channels,
                                         kernel_size[1])

        # 时间卷积层 (TCN),用来在时间维度上处理数据
        self.tcn = nn.Sequential(
            nn.BatchNorm2d(out_channels),  # 对输出通道数进行批量归一化
            nn.ReLU(inplace=True),  # 使用 ReLU 激活函数
            nn.Conv2d(  # 二维卷积层
                out_channels,
                out_channels,
                (kernel_size[0], 1),  # 卷积核大小,时间维度上使用 kernel_size[0],空间维度上使用 1
                (stride, 1),  # 步幅设置,时间维度上使用 stride,空间维度上为 1
                padding,  # 填充大小,时间维度上使用之前计算的 padding,空间维度上为 0
            ),
            nn.BatchNorm2d(out_channels),  # 再次进行批量归一化
            nn.Dropout(dropout, inplace=True),  # 使用 Dropout 进行正则化,防止过拟合
        )

        # 残差连接
        if not residual:
            # 如果不使用残差连接,返回 0
            self.residual = lambda x: 0

        elif (in_channels == out_channels) and (stride == 1):
            # 如果输入通道数等于输出通道数,并且步幅为 1,则残差连接直接传递输入
            self.residual = lambda x: x

        else:
            # 如果输入通道数与输出通道数不同,或者步幅不为 1,则使用卷积和批量归一化进行调整
            self.residual = nn.Sequential(
                nn.Conv2d(
                    in_channels,
                    out_channels,
                    kernel_size=1,  # 使用 1x1 卷积进行通道数的调整
                    stride=(stride, 1)),  # 使用指定的步幅
                nn.BatchNorm2d(out_channels),  # 对调整后的输出通道数进行批量归一化
            )

        # 激活函数
        self.relu = nn.ReLU(inplace=True)  # 使用 ReLU 激活函数

5、TCN模块

将TCN模块从class st_gcn(nn.Module)中抽出来

# 时间卷积层 (TCN),用来在时间维度上处理数据
        self.tcn = nn.Sequential(
            nn.BatchNorm2d(out_channels),  # 对输出通道数进行批量归一化
            nn.ReLU(inplace=True),  # 使用 ReLU 激活函数
            nn.Conv2d(  # 二维卷积层
                out_channels,
                out_channels,
                (kernel_size[0], 1),  # 卷积核大小,时间维度上使用 kernel_size[0],空间维度上使用 1
                (stride, 1),  # 步幅设置,时间维度上使用 stride,空间维度上为 1
                padding,  # 填充大小,时间维度上使用之前计算的 padding,空间维度上为 0
            ),
            nn.BatchNorm2d(out_channels),  # 再次进行批量归一化
            nn.Dropout(dropout, inplace=True),  # 使用 Dropout 进行正则化,防止过拟合
        )
2.3.2.3 tgcn.py

tgcn.py中定义了ConvTemporalGraphical(nn.Module),即GCN模块

1、GCN模块的参数定义

class ConvTemporalGraphical(nn.Module):
    def __init__(self,
                 in_channels,       # 输入通道数
                 out_channels,      # 输出通道数
                 kernel_size,       # 卷积核大小,用于图卷积(空间维度)
                 t_kernel_size=1,   # 时间维度上的卷积核大小,默认为 1
                 t_stride=1,        # 时间维度上的步幅,默认为 1
                 t_padding=0,       # 时间维度上的填充,默认为 0
                 t_dilation=1,      # 时间维度上的膨胀率,默认为 1
                 bias=True):        # 是否在卷积操作中添加偏置,默认为 True
        super().__init__()

        # 保存图卷积核的大小
        self.kernel_size = kernel_size

        # 定义一个二维卷积层,用于对输入进行时空卷积操作
        # 其中:
        # - in_channels 是输入的通道数
        # - out_channels * kernel_size 是输出的通道数乘以图卷积核的大小
        # - kernel_size=(t_kernel_size, 1) 指定卷积核在时间维度和空间维度上的大小
        # - padding=(t_padding, 0) 指定在时间维度和空间维度上的填充
        # - stride=(t_stride, 1) 指定在时间维度和空间维度上的步幅
        # - dilation=(t_dilation, 1) 指定在时间维度和空间维度上的膨胀率
        # - bias 指定是否在卷积操作中添加偏置
        self.conv = nn.Conv2d(
            in_channels,                       # 输入通道数
            out_channels * kernel_size,        # 输出通道数乘以图卷积核的大小
            kernel_size=(t_kernel_size, 1),    # 卷积核在时间维度和空间维度上的大小
            padding=(t_padding, 0),            # 时间维度和空间维度上的填充
            stride=(t_stride, 1),              # 时间维度和空间维度上的步幅
            dilation=(t_dilation, 1),          # 时间维度和空间维度上的膨胀率
            bias=bias)                         # 是否在卷积操作中添加偏置

2、GCN模块的前向传播

def forward(self, x, A):
        # 确保邻接矩阵 A 的深度(通道数)与卷积核的大小一致
        assert A.size(0) == self.kernel_size
        # 进入GCN时,x的维度是[N * M, C, T, V]
        # A(3,18,18)
        # 对输入 x 进行卷积操作,在这里将x的通道数变为out_channels * kernel_size
        x = self.conv(x)

        # 获取卷积后张量的尺寸
        n, kc, t, v = x.size()  # n: 批量大小, kc: 通道数, t: 时间维度, v: 节点数量

        # 调整 x 的形状,将通道数 kc 分成 self.kernel_size 和 kc//self.kernel_size 两部分
        x = x.view(n, self.kernel_size, kc // self.kernel_size, t, v)

        # 使用爱因斯坦求和约定对 x 和邻接矩阵 A 进行张量乘积
        # 'nkctv,kvw->nctw' 的含义是:
       
        x = torch.einsum('nkctv,kvw->nctw', (x, A))

        # 返回调整后的 x 和邻接矩阵 A,x 使用 contiguous 确保内存是连续的
        return x.contiguous(), A

爱因斯坦求和计算公式:
在这里插入图片描述

对于爱因斯坦求和的理解:

  • x的输入维度是[N * M, C, T, V],经过self.conv后,输出通道数变为out_channels * kernel_size,记作[n, kc, t, v],再通过调整变为[n, k,c, t, v]
  • A的维度是[k,v,w],即(3,18,18)
  • 假定n=1,x的维度可认为是k组[c,t,v],A可认为是k组[v,w],即k组邻接矩阵,对应之前讲到的分区策略
  • x与A相乘,即k组[c,t,v]与k组[v,w]相乘得到k组[c,t,w],这k组[c,t,w]分别获取了对应的k个子集的特征信息
  • 将k组[c,t,w]相加得到一组[c,t,w],即将子集特征信息进行汇聚。
  • 算上n的维度,即得到最后的结果维度为[n,c,t,w]

三、AS-GCN

ST-GCN存在的问题

  • 基于关节间的固定骨架,只捕捉关节间局部的物理依赖性

  • 人类的许多动作需要相距较远的关节协同移动,但结构上的距离较远的关节可能包含关键的动作模式,在很大程度上被忽略了。

AS-GCN:

  • 引入了一个encoder-decoder(编码器-解码器)结构,称为A -link 推理模块(AIM),捕获各种动作的潜在依赖关系,即直接从动作中捕获活动链接

  • 扩展了现有的骨架图,以表示高阶依赖关系,即S-link 结构链接。

  • 将这两种类型的link组合成一个广义的骨架图,进一步提出了动作结构图卷积网络(AS - GCN)

  • 在识别头的平行位置增加了一个未来姿态预测头,以帮助通过自我监督捕获更详细的动作模式。

3.1 Notations (符号注记)

  • 骨架图: G ( V , E ) \mathcal{G}(V, E) G(V,E),其中 V V V n n n 个身体关节的集合, E E E是$m $个骨头的集合。
  • 邻接矩阵: A ∈ { 0 , 1 } n × n A\in \lbrace0,1\rbrace^{n\times n} A{0,1}n×n 。如果第 i i i和第 j j j个关节连接,则 A i j = 1 A_{ij}=1 Aij=1,否则为0。A充分描述了骨架结构。
    对角度矩阵: D ∈ R n × n D\in\mathbb{R}^{n\times n} DRn×n。其中, D i , i = ∑ j A i , j D_{i,i} = ∑_j A_{i,j} Di,i=jAi,j

为了获取更精细的位置信息,文章将一个根节点及其邻居分成三个集合:
(1)根节点: A r o o t A^{root} Aroot
(2)向心群节点: A c e n t r i p e t a l A^{centripetal} Acentripetal,它们比根更接近身体重心。
(3)离心群节点: A c e n t r i f u g a l A^{centrifugal} Acentrifugal ,它们远离重心。

  • 分区组集合: P = { r o o t , c e n t r i p e t a l , c e n t r i f u g a l } \mathcal{P} = \{root, centripetal, centrifugal\} P={root,centripetal,centrifugal},记 ∑ p ∈ P A ( p ) = A ∑_{p∈P} A^{(p)} = A pPA(p)=A
  • T帧内3D关节位置: X ∈ R n × 3 × T \mathcal{X}\in\mathbb{R}^{n\times 3 \times T} XRn×3×T
  • 第t帧的3D关节位置: X t = X i , : , t ∈ R n × 3 \mathcal{X}_t=\mathcal{X}_{i,:,t}\in\mathbb{R}^{n\times 3} Xt=Xi,:,tRn×3,它在 X \mathcal{X} X的最后一个维度中对第 t t t 帧进行了切片。
  • i i i个关节在第 t t t 帧坐标系的位置: x t = X i , : , t ∈ R d x_t=\mathcal{X}_{i,:,t}\in\mathbb{R}^{d} xt=Xi,:,tRd

3.2 ST-GCN的输出

在这里插入图片描述

其中:
在这里插入图片描述

3.2.1 两种链接

在这里插入图片描述
S-links:结构链接,明确地从主体结构派生出来,表示高阶依赖关系,例如挥手时,ST-GCN只能看到相距一跳的胳膊,AS-GCN想要拓展到肩膀。
A-links:活动链接,从骨架数据推断处理的活动链接,用于捕获特定于动作的潜在依赖关系,即直接从动作中捕获活动链接。例如走路时,手和脚离得很远,但它们往往会有协同移动的关系。

3.3 Actional Links (A-links)

人类的许多动作都需要相隔很远的关节才能协同移动,这导致了关节之间的非物理依赖性。为了捕捉各种动作的对应依赖关系,论文引入了动作链接(A-links),它由动作激活,可能存在于任意一对关节之间。

为了从动作中自动推断出A-link,论文开发了一个可训练的A-link推理模块(AIM),该模块由一个编码器(Encoder )和一个解码器(Decoder)组成

  • Encoder:通过在节点和链路之间迭代传播信息来产生A-links,学习链路特征。
  • Decoder:以A-links概率和前几帧的骨架节点为输入,预测下一帧的动作。
    在这里插入图片描述

3.3.1 Encoder

在这里插入图片描述

  • C:A-links的类型数量
  • 元素 A i , j , c \mathcal A_{i,j,c} Ai,j,c 表示第c中类型的A-links,第 i , j i,j i,j关节点连接的概率。
  • 编码器的设计思想:首先从三维关节位置精确地提取出链接的特征,然后将连接特征转化为连接概率。
  • 为了精确的连接特征,作者交替地在关节和链接之间传递信息
  • 在所有T帧上,令 x i = v e c ( X i , : , : ) ∈ R d T x_i=vec(\mathcal X_{i,:,:})\in \mathcal R^{dT} xi=vec(Xi,:,:)RdT 为第i个关节特征的向量( x i x_i xi的维度可以看出,此处 x i x_i xi应该是第i个关节的所有帧组成的一个向量)。
  • 初始化关节特征为 p i ( 0 ) = x i p^{(0)}_i=x_i pi(0)=xi
  • 为了获取更高阶的 link features,作者进行了k次迭代,在关节和连接之间更新权值。
  • 获取 i , j i,j i,j之间链接特征:

f v ( ⋅ ) f_v(\cdot ) fv() f e ( ⋅ ) f_e(\cdot ) fe()都是多层感知器, ⊕ ⊕ 是向量拼接
p i p_i pi p j p_j pj分别经过一个多层感知机,然后进行向量拼接,再经过一个多层感知机获得链接特征

  • 获取节点特征:

F(⋅)是aggregate link features 得到joint features 的操作,例如平均和最大化
先汇聚链接特征,再与原来的节点特征进行拼接,得到更新后的节点特征

在这里插入图片描述

  • 在迭代k次后,编码器将链接概率输出为:

在这里插入图片描述

  • r是一个随机向量,其中的每个元素都是从 Gumbel 分布中独立同分布 (i.i.d.) 地采样得到的。
  • τ τ τ控制 A i , j , : \mathcal A_{i,j,:} Ai,j,: 的离散化,这里我们设置 τ \tau τ=0.5

3.3.2 Decoder

解码器:用于预测未来的3D关节位置,利用前T 帧的结点features以及A(link probabilities)去预测t+1帧的节点位置。

  • 基本思想:首先基于A-links提取关节特征,然后将关节特征转化为未来的关节位置坐标。
  • Xt是第t帧的3D关节点坐标位置
    在这里插入图片描述
    映射解码工作为:
    在这里插入图片描述
  • f v ( c ) ( ⋅ ) , f e ( c ) ( ⋅ ) f_v^{(c)}(\cdot),f_e^{(c)}(\cdot) fv(c)(),fe(c)() f o u t ( ⋅ ) f_{out}(\cdot) fout()都是是MLPs。
  • (a) 通过加权平均求得在t帧时,关节点i,j之间的连接程度。其中link概率 A i , j , : \mathcal A_{i,j,:} Ai,j,:
  • (b) aggregates i i i节点与其他节点的 Q i , j t Q_{i,j}^t Qi,jt,并和 x i t x_i^t xit进行拼接,得到 i i i节点的joint features P i t P_i^t Pit
  • (c )使用门控循环单元(GRU)更新关节特征,其中, S i t S_i^t Sit表示在 t t t层的隐藏状态;
  • (d)预测了未来关节点位置坐标的平均值。我们最终从高斯分布(即 x i t + 1 N ( μ ^ i t + 1 , σ 2 I ) {x}^{t+1}_i \mathcal N(\hat \mu^{t+1}_i,\sigma^2I) xit+1N(μ^it+1,σ2I) 中采样未来的关节点位置 x i t + 1 ∈ R 3 x^{t+1}_i\in \mathcal R^3 xit+1R3,其中 σ 2 \sigma^2 σ2表示方差, I I I是单位矩阵(identity matrix)。

3.3.3 动作图卷积(AGC)

使用A-links来捕捉关节点之间的动作依赖关系。

  • 对多个样本累积求取KaTeX parse error: Expected '}', got 'EOF' at end of input: \mathcal L_{AIM,并将其最小化以获得warm-up A \mathcal A A

  • A a c t ( c ) = A : , : , c ∈ [ 0 , 1 ] n × n \mathcal A_{act}^{(c)}=A_{:,:,c}\in [0,1]^{n\times n} Aact(c)=A:,:,c[0,1]n×n为第c类连接概率,表示第c类动作图的拓扑结构。

  • 在AGC中,我们使用 A ^ a c t ( c ) \hat A_{act}^{(c)} A^act(c)作为图卷积核,其中 A ^ a c t ( c ) = D a c t ( c ) − 1 A a c t ( c ) \hat A_{act}^{(c)}={D_{act}^{(c)}}^{-1}A_{act}^{(c)} A^act(c)=Dact(c)1Aact(c)

  • 给定输入 X i n X_{in} Xin,AGC为:

在这里插入图片描述

3.4 Structural Links (S-links)

  • 在骨骼图中, A ( p ) ~ X i n \tilde{A^{(p)}}X_{in} A(p)~Xin汇聚了1-hop邻域的信息;也就是说ST-GCN在每层中只是局部传递信息。
  • 为了获得长距离links,我们使用A的高阶多项式,表示S链路。
  • 在这里我们使用 A ^ L \hat A^L A^L作为图卷积核,其中 A ^ = D − 1 A \hat A=D^{-1}A A^=D1A 是图的转移矩阵, L L L是多项式的阶数。
  • 利用L阶多项式定义了结构图卷积(SGC),该卷积可以直接到达L-hop邻域以增加感受野。SGC的表述如下
    在这里插入图片描述
  • l l l是多项式阶数
  • A ^ ( p ) \hat A^{(p)} A^(p) 是第p部分的图转移矩阵
  • M s t r u c ( p , l ) ∈ R n × n M^{(p,l)}_{struc}\in \mathcal R^{n\times n} Mstruc(p,l)Rn×n W s t r u c ( p , l ) ∈ R n × d s t r u c W^{(p,l)}_{struc}\in \mathcal R^{n\times d_{struc}} Wstruc(p,l)Rn×dstruc是可训练权重,捕捉边的权重和特征的重要性;

一个l对应一个邻接矩阵,多个l的邻接矩阵相加,在这个过程中,距离越近,计算越多次,权重越大

A ^ ( p ) l \hat A^{(p)l} A^(p)l 是如何获得的:
先看AS-GCN的class Graph()初始化代码:

  • 获取最大跳数,默认为2,实际可以是2、3、4
  • 获取dilation,用于这一步
  • 根据layout,创建边集
  • 根据节点个数、边集、最大跳数获取距离矩阵,矩阵中的值代表了两个节点之间的距离
class Graph():

    def __init__(self,
                 layout='openpose',
                 strategy='uniform',
                 max_hop=2,
                 dilation=1):
        self.max_hop = max_hop
        self.dilation = dilation

        self.get_edge(layout)
        self.hop_dis = get_hop_distance(self.num_node, self.edge, max_hop=max_hop)
        self.get_adjacency(strategy)

以openpose的18个关节点为例:

max_hop=4,矩阵hop_dis
在这里插入图片描述
max_hop=4,邻接矩阵A
在这里插入图片描述
max_hop=4,归一化后的邻接矩阵
在这里插入图片描述
接下来的操作就是:拆分邻接矩阵

valid_hop = range(0, self.max_hop + 1, self.dilation)

for hop in valid_hop:
                a_root = np.zeros((self.num_node, self.num_node))
                a_close = np.zeros((self.num_node, self.num_node))
                a_further = np.zeros((self.num_node, self.num_node))
                for i in range(self.num_node):
                    for j in range(self.num_node):
                    '''
                    这个判断语句会将邻接矩阵分成max_hop+1个
                    hop=0时,实际上只有a_root,a_close、a_further都为空
                    hop=1、2、3、4时,a_root和a_close合并,a_further不为空
                    最终,输出A的维度为[max_hop*2+1,18,18]
                    max_hop=4时,在SpatialGcn中,k_num=11,来自于Class Model中 spatial_kernel_size = A.size(0) + self.edge_type
                    self.edge_type=2,应该就是指A-links和S-links两种链接
                    '''
                        if self.hop_dis[j, i] == hop:
                        
                            if self.hop_dis[j, self.center] == self.hop_dis[i, self.center]:
                                a_root[j, i] = normalize_adjacency[j, i]
                            elif self.hop_dis[j, self.center] > self.hop_dis[i, self.center]:
                                a_close[j, i] = normalize_adjacency[j, i]
                            else:
                                a_further[j, i] = normalize_adjacency[j, i]
                if hop == 0:
                    A.append(a_root)
                else:
                    A.append(a_root + a_close)
                    A.append(a_further)
                
            A = np.stack(A)
            self.A = A 

3.5 Actional-Structural Graph Convolution Block(动作结构图卷积块)

为了完整地捕捉任意关节之间的动作结构特征,将AGC和SGC结合,组合成为了动作结构图卷积(ASGC)。
在这里插入图片描述
λ \lambda λ是一个超参数,它权衡了结构特征和动作特征之间的重要性

3.6 代码实现

3.6.1 graph.py

这部分代码跟ST-GCN相比没有太大变化

import numpy as np

class Graph():

    def __init__(self,
                 layout='openpose',
                 strategy='uniform',
                 max_hop=2,
                 dilation=1):
        self.max_hop = max_hop
        self.dilation = dilation

        self.get_edge(layout)
        self.hop_dis = get_hop_distance(self.num_node, self.edge, max_hop=max_hop)
        self.get_adjacency(strategy)

    def __str__(self):
        return self.A

    def get_edge(self, layout):
        if layout == 'openpose':
            self.num_node = 18
            self_link = [(i, i) for i in range(self.num_node)]
            neighbor_link = [(4, 3), (3, 2), (7, 6), (6, 5), (13, 12), (12, 11),
                             (10, 9), (9, 8), (11, 5), (8, 2), (5, 1), (2, 1),
                             (0, 1), (15, 0), (14, 0), (17, 15), (16, 14)]
            self.edge = self_link + neighbor_link
            self.center = 1
        elif layout == 'ntu-rgb+d':
            self.num_node = 25
            self_link = [(i, i) for i in range(self.num_node)]
            neighbor_1base = [(1, 2), (2, 21), (3, 21), (4, 3), (5, 21),
                              (6, 5), (7, 6), (8, 7), (9, 21), (10, 9),
                              (11, 10), (12, 11), (13, 1), (14, 13), (15, 14),
                              (16, 15), (17, 1), (18, 17), (19, 18), (20, 19),
                              (22, 23), (23, 8), (24, 25), (25, 12)]
            neighbor_link = [(i - 1, j - 1) for (i, j) in neighbor_1base]
            self.edge = self_link + neighbor_link
            self.center = 21 - 1
        elif layout == 'ntu_edge':
            self.num_node = 24
            self_link = [(i, i) for i in range(self.num_node)]
            neighbor_1base = [(1, 2), (3, 2), (4, 3), (5, 2), (6, 5), (7, 6),
                              (8, 7), (9, 2), (10, 9), (11, 10), (12, 11),
                              (13, 1), (14, 13), (15, 14), (16, 15), (17, 1),
                              (18, 17), (19, 18), (20, 19), (21, 22), (22, 8),
                              (23, 24), (24, 12)]
            neighbor_link = [(i - 1, j - 1) for (i, j) in neighbor_1base]
            self.edge = self_link + neighbor_link
            self.center = 2
        else:
            raise ValueError("Do Not Exist This Layout.")

    def get_adjacency(self, strategy):
        valid_hop = range(0, self.max_hop + 1, self.dilation)
        adjacency = np.zeros((self.num_node, self.num_node))
        for hop in valid_hop:
            adjacency[self.hop_dis == hop] = 1
        normalize_adjacency = normalize_digraph(adjacency)

        if strategy == 'uniform':
            A = np.zeros((1, self.num_node, self.num_node))
            A[0] = normalize_adjacency
            self.A = A
        elif strategy == 'distance':
            A = np.zeros((len(valid_hop), self.num_node, self.num_node))
            for i, hop in enumerate(valid_hop):
                A[i][self.hop_dis == hop] = normalize_adjacency[self.hop_dis == hop]
            self.A = A
        elif strategy == 'spatial':
            A = []
            for hop in valid_hop:
                a_root = np.zeros((self.num_node, self.num_node))
                a_close = np.zeros((self.num_node, self.num_node))
                a_further = np.zeros((self.num_node, self.num_node))
                for i in range(self.num_node):
                    for j in range(self.num_node):
                        if self.hop_dis[j, i] == hop:
                            if self.hop_dis[j, self.center] == self.hop_dis[i, self.center]:
                                a_root[j, i] = normalize_adjacency[j, i]
                            elif self.hop_dis[j, self.center] > self.hop_dis[i, self.center]:
                                a_close[j, i] = normalize_adjacency[j, i]
                            else:
                                a_further[j, i] = normalize_adjacency[j, i]
                if hop == 0:
                    A.append(a_root)
                else:
                    A.append(a_root + a_close)
                    A.append(a_further)
            A = np.stack(A)
            self.A = A 
        else:
            raise ValueError("Do Not Exist This Strategy")


def get_hop_distance(num_node, edge, max_hop=1):
    A = np.zeros((num_node, num_node))
    for i, j in edge:
        A[j, i] = 1
        A[i, j] = 1

    hop_dis = np.zeros((num_node, num_node)) + np.inf
    transfer_mat = [np.linalg.matrix_power(A, d) for d in range(max_hop + 1)]
    arrive_mat = (np.stack(transfer_mat) > 0)
    for d in range(max_hop, -1, -1):
        hop_dis[arrive_mat[d]] = d
    return hop_dis


def normalize_digraph(A):
    Dl = np.sum(A, 0)
    num_node = A.shape[0]
    Dn = np.zeros((num_node, num_node))
    for i in range(num_node):
        if Dl[i] > 0:
            Dn[i, i] = Dl[i]**(-1)
    AD = np.dot(A, Dn)
    return AD


def normalize_undigraph(A):
    Dl = np.sum(A, 0)
    num_node = A.shape[0]
    Dn = np.zeros((num_node, num_node))
    for i in range(num_node):
        if Dl[i] > 0:
            Dn[i, i] = Dl[i]**(-0.5)
    DAD = np.dot(np.dot(Dn, A), Dn)
    return DAD

3.6.2 AS-GCN网络

待补充:2S-AGCN的预测

在这里插入图片描述
1、as-agcn类的初始化

class Model(nn.Module):

    def __init__(self, in_channels, num_class, graph_args, edge_importance_weighting, **kwargs):
        super().__init__()

        # 初始化图结构,并将邻接矩阵A注册为模型的缓冲区变量,不参与梯度计算
        self.graph = Graph(**graph_args)
        A = torch.tensor(self.graph.A, dtype=torch.float32, requires_grad=False)
        self.register_buffer('A', A)
        self.edge_type = 2  # 定义边类型的数量

        # 定义时空卷积核大小
        temporal_kernel_size = 9
        spatial_kernel_size = A.size(0) + self.edge_type
        st_kernel_size = (temporal_kernel_size, spatial_kernel_size)
        
        # 初始化输入数据的Batch Normalization层
        self.data_bn = nn.BatchNorm1d(in_channels * A.size(1))

        # 定义多个时空卷积层(ST-GCN层),用于分类任务
        self.class_layer_0 = StgcnBlock(in_channels, 64, st_kernel_size, self.edge_type, stride=1, residual=False, **kwargs)
        self.class_layer_1 = StgcnBlock(64, 64, st_kernel_size, self.edge_type, stride=1, **kwargs)
        self.class_layer_2 = StgcnBlock(64, 64, st_kernel_size, self.edge_type, stride=1, **kwargs)
        self.class_layer_3 = StgcnBlock(64, 128, st_kernel_size, self.edge_type, stride=2, **kwargs)
        self.class_layer_4 = StgcnBlock(128, 128, st_kernel_size, self.edge_type, stride=1, **kwargs)
        self.class_layer_5 = StgcnBlock(128, 128, st_kernel_size, self.edge_type, stride=1, **kwargs)
        self.class_layer_6 = StgcnBlock(128, 256, st_kernel_size, self.edge_type, stride=2, **kwargs)
        self.class_layer_7 = StgcnBlock(256, 256, st_kernel_size, self.edge_type, stride=1, **kwargs)
        self.class_layer_8 = StgcnBlock(256, 256, st_kernel_size, self.edge_type, stride=1, **kwargs)

        # 定义多个时空卷积层(ST-GCN层),用于重建任务
        self.recon_layer_0 = StgcnBlock(256, 128, st_kernel_size, self.edge_type, stride=1, **kwargs)
        self.recon_layer_1 = StgcnBlock(128, 128, st_kernel_size, self.edge_type, stride=2, **kwargs)     
        self.recon_layer_2 = StgcnBlock(128, 128, st_kernel_size, self.edge_type, stride=2, **kwargs) 
        self.recon_layer_3 = StgcnBlock(128, 128, st_kernel_size, self.edge_type, stride=2, **kwargs)
        self.recon_layer_4 = StgcnBlock(128, 128, (3, spatial_kernel_size), self.edge_type, stride=2, **kwargs) 
        self.recon_layer_5 = StgcnBlock(128, 128, (5, spatial_kernel_size), self.edge_type, stride=1, padding=False, residual=False, **kwargs)
        self.recon_layer_6 = StgcnReconBlock(128+3, 30, (1, spatial_kernel_size), self.edge_type, stride=1, padding=False, residual=False, activation=None, **kwargs)

        # 根据是否使用边权重,定义分类和重建任务的边权重参数
        if edge_importance_weighting:
            self.edge_importance = nn.ParameterList([nn.Parameter(torch.ones(self.A.size())) for i in range(9)])
            self.edge_importance_recon = nn.ParameterList([nn.Parameter(torch.ones(self.A.size())) for i in range(9)])
        else:
            self.edge_importance = [1] * (len(self.st_gcn_networks) + len(self.st_gcn_recon))
        
        # 定义全连接层,用于分类任务的最终输出
        self.fcn = nn.Conv2d(256, num_class, kernel_size=1)

2、as-agcn类的前向传播

def forward(self, x, x_target, x_last, A_act, lamda_act):

    # 获取输入张量的大小 [N, C, T, V, M]
    N, C, T, V, M = x.size()
    
    # 提取第一个样本数据 [2N, 3, 300, 25]
    x_recon = x[:, :, :, :, 0]  
    
    # 调整张量的维度顺序并使其在内存中连续 [N, 2, 25, 3, 300] -> [2N, 75, 300]
    x = x.permute(0, 4, 3, 1, 2).contiguous()
    x = x.view(N * M, V * C, T)

    # 调整 x_last 的维度 [N, 3, 1, 25]
    x_last = x_last.permute(0, 4, 1, 2, 3).contiguous().view(-1, 3, 1, 25)
    
    # 通过 Batch Normalization 层进行归一化处理
    x_bn = self.data_bn(x)
    x_bn = x_bn.view(N, M, V, C, T)
    x_bn = x_bn.permute(0, 1, 3, 4, 2).contiguous()
    x_bn = x_bn.view(N * M, C, T, V)

    # 通过多个分类层逐步提取特征
    h0, _ = self.class_layer_0(x_bn, self.A * self.edge_importance[0], A_act, lamda_act)  # [N, 64, 300, 25]
    h1, _ = self.class_layer_1(h0, self.A * self.edge_importance[1], A_act, lamda_act)    # [N, 64, 300, 25]
    h1, _ = self.class_layer_1(h0, self.A * self.edge_importance[1], A_act, lamda_act)    # 重复计算 (可能是代码中的错误) [N, 64, 300, 25]
    h2, _ = self.class_layer_2(h1, self.A * self.edge_importance[2], A_act, lamda_act)    # [N, 64, 300, 25]
    h3, _ = self.class_layer_3(h2, self.A * self.edge_importance[3], A_act, lamda_act)    # [N, 128, 150, 25]
    h4, _ = self.class_layer_4(h3, self.A * self.edge_importance[4], A_act, lamda_act)    # [N, 128, 150, 25]
    h5, _ = self.class_layer_5(h4, self.A * self.edge_importance[5], A_act, lamda_act)    # [N, 128, 150, 25]
    h6, _ = self.class_layer_6(h5, self.A * self.edge_importance[6], A_act, lamda_act)    # [N, 256, 75, 25]
    h7, _ = self.class_layer_7(h6, self.A * self.edge_importance[7], A_act, lamda_act)    # [N, 256, 75, 25]
    h8, _ = self.class_layer_8(h7, self.A * self.edge_importance[8], A_act, lamda_act)    # [N, 256, 75, 25]

    # 对最后一层特征图进行全局平均池化并进行分类
    x_class = F.avg_pool2d(h8, h8.size()[2:])
    x_class = x_class.view(N, M, -1, 1, 1).mean(dim=1)
    x_class = self.fcn(x_class)
    x_class = x_class.view(x_class.size(0), -1)

    # 通过多个重建层逐步重建数据
    r0, _ = self.recon_layer_0(h8, self.A * self.edge_importance_recon[0], A_act, lamda_act)  # [N, 128, 75, 25]
    r1, _ = self.recon_layer_1(r0, self.A * self.edge_importance_recon[1], A_act, lamda_act)  # [N, 128, 38, 25]
    r2, _ = self.recon_layer_2(r1, self.A * self.edge_importance_recon[2], A_act, lamda_act)  # [N, 128, 19, 25]
    r3, _ = self.recon_layer_3(r2, self.A * self.edge_importance_recon[3], A_act, lamda_act)  # [N, 128, 10, 25]
    r4, _ = self.recon_layer_4(r3, self.A * self.edge_importance_recon[4], A_act, lamda_act)  # [N, 128, 5, 25]
    r5, _ = self.recon_layer_5(r4, self.A * self.edge_importance_recon[5], A_act, lamda_act)  # [N, 128, 1, 25]
    r6, _ = self.recon_layer_6(torch.cat((r5, x_last), 1), self.A * self.edge_importance_recon[6], A_act, lamda_act)  # [N, 64, 1, 25]
    
    # 最终预测,结合 x_last 和 r6 重建出的结果 [N, 3, 25]
    pred = x_last.squeeze().repeat(1, 10,

3.6.3 StgcnBlock

在这里插入图片描述

class StgcnBlock(nn.Module):

    def __init__(self,
                 in_channels,        # 输入通道数
                 out_channels,       # 输出通道数
                 kernel_size,        # 卷积核大小 (时间维度的卷积核大小, 空间维度的卷积核大小)
                 edge_type=2,        # 边的类型数量
                 t_kernel_size=1,    # 时间卷积核大小 (默认为1)
                 stride=1,           # 步幅大小 (默认为1)
                 padding=True,       # 是否填充 (默认为True)
                 dropout=0,          # Dropout 概率 (默认为0)
                 residual=True):     # 是否使用残差连接 (默认为True)
        super().__init__()

        # 检查kernel_size是否为长度为2的元组,并确保时间卷积核大小是奇数
        assert len(kernel_size) == 2
        assert kernel_size[0] % 2 == 1

        # 如果padding为True,则设置时间维度的padding为 (kernel_size[0] - 1) // 2,以保持卷积后的尺寸不变
        if padding == True:
            padding = ((kernel_size[0] - 1) // 2, 0)
        else:
            padding = (0, 0)

        # 定义GCN层(图卷积网络层),用于空间维度上的卷积操作
        self.gcn = SpatialGcn(in_channels=in_channels,
                              out_channels=out_channels,
                              k_num=kernel_size[1],     # 空间维度的卷积核大小
                              edge_type=edge_type,      # 边的类型数量
                              t_kernel_size=t_kernel_size)

        # 定义TCN层(时间卷积网络层),用于时间维度上的卷积操作
        self.tcn = nn.Sequential(
            nn.BatchNorm2d(out_channels),            # 批归一化
            nn.ReLU(inplace=True),                   # ReLU 激活函数
            nn.Conv2d(out_channels,
                      out_channels,
                      (kernel_size[0], 1),           # 卷积核大小为 (时间卷积核大小, 1)
                      (stride, 1),                   # 步幅为 (stride, 1)
                      padding),                      # 填充
            nn.BatchNorm2d(out_channels),            # 批归一化
            nn.Dropout(dropout, inplace=True)        # Dropout 层,防止过拟合
        )

        # 定义残差连接。如果不使用残差连接,则残差部分设为零;否则,根据输入输出通道数和步幅,选择合适的残差连接方式
        if not residual:
            self.residual = lambda x: 0
        elif (in_channels == out_channels) and (stride == 1):
            self.residual = lambda x: x
        else:
            self.residual = nn.Sequential(
                nn.Conv2d(in_channels,
                          out_channels,
                          kernel_size=1,
                          stride=(stride, 1)),       # 使用1x1卷积进行通道数变换
                nn.BatchNorm2d(out_channels)         # 批归一化
            )
        # 定义ReLU激活函数
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x, A, B, lamda_act):
        # 计算残差
        res = self.residual(x)

        # 通过GCN层进行空间卷积
        x, A = self.gcn(x, A, B, lamda_act)

        # 通过TCN层进行时间卷积,并加上残差
        x = self.tcn(x) + res

        # 经过ReLU激活函数
        return self.relu(x), A

3.6.4 SpatialGcn

class SpatialGcn(nn.Module):

    def __init__(self,
                 in_channels,         # 输入通道数
                 out_channels,        # 输出通道数
                 k_num,               # 卷积核的数量(即空间卷积核的数量)
                 edge_type=2,         # 边的类型数量(默认为2)
                 t_kernel_size=1,     # 时间卷积核大小(默认为1)
                 t_stride=1,          # 时间维度上的步幅(默认为1)
                 t_padding=0,         # 时间维度上的填充(默认为0)
                 t_dilation=1,        # 时间卷积的扩张率(默认为1)
                 bias=True):          # 是否使用偏置(默认为True)
        super().__init__()

        self.k_num = k_num          # 保存卷积核的数量
        self.edge_type = edge_type  # 保存边的类型数量

        # 定义2D卷积层,进行时间维度的卷积操作
        # 卷积核大小为 (t_kernel_size, 1) ,即在时间维度上进行卷积,不改变空间维度
        # 输出通道数为 out_channels * k_num
        self.conv = nn.Conv2d(in_channels=in_channels,
                              out_channels=out_channels * k_num,
                              kernel_size=(t_kernel_size, 1),
                              padding=(t_padding, 0),
                              stride=(t_stride, 1),
                              dilation=(t_dilation, 1),
                              bias=bias)

    def forward(self, x, A, B, lamda_act):
    
        # 对输入进行卷积操作
        x = self.conv(x)

        # 获取卷积后的张量的尺寸:批次大小 n,通道数 kc,时间维度 t,节点数 v
        n, kc, t, v = x.size()

        # 调整x的形状为 (n, k_num, out_channels, t, v)
        x = x.view(n, self.k_num, kc // self.k_num, t, v)

        # 将前部分的卷积结果分配给 x1,维度为 (n, k_num-edge_type, out_channels, t, v)
        x1 = x[:, :self.k_num - self.edge_type, :, :, :]

        # 将后部分的卷积结果分配给 x2,维度为 (n, edge_type, out_channels, t, v)
        x2 = x[:, -self.edge_type:, :, :, :]

        # 对x1应用A矩阵,进行常规的图卷积操作
        x1 = torch.einsum('nkctv,kvw->nctw', (x1, A))

        # 对x2应用B矩阵,进行图卷积操作,B是动态变化的
        x2 = torch.einsum('nkctv,nkvw->nctw', (x2, B))

        # 结合x1和x2,使用 lamda_act 作为权重
        x_sum = x1 + x2 * lamda_act

        # 返回结果和A矩阵
        return x_sum.contiguous(), A

疑问:代码中A、B两个矩阵从哪来的:

#在class Model(nn.Module)中定义了AS-GCN层
self.class_layer_0 = StgcnBlock(in_channels, 64, st_kernel_size, self.edge_type, stride=1, residual=False, **kwargs)
#在class Model的forward中:
def forward(self, x, x_target, x_last, A_act, lamda_act):
h0, _ = self.class_layer_0(x_bn, self.A * self.edge_importance[0], A_act, lamda_act)
所以:
A是 self.A * self.edge_importance[0]
B是 A_act,来自Model类的外部输出

在recognition.py的class REC_Processor(Processor)中有这两行代码:
A_batch, prob, outputs, _ = self.model2(data_downsample)
       
x_class, pred, target = self.model1(data, target_data, data_last, A_batch, self.arg.lamda_act)

其中model2是:adi_learn.py中的Class AdjacencyLearn
model1是:AS-GCN网络
A_batch就是A_act

3.6.5 AdjacencyLearn

3.6.5.1 MLP
class MLP(nn.Module):

    def __init__(self, n_in, n_hid, n_out, do_prob=0.):
        super().__init__()

        # 定义第一个全连接层,将输入特征映射到隐藏层
        self.fc1 = nn.Linear(n_in, n_hid)

        # 定义第二个全连接层,将隐藏层映射到输出层
        self.fc2 = nn.Linear(n_hid, n_out)

        # 定义一个批归一化层,用于归一化输出层的激活值
        self.bn = nn.BatchNorm1d(n_out)

        # 定义一个dropout层,用于防止过拟合
        self.dropout = nn.Dropout(p=do_prob)

        # 初始化网络的权重
        self.init_weights()

    def init_weights(self):
        # 初始化全连接层和批归一化层的权重
        for m in self.modules():
            if isinstance(m, nn.Linear):
                # 使用Xavier正态分布初始化
                nn.init.xavier_normal_(m.weight.data)
                m.bias.data.fill_(0.1)
            elif isinstance(m, nn.BatchNorm1d):
                m.weight.data.fill_(1)
                m.bias.data.zero_()

    def batch_norm(self, inputs):
        # 批归一化函数
        x = inputs.view(inputs.size(0) * inputs.size(1), -1)
        x = self.bn(x)
        return x.view(inputs.size(0), inputs.size(1), -1)
        
    def forward(self, inputs):
        # 前向传播函数
        x = F.elu(self.fc1(inputs))  # 使用ELU激活函数
        x = self.dropout(x)  # 应用dropout层
        x = F.elu(self.fc2(x))  # 使用ELU激活函数
        return self.batch_norm(x)  # 批归一化并返回输出
3.6.5.2 编码器
class InteractionNet(nn.Module):

    def __init__(self, n_in, n_hid, n_out, do_prob=0., factor=True):
        super().__init__()

        self.factor = factor
        
        # 定义多个多层感知机(MLP)模块
        self.mlp1 = MLP(n_in, n_hid, n_hid, do_prob)
        self.mlp2 = MLP(n_hid * 2, n_hid, n_hid, do_prob)
        self.mlp3 = MLP(n_hid, n_hid, n_hid, do_prob)
        
        # 根据factor参数决定是否使用一个额外的MLP层
        self.mlp4 = MLP(n_hid * 3, n_hid, n_hid, do_prob) if self.factor else MLP(n_hid * 2, n_hid, n_hid, do_prob)
        
        # 定义输出层的全连接层
        self.fc_out = nn.Linear(n_hid, n_out)

        # 初始化网络权重
        self.init_weights()

    def init_weights(self):
        # 初始化网络中全连接层的权重
        for m in self.modules():
            if isinstance(m, nn.Linear):
                nn.init.xavier_normal_(m.weight.data)
                m.bias.data.fill_(0.1)

    def node2edge(self, x, rel_rec, rel_send):
        # 将节点信息转换为边信息
        receivers = torch.matmul(rel_rec, x)
        senders = torch.matmul(rel_send, x)
        edges = torch.cat([receivers, senders], dim=2)
        return edges

    def edge2node(self, x, rel_rec, rel_send):
        # 将边信息转换为节点信息
        incoming = torch.matmul(rel_rec.t(), x)
        nodes = incoming / incoming.size(1)
        return nodes

    def forward(self, inputs, rel_rec, rel_send):
        # 前向传播函数
        
        # 重塑输入的形状,方便后续处理
        x = inputs.contiguous()
        x = x.view(inputs.size(0), inputs.size(1), -1)         # [N, 25, 50, 3] -> [N, 25, 150]
        
        # 通过第一个MLP处理
        x = self.mlp1(x)                                       # [N, 25, 150] -> [N, 25, n_hid=256]
        
        # 将节点信息转换为边信息
        x = self.node2edge(x, rel_rec, rel_send)               # [N, 25, 256] -> [N, 600, 512]
        
        # 通过第二个MLP处理
        x = self.mlp2(x)                                       # [N, 600, 512] -> [N, 600, n_hid=256]
        
        x_skip = x  # 保存x用于跳跃连接
        
        if self.factor:
            # 如果factor为True,则执行以下操作
            
            # 将边信息转换为节点信息
            x = self.edge2node(x, rel_rec, rel_send)           # [N, 600, 256] -> [N, 25, 256]
            
            # 通过第三个MLP处理
            x = self.mlp3(x)                                   # [N, 25, 256] -> [N, 25, n_hid=256]
            
            # 再次将节点信息转换为边信息
            x = self.node2edge(x, rel_rec, rel_send)           # [N, 25, 256] -> [N, 600, 512]
            
            # 与之前保存的x进行拼接
            x = torch.cat((x, x_skip), dim=2)                  # [N, 600, 512] -> [N, 600, 768]
            
            # 通过第四个MLP处理
            x = self.mlp4(x)                                   # [N, 600, 768] -> [N, 600, n_hid=256]
        else:
            # 如果factor为False,则直接执行以下操作
            x = self.mlp3(x)
            x = torch.cat((x, x_skip), dim=2)
            x = self.mlp4(x)
        
        # 最后通过输出层返回结果
        return self.fc_out(x)                                  # [N, 600, 256] -> [N, 600, 3]

3.6.5.3 解码器
import torch
import torch.nn as nn
import torch.nn.functional as F

class InteractionDecoderRecurrent(nn.Module):
    
    def __init__(self, n_in_node, edge_types, n_hid, do_prob=0., skip_first=True):
        super().__init__()

        # 定义消息传递网络的两层全连接层,每种边类型都有一个
        self.msg_fc1 = nn.ModuleList([nn.Linear(2 * n_hid, n_hid) for _ in range(edge_types)])
        self.msg_fc2 = nn.ModuleList([nn.Linear(n_hid, n_hid) for _ in range(edge_types)])
        self.msg_out_shape = n_hid
        self.skip_first_edge_type = skip_first

        # 定义隐状态更新的三个全连接层 (GRU-like structure)
        self.hidden_r = nn.Linear(n_hid, n_hid, bias=False)  # 重置门
        self.hidden_i = nn.Linear(n_hid, n_hid, bias=False)  # 更新门
        self.hidden_n = nn.Linear(n_hid, n_hid, bias=False)  # 新信息

        # 定义输入到隐状态的三个全连接层
        self.input_r = nn.Linear(n_in_node, n_hid, bias=True)  # 3 x 256, 对应输入到重置门
        self.input_i = nn.Linear(n_in_node, n_hid, bias=True)  # 对应输入到更新门
        self.input_n = nn.Linear(n_in_node, n_hid, bias=True)  # 对应输入到新信息

        # 定义输出的全连接层
        self.out_fc1 = nn.Linear(n_hid, n_hid)
        self.out_fc2 = nn.Linear(n_hid, n_hid)
        self.out_fc3 = nn.Linear(n_hid, n_in_node)

        # 定义三个Dropout层,防止过拟合
        self.dropout1 = nn.Dropout(p=do_prob)
        self.dropout2 = nn.Dropout(p=do_prob)
        self.dropout3 = nn.Dropout(p=do_prob)

    def single_step_forward(self, inputs, rel_rec, rel_send, rel_type, hidden):
        # 单步前向传播函数
        
        # 计算消息传递:从隐状态获取接收者和发送者信息
        receivers = torch.matmul(rel_rec, hidden)
        senders = torch.matmul(rel_send, hidden)
        
        # 将接收者和发送者的信息拼接
        pre_msg = torch.cat([receivers, senders], dim=-1)

        # 初始化所有消息的张量
        all_msgs = torch.zeros(pre_msg.size(0), pre_msg.size(1), self.msg_out_shape)
        
        # 获取GPU设备ID并将all_msgs张量移动到GPU
        gpu_id = rel_rec.get_device()
        all_msgs = all_msgs.cuda(gpu_id)

        # 根据skip_first_edge_type决定是否跳过第一个边类型
        if self.skip_first_edge_type:
            start_idx = 1
            norm = float(len(self.msg_fc2)) - 1.  # 归一化因子
        else:
            start_idx = 0
            norm = float(len(self.msg_fc2))

        # 逐个边类型处理消息
        for k in range(start_idx, len(self.msg_fc2)):
            # 通过msg_fc1和msg_fc2计算消息
            msg = torch.tanh(self.msg_fc1[k](pre_msg))
            msg = self.dropout1(msg)
            msg = torch.tanh(self.msg_fc2[k](msg))
            
            # 根据rel_type对消息进行加权
            msg = msg * rel_type[:, :, k:k + 1]
            
            # 累加处理后的消息,并归一化
            all_msgs += msg / norm

        # 聚合所有消息,并对接收者的信息进行平均
        agg_msgs = all_msgs.transpose(-2, -1).matmul(rel_rec).transpose(-2, -1)
        agg_msgs = agg_msgs.contiguous() / inputs.size(2)

        # 计算GRU-like结构的重置门、更新门和新信息
        r = torch.sigmoid(self.input_r(inputs) + self.hidden_r(agg_msgs))
        i = torch.sigmoid(self.input_i(inputs) + self.hidden_i(agg_msgs))
        n = torch.tanh(self.input_n(inputs) + r * self.hidden_n(agg_msgs))
        
        # 更新隐状态
        hidden = (1-i) * n + i * hidden

        # 计算预测结果,通过全连接层和激活函数
        pred = self.dropout2(F.relu(self.out_fc1(hidden)))
        pred = self.dropout2(F.relu(self.out_fc2(pred)))
        pred = self.out_fc3(pred)
        
        # 将预测结果加上输入,形成最终输出
        pred = inputs + pred

        return pred, hidden

    def forward(self, data, rel_type, rel_rec, rel_send, pred_steps=1, 
                burn_in=False, burn_in_steps=1, dynamic_graph=False,
                encoder=None, temp=None):
        # 前向传播函数,处理整个时间序列的数据
        
        inputs = data.transpose(1, 2).contiguous()  # [N, T, V, C] -> [N, V, T, C]
        time_steps = inputs.size(1)
        
        # 初始化隐状态为零
        hidden = torch.zeros(inputs.size(0), inputs.size(2), self.msg_out_shape)
        gpu_id = rel_rec.get_device()
        hidden = hidden.cuda(gpu_id)
        
        pred_all = []  # 用于保存所有时间步的预测
        
        # 遍历所有时间步进行预测
        for step in range(0, inputs.size(1) - 1):
            if not step % pred_steps:
                ins = inputs[:, step, :, :]  # 使用真实数据进行预测
            else:
                ins = pred_all[step - 1]  # 使用之前的预测结果进行预测
            
            # 单步前向传播
            pred, hidden = self.single_step_forward(ins, rel_rec, rel_send, rel_type, hidden)
            pred_all.append(pred)

        # 将所有时间步的预测结果堆叠成一个张量
        preds = torch.stack(pred_all, dim=1)
        return preds.transpose(1, 2).contiguous()  # [N, V, T, C]

3.6.5.3 训练得到A-links

class AdjacencyLearn(nn.Module):

    def __init__(self, n_in_enc, n_hid_enc, edge_types, n_in_dec, n_hid_dec, node_num=25):
        super().__init__()

        # 定义编码器:用于从输入数据中提取特征并预测边的类型
        self.encoder = InteractionNet(n_in=n_in_enc,       # 输入维度为150
                                      n_hid=n_hid_enc,     # 隐藏层维度为256
                                      n_out=edge_types,    # 边的类型数为3
                                      do_prob=0.5,         # dropout 概率
                                      factor=True)

        # 定义解码器:用于根据编码器的输出生成预测
        self.decoder = InteractionDecoderRecurrent(n_in_node=n_in_dec,    # 输入节点维度为256
                                                   edge_types=edge_types, # 边的类型数为3
                                                   n_hid=n_hid_dec,       # 隐藏层维度为256
                                                   do_prob=0.5,           # dropout 概率
                                                   skip_first=True)

        # 获取非对角线索引,排除自循环
        self.offdiag_indices, _ = get_offdiag_indices(node_num)

        # 初始化关系矩阵,rel_rec 和 rel_send 表示发送和接收关系
        off_diag = np.ones([node_num, node_num]) - np.eye(node_num, node_num)
        self.rel_rec = torch.FloatTensor(np.array(encode_onehot(np.where(off_diag)[1]), dtype=np.float32))
        self.rel_send = torch.FloatTensor(np.array(encode_onehot(np.where(off_diag)[0]), dtype=np.float32))
        
        # 衰减因子,用于调整邻接矩阵
        self.dcy = 0.1

        # 初始化权重
        self.init_weights()

    def init_weights(self):
        # 初始化批量归一化层的权重
        for m in self.modules():
            if isinstance(m, nn.BatchNorm1d):
                m.weight.data.fill_(1)
                m.bias.data.zero_()

    def forward(self, inputs):  # 输入维度: [N, 3, 50, 25, 2]

        # 获取输入数据的尺寸
        N, C, T, V, M = inputs.size()

        # 调整输入数据的维度顺序并展平一些维度
        x = inputs.permute(0, 4, 3, 1, 2).contiguous()  # 调整维度顺序: [N, 2, 25, 3, 50]
        x = x.contiguous().view(N * M, V, C, T).permute(0, 1, 3, 2)  # 变形: [2N, 25, 50, 3]

        # 将关系矩阵移动到GPU上
        gpu_id = x.get_device()
        rel_rec = self.rel_rec.cuda(gpu_id)
        rel_send = self.rel_send.cuda(gpu_id)

        # 通过编码器计算边的 logits
        self.logits = self.encoder(x, rel_rec, rel_send)
        self.N, self.v, self.c = self.logits.size()

        # 通过Gumbel Softmax采样获取边的类型 (硬采样)
        self.edges = gumbel_softmax(self.logits, tau=0.5, hard=True)
        self.prob = my_softmax(self.logits, -1)  # 计算边的类型概率

        # 通过解码器生成预测
        self.outputs = self.decoder(x, self.edges, rel_rec, rel_send, burn_in=False, burn_in_steps=40)
        self.offdiag_indices = self.offdiag_indices.cuda(gpu_id)

        # 生成邻接矩阵批次
        A_batch = []
        for i in range(self.N):
            A_types = []
            for j in range(1, self.c):  # 跳过第一个类型(通常是空类型)
                # 构建稀疏邻接矩阵,并加上自环
                A = torch.sparse.FloatTensor(self.offdiag_indices, self.edges[i, :, j], torch.Size([25, 25])).to_dense().cuda(gpu_id)
                A = A + torch.eye(25, 25).cuda(gpu_id)

                # 计算度矩阵并取其倒数的对角矩阵形式
                D = torch.sum(A, dim=0).squeeze().pow(-1) + 1e-10
                D = torch.diag(D)

                # 计算归一化邻接矩阵,并进行衰减
                A_ = torch.matmul(A, D) * self.dcy
                A_types.append(A_)

            # 堆叠不同类型的邻接矩阵
            A_types = torch.stack(A_types)
            A_batch.append(A_types)

        # 将邻接矩阵批次堆叠起来
        self.A_batch = torch.stack(A_batch).cuda(gpu_id)  # 输出尺寸: [N, 2, 25, 25]

        return self.A_batch, self.prob, self.outputs, x

核心步骤

  • 输入数据处理:输入数据的维度进行调整和变形,使其适应编码器的输入要求。
  • 边的类型预测:通过编码器生成边的 logits,并利用 Gumbel Softmax 进行采样,得到边的类型。
  • 预测生成:通过解码器根据预测的边类型生成预测输出。
  • 邻接矩阵计算:根据边的类型生成相应的邻接矩阵,并对其进行归一化和衰减处理。

四、2S-AGCN

4.1介绍

ST-GCN缺点:

  • ST-GCN中使用的骨架图是启发式预定义的,并且仅表示人体的物理结构。因此,不能保证它对于动作识别任务是最优的。例如,两只手之间的关系对于识别诸如“鼓掌”和“阅读”之类的动作非常重要。然而,ST-GCN很难捕捉到两只手之间的依赖关系,因为它们在预定义的基于人体的图形中距离彼此很远。
  • GCNs的结构是层次化的,不同的层次包含多层语义信息。然而,ST-GCN应用的图的拓扑结构是固定在所有层上的,缺乏对所有层中包含的多层语义信息建模的灵活性和能力;
  • 对于不同动作类的所有样本,一个固定的图结构不一定是最优的。对于一些动作来说,比如“擦脸”和“碰头”,手和头之间的联系应该更强一些,但对于其他一些动作,比如“跳起来”和“坐下来”,情况则相反。这一事实表明,图结构应该是数据相关的,然而,ST-GCN不支持这一点。
  • 每个顶点附加的特征向量只包含关节的2D或3D坐标,可视为骨骼数据的一阶信息。然而,表示两个关节之间骨骼特征的二阶信息(骨骼长度和方向)没有得到充分利用,但骨骼的长度和方向自然更能提供信息和辨别动作。

2S-AGCN的改进:

  • 提出了一种自适应图卷积网络,以端到端方式自适应学习不同GCN层和骨架样本的图的拓扑结构,能够更好地适应动作识别任务和GCN的层次结构。

  • 将骨架数据的二阶信息显式表述,并采用双流框架将其与一阶信息相结合,显著提高了识别性能。

4.2 公式原理

4.2.1 图的构建

在这里插入图片描述

  • 在 Graph 的建构上,该论文采用 ST-GCN 的逻辑。
  • 空间上,以关节点当作 Vertexes
  • 而Edges 则是人体生理上的连结 ( 上图橘色点、线 )。
  • 时间轴上的 Edges 则是相同 Vertex 之间的连结 ( 上图同关节之间的蓝色线 ) 。

4.2.2 Graph convolution(图卷积)

在ST-GCN中:

在空间上对一个顶点做 Graph convolution 的公式定义如下:
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

2S-AGCN对整个图做卷积的公式定义如下:
在这里插入图片描述

疑问: W k W_k Wk f i n f_{in} fin以及 M k M_k Mk位置为什么发生变化?

在这里插入图片描述

  • K v K_v Kv表示空间维度上的 Kernel size ( 基于前述定义设定为 3 ) 。
  • ⊙ 则是 Dot product

4.2.3 Adaptive graph convolutional layer(自适应图卷积层)

  • 采用人体骨架做 Predefined graph 并不见得最能表达各种行为
  • 该论文的在 Graph convolution 上加入了 Attention,并提出 Adaptive graph convolutional layer
  • 这使 Graph 的 Topology 可以随著网络的训练一起进行优化,让每个 Layer 变得更独特,借此扩大模型的弹性 。
    在这里插入图片描述

区别在于,论文将Graph的邻接矩阵拆分为了 A k A_k Ak B k B_k Bk C k C_k Ck

  • A k A_k Ak:原始经过 Normalized 的N×N 邻接矩阵,决定的是两个顶点之间是否有连接。

  • B k B_k Bk
    (1)尺寸上也是N×N,每个参数都是可被训练的,因此没有任何的约束,最终这部分的表示会完全取决于训练资料,也就能依据不同行为类别而有所差异。
    (2)矩阵中的元素可以是任意值。它不仅表明两个节点之间是否存在连接,而且表明连接的强度。

  • C k C_k Ck:这部分是个 Data-dependent graph ,会针对 Graph 上的每个 Sample 去学习 。为了决定两个顶点之间的连结强度,使用 Dot product 计算两顶点在 Embedding space 的相似度,所以嵌入的是个 Gaussian function :

这个公式计算的是 V i V_i Vi V j V_j Vj之间的相似度:
在这里插入图片描述

这个公式实际上是上面这个公式的矩阵化,计算所有节点彼此之间的相似度

在这里插入图片描述

如图所示是输出的计算过程:
在这里插入图片描述

M k M_k Mk上的缺点:
若原本 A k A_k Ak上有些地方是0(代表非骨架的连结),那从训练开始到结束都会是0, M k M_k Mk只能调整已有的连接的强度,而没办法产生出新的连结(限制模型去看骨架以外的关联性)。

4.3 AGCN网络结构

4.3.1 Graph的定义

在tools.py中定义了一些必要的函数:

import numpy as np  
  
def edge2mat(link, num_node):  
    """  
    根据给定的连接列表和节点数,生成邻接矩阵。  
    :param link: 连接列表,每个元素是一个元组(i, j),表示从节点i到节点j有一条边。  
    :param num_node: 图中的节点数。  
    :return: 生成的邻接矩阵A,其中A[j, i] = 1表示从节点i到节点j有一条边。  
    """  
    A = np.zeros((num_node, num_node))  
    for i, j in link:  
        A[j, i] = 1  
    return A  
  
def normalize_digraph(A):  
    """  
    对给定的有向图的邻接矩阵进行归一化处理。  
    :param A: 有向图的邻接矩阵。  
    :return: 归一化后的邻接矩阵AD,其中AD[i, j] = A[i, j] / sum(A[:, j])。  
    """  
    Dl = np.sum(A, 0)  # 计算每列的和  
    h, w = A.shape  
    Dn = np.zeros((w, w))  
    for i in range(w):  
        if Dl[i] > 0:  
            Dn[i, i] = Dl[i] ** (-1)  # 计算每列和的倒数  
    AD = np.dot(A, Dn)  # 归一化处理  
    return AD  
  
def get_spatial_graph(num_node, self_link, inward, outward):  
    """  
    :return: 一个堆叠的邻接矩阵A
    """  
    I = edge2mat(self_link, num_node)  # 自连接的邻接矩阵  
    In = normalize_digraph(edge2mat(inward, num_node))  # 入度的归一化邻接矩阵  
    Out = normalize_digraph(edge2mat(outward, num_node))  # 出度的归一化邻接矩阵  
    A = np.stack((I, In, Out))  # 堆叠成最终的邻接矩阵  
    return A

在这里插入图片描述
左图是Kinetics-Skeleton数据集骨骼点示意图,右图是NTU-RGBD数据集骨骼点示意图。
以左图为例:在Kinetics.py中定义了Kinetics骨骼序列图是如何构建的

import numpy as np  
import sys  
  
# 将上级目录添加到系统路径中,以便导入自定义模块  
sys.path.extend(['../'])  
from graph import tools  
import networkx as nx  
  
# 定义关节索引,每个关节都有一个唯一的索引和对应的名称  
# Joint index:
# {0,  "Nose"}
# {1,  "Neck"},
# {2,  "RShoulder"},
# {3,  "RElbow"},
# {4,  "RWrist"},
# {5,  "LShoulder"},
# {6,  "LElbow"},
# {7,  "LWrist"},
# {8,  "RHip"},
# {9,  "RKnee"},
# {10, "RAnkle"},
# {11, "LHip"},
# {12, "LKnee"},
# {13, "LAnkle"},
# {14, "REye"},
# {15, "LEye"},
# {16, "REar"},
# {17, "LEar"},  
  
# 定义边的格式:(起点, 终点)  
num_node = 18  # 图中节点的总数  
self_link = [(i, i) for i in range(num_node)]  # 自连接,即每个节点都连接到自己  
# 定义向内连接的边,即每个节点与向心节点的连接对
inward = [(4, 3), (3, 2), (7, 6), (6, 5), (13, 12), (12, 11), (10, 9), (9, 8),
          (11, 5), (8, 2), (5, 1), (2, 1), (0, 1), (15, 0), (14, 0), (17, 15),
          (16, 14)]
# 定义向外连接的边,即每个节点与离心节点的连接对 
outward = [(j, i) for (i, j) in inward]  
# 邻居节点,包括向内和向外连接的节点  
neighbor = inward + outward  
"""
以3号节点为例,解释inward 和outward:
AS-GCN将采样区域划分为三类:根节点、近心节点、离心节点
对应的就是三种不同的邻接矩阵:根节点与自身的连接、根节点与邻居节点中近心节点的连接、根节点与邻居节点中离心节点的连接

在左图中,3号节点有2和4两个邻居节点,其中2号节点较3号节点更接近中心节点,4号节点较3号节点更远离中心节点
因此(4,3)、(3,2)被添加到了inward 中,将inward以矩阵A的形式表达出来,A[4,3]=1,A[3,2]=1表示的含义就是3是4邻居中的近心节点,2是3邻居中的近心节点,

同理(3,4)、(2,3)被添加到outward ,所转变的邻接矩阵表达的就是根节点与邻居节点中离心节点的连接

所以最后的 A 是由 单位矩阵、每个节点相邻的向心节点 和 每个节点相邻的离心节点 堆叠而成的。
"""
class Graph:  
    def __init__(self, labeling_mode='spatial'):  
        # 初始化图对象时,根据指定的标记模式获取邻接矩阵  
        self.A = self.get_adjacency_matrix(labeling_mode)  
        self.num_node = num_node  # 图中节点的总数  
        self.self_link = self_link  # 自连接的边  
        self.inward = inward  # 向内连接的边  
        self.outward = outward  # 向外连接的边  
        self.neighbor = neighbor  # 邻居节点  
  
    def get_adjacency_matrix(self, labeling_mode=None):  
        # 根据指定的标记模式获取邻接矩阵  
        if labeling_mode is None:  
            return self.A  # 如果没有指定标记模式,则返回已有的邻接矩阵  
        if labeling_mode == 'spatial':  
            # 如果标记模式为'spatial',则使用tools模块中的get_spatial_graph函数生成空间图的邻接矩阵  
            A = tools.get_spatial_graph(num_node, self_link, inward, outward)  
        else:  
            # 如果标记模式不是'spatial',则抛出值错误异常  
            raise ValueError()  
        return A  # 返回生成的邻接矩阵

在AS-GCN中,max_hop被设置为2、3、4,max_hop不同,S-link的邻接矩阵通道数k是否也不同?还是k仍保持为3?从代码中看应该是仍保持为3的。

  • 与AGCN相比,AS-GCN中S-link的邻接矩阵同样也是将采样区域划分为三类,不过它的采样区域更大,能够看到更远的连接,AGCN的 A k A_k Ak只能看到周围一跳区域内的连接,更远的连接要依靠 B k B_k Bk C k C_k Ck
  • 因此这应该就是AGCN构建Graph的代码发生较大变化的原因,因为它不需要计算多跳的区域、再将这个区域划分子集,因为AS-GCN的 A k A_k Ak只看一跳,所以在预定义的时候就能将区域找出来、把子集划分出来。
    - B k B_k Bk完全靠训练得到, C k C_k Ck会计算不同节点之间的关系,感觉 C k C_k Ck有点像AGCN中A-links的作用。

ntu_rgb.py与Kinetics.py的唯一区别就是顶点和边的定义:

import sys

sys.path.extend(['../'])
from graph import tools

num_node = 25
self_link = [(i, i) for i in range(num_node)]
inward_ori_index = [(1, 2), (2, 21), (3, 21), (4, 3), (5, 21), (6, 5), (7, 6),
                    (8, 7), (9, 21), (10, 9), (11, 10), (12, 11), (13, 1),
                    (14, 13), (15, 14), (16, 15), (17, 1), (18, 17), (19, 18),
                    (20, 19), (22, 23), (23, 8), (24, 25), (25, 12)]
inward = [(i - 1, j - 1) for (i, j) in inward_ori_index]
outward = [(j, i) for (i, j) in inward]
neighbor = inward + outward


class Graph:
    def __init__(self, labeling_mode='spatial'):
        self.A = self.get_adjacency_matrix(labeling_mode)
        self.num_node = num_node
        self.self_link = self_link
        self.inward = inward
        self.outward = outward
        self.neighbor = neighbor

    def get_adjacency_matrix(self, labeling_mode=None):
        if labeling_mode is None:
            return self.A
        if labeling_mode == 'spatial':
            A = tools.get_spatial_graph(num_node, self_link, inward, outward)
        else:
            raise ValueError()
        return A

4.3.2 AGCN的定义

在这里插入图片描述

对应论文里的图(模型的定义多了一层 l4? )


class Model(nn.Module):  
    def __init__(self, num_class=60, num_point=25, num_person=2, graph=None, graph_args=dict(), in_channels=3):  
        super(Model, self).__init__()  
  
        # 检查graph是否为None,如果不是,则使用graph参数指定的类来创建一个图对象  
        if graph is None:  
            raise ValueError()  
        else:  
            Graph = import_class(graph)  
            self.graph = Graph(**graph_args)  
  
        # 从图中获取邻接矩阵A  
        A = self.graph.A  
        # 初始化批量归一化层  
        self.data_bn = nn.BatchNorm1d(num_person * in_channels * num_point)  
  
        # 定义一系列的TCN_GCN_unit层,用于特征提取  
        self.l1 = TCN_GCN_unit(3, 64, A, residual=False)  
        self.l2 = TCN_GCN_unit(64, 64, A)  
        self.l3 = TCN_GCN_unit(64, 64, A)  
        self.l4 = TCN_GCN_unit(64, 64, A)  
        self.l5 = TCN_GCN_unit(64, 128, A, stride=2)  
        self.l6 = TCN_GCN_unit(128, 128, A)  
        self.l7 = TCN_GCN_unit(128, 128, A)  
        self.l8 = TCN_GCN_unit(128, 256, A, stride=2)  
        self.l9 = TCN_GCN_unit(256, 256, A)  
        self.l10 = TCN_GCN_unit(256, 256, A)  
  
        # 定义全连接层,用于分类  
        self.fc = nn.Linear(256, num_class)  
        # 初始化全连接层的权重  
        nn.init.normal_(self.fc.weight, 0, math.sqrt(2. / num_class))  
        # 初始化批量归一化层  
        bn_init(self.data_bn, 1)  
  
    def forward(self, x):  
        # 输入x的尺寸:批次大小N,通道数C,时间步长T,节点数V,人物数M  
        N, C, T, V, M = x.size()  
  
        # 调整输入x的维度,以适应网络结构  
        x = x.permute(0, 4, 3, 1, 2).contiguous().view(N, M * V * C, T)  
        x = self.data_bn(x)  
        x = x.view(N, M, V, C, T).permute(0, 1, 3, 4, 2).contiguous().view(N * M, C, T, V)  
  
        # 通过一系列的TCN_GCN_unit层进行特征提取  
        x = self.l1(x)  
        x = self.l2(x)  
        x = self.l3(x)  
        x = self.l4(x)  
        x = self.l5(x)  
        x = self.l6(x)  
        x = self.l7(x)  
        x = self.l8(x)  
        x = self.l9(x)  
        x = self.l10(x)  
  
        # 调整维度,以便进行全局平均池化  
        c_new = x.size(1)  
        x = x.view(N, M, c_new, -1)  
        x = x.mean(3).mean(1)  
  
        # 通过全连接层进行分类  
        return self.fc(x)

4.3.3 TCN_GCN_unit

在这里插入图片描述

  • 每个 TCN_GCN_unit 是由一个 unit_gcn 和 一个 unit_tcn 组成的。还有额外的 residual。
  • 如果输入输出通道数一致 且 stride = 1,那么 residual 为传入进来的参数 x 本身
  • 如果输入输出通道数不一致 或者 且 stride ≠ 1,对传入进来的参数使用一个1x1的unit_tcn调整输入x的维度,以匹配输出x的维度 。

class TCN_GCN_unit(nn.Module):  
    def __init__(self, in_channels, out_channels, A, stride=1, residual=True):  
        super(TCN_GCN_unit, self).__init__()  
  
        # 初始化图卷积网络单元,用于处理图结构数据  
        self.gcn1 = unit_gcn(in_channels, out_channels, A)  # 假设unit_gcn是一个已经定义的GCN单元  
  
        # 初始化时间卷积网络单元,用于处理时间序列数据  
        self.tcn1 = unit_tcn(out_channels, out_channels, stride=stride)  # 假设unit_tcn是一个已经定义的TCN单元  
  
        # 激活函数,这里使用ReLU  
        self.relu = nn.ReLU()  
  
        # 根据是否启用残差连接以及输入输出通道数和步长,设置残差连接的方式  
        if not residual:  
            # 如果不启用残差连接,则残差函数返回0  
            self.residual = lambda x: 0  
  
        elif (in_channels == out_channels) and (stride == 1):  
            # 如果输入输出通道数相同且步长为1,则直接返回输入x作为残差  
            self.residual = lambda x: x  
  
        else:  
            # 否则,使用一个1x1的时间卷积来调整输入x的维度,以匹配输出x的维度  
            self.residual = unit_tcn(in_channels, out_channels, kernel_size=1, stride=stride)  
  
    def forward(self, x):  
        # 首先,通过GCN单元处理图结构数据  
        x_gcn = self.gcn1(x)  
  
        # 然后,通过TCN单元处理时间序列数据  
        x_tcn = self.tcn1(x_gcn)  
  
        # 接着,计算残差连接  
        residual = self.residual(x)  
  
        # 将TCN的输出与残差相加  
        out = x_tcn + residual  
  
        # 最后,应用ReLU激活函数  
        return self.relu(out)

4.3.4 unit_gcn和unit_tcn

一些函数没有定义在两个类里:

# 导入指定名称的类  
# 参数:  
#   name: 一个字符串,表示要导入的类的完整路径,例如'torch.nn.Conv2d'  
# 返回值:  
#   返回指定的类对象  
def import_class(name):  
    # 将类名字符串按'.'分割成组件  
    components = name.split('.')  
    # 导入第一个组件(通常是模块名)  
    mod = __import__(components[0])  
    # 遍历剩余的组件,通过getattr逐级访问,直到找到最终的类  
    for comp in components[1:]:  
        mod = getattr(mod, comp)  
    # 返回找到的类  
    return mod  
  
# 初始化卷积分支的权重和偏置  
# 参数:  
#   conv: 一个卷积层对象,如nn.Conv2d  
#   branches: 分支的数量,用于调整权重初始化的标准差  
# 备注:  
#   该函数使用正态分布初始化权重,标准差根据输入特征数、卷积核大小和分支数调整,以维持输出方差的一致性。  
#   偏置被初始化为0。  
def conv_branch_init(conv, branches):  
    weight = conv.weight  
    n = weight.size(0)  # 输入通道数  
    k1 = weight.size(1)  # 卷积核高度  
    k2 = weight.size(2)  # 卷积核宽度  
    # 使用正态分布初始化权重,标准差根据公式计算  
    nn.init.normal_(weight, 0, math.sqrt(2. / (n * k1 * k2 * branches)))  
    # 偏置初始化为0  
    nn.init.constant_(conv.bias, 0)  
  
# 初始化卷积层的权重和偏置  
# 参数:  
#   conv: 一个卷积层对象,如nn.Conv2d  
# 备注:  
#   该函数使用Kaiming初始化(也称为He初始化)来初始化权重,适用于ReLU激活函数。  
#   偏置被初始化为0。  
def conv_init(conv):  
    # 使用Kaiming初始化权重,'fan_out'模式保持前向传播时激活值的方差一致  
    nn.init.kaiming_normal_(conv.weight, mode='fan_out')  
    # 偏置初始化为0  
    nn.init.constant_(conv.bias, 0)  
  
# 初始化批量归一化层的权重和偏置  
# 参数:  
#   bn: 一个批量归一化层对象,如nn.BatchNorm2d  
#   scale: 权重初始化的值  
# 备注:  
#   该函数将批量归一化层的权重初始化为指定的scale值,偏置初始化为0。  
def bn_init(bn, scale):  
    # 权重初始化为scale  
    nn.init.constant_(bn.weight, scale)  
    # 偏置初始化为0  
    nn.init.constant_(bn.bias, 0)

unit_gcn

# conv_init, bn_init, conv_branch_init 是上方自定义的初始化函数  
  
class unit_gcn(nn.Module):  
    def __init__(self, in_channels, out_channels, A, coff_embedding=4, num_subset=3):  
        super(unit_gcn, self).__init__()  
        # 计算中间通道数  
        inter_channels = out_channels // coff_embedding  
        self.inter_c = inter_channels  
          
        # 初始化可学习的图邻接矩阵PA  
        self.PA = nn.Parameter(torch.from_numpy(A.astype(np.float32)))  
        nn.init.constant_(self.PA, 1e-6)  # 初始化为很小的值  
          
        # 不可学习的图邻接矩阵A  
        self.A = nn.Parameter(torch.from_numpy(A.astype(np.float32)), requires_grad=False)  
          
        # 子集数量  
        self.num_subset = num_subset  
  
        # 初始化多个卷积层,用于不同的子集  
        self.conv_a = nn.ModuleList()  
        self.conv_b = nn.ModuleList()  
        self.conv_d = nn.ModuleList()  
        for i in range(self.num_subset):  
            self.conv_a.append(nn.Conv2d(in_channels, inter_channels, 1))  
            self.conv_b.append(nn.Conv2d(in_channels, inter_channels, 1))  
            self.conv_d.append(nn.Conv2d(in_channels, out_channels, 1))  
  
        # 如果输入和输出通道数不同,则添加一个下采样层  
        if in_channels != out_channels:  
            self.down = nn.Sequential(  
                nn.Conv2d(in_channels, out_channels, 1),  
                nn.BatchNorm2d(out_channels)  
            )  
        else:  
            self.down = lambda x: x  # 否则直接返回输入  
  
        # 批量归一化层  
        self.bn = nn.BatchNorm2d(out_channels)  
        # Softmax层,用于归一化注意力权重  
        self.soft = nn.Softmax(dim=-2)  
        # 激活函数  
        self.relu = nn.ReLU()  
  
        # 初始化卷积层和批量归一化层  
        for m in self.modules():  
            if isinstance(m, nn.Conv2d):  
                conv_init(m)  
            elif isinstance(m, nn.BatchNorm2d):  
                bn_init(m, 1)  
        bn_init(self.bn, 1e-6)  # 特别初始化bn层  
        for i in range(self.num_subset):  
            conv_branch_init(self.conv_d[i], self.num_subset)  # 特别初始化conv_d层  
  
    def forward(self, x):  
        # 获取输入x的维度  
        N, C, T, V = x.size()  
        # 将不可学习的图邻接矩阵A移动到与x相同的设备上  
        A = self.A.cuda(x.get_device())  
        # 将可学习的PA加到A上  
        A = A + self.PA  
  
        # 初始化输出y  
        y = None  
        for i in range(self.num_subset):  
            # 通过conv_a和conv_b计算注意力权重Ck 
            A1 = self.conv_a[i](x).permute(0, 3, 1, 2).contiguous().view(N, V, self.inter_c * T)  
            A2 = self.conv_b[i](x).view(N, self.inter_c * T, V)  
            A1 = self.soft(torch.matmul(A1, A2) / A1.size(-1))  # 计算注意力权重并归一化 
            ###
             
            A1 = A1 + A[i]  # 将注意力权重与原始图邻接矩阵结合  
              
            # 通过conv_d和注意力权重A1计算输出z  
            A2 = x.view(N, C * T, V)  
            z = self.conv_d[i](torch.matmul(A2, A1).view(N, C, T, V))
            #if y is not None:y = z + y  else: y=z
            y = z + y if y is not None else z

        y = self.bn(y)
        #这里实际上就是残差连接,只不过这里的残差连接是有条件的,具体条件看上方对self.down的定义
        y += self.down(x)
        return self.relu(y)

在这里插入图片描述

按图解释unit_gcn的forward中的代码

  • 代码里的 A 对应图中的 Ak
  • 代码里的 self.PA 对应图中的 Bk(这里有点奇怪,明明论文里说的是初始化为 0,但是代码里初始化为 1e-6)
  • 代码里的 self.conv_a 对应图中的 θk
  • 代码里的 self.conv_b 对应图中的 φk
  • 代码里的 self.conv_d 对应图中的 wk

unit_tcn

class unit_tcn(nn.Module):  
    def __init__(self, in_channels, out_channels, kernel_size=9, stride=1):  
        super(unit_tcn, self).__init__()  
        # 计算padding以保持特征图尺寸不变  
        pad = int((kernel_size - 1) / 2)  
        # 创建一个二维卷积层,用于在时间维度上进行卷积
        self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=(kernel_size, 1), padding=(pad, 0), stride=(stride, 1))  
  
        # 批量归一化层  
        self.bn = nn.BatchNorm2d(out_channels)  
        # 激活函数  
        self.relu = nn.ReLU()  
        # 初始化卷积层的权重和偏置  
        conv_init(self.conv)  
        # 初始化批量归一化层的权重(通常设置为1)  
        bn_init(self.bn, 1)  
  
    def forward(self, x):  
        # 通过卷积层、批量归一化层和激活函数处理输入  
        x = self.bn(self.conv(x))  
        return x

疑问:
unit_tcn中定义了self.relu = nn.ReLU() ,但是为什么最后没用?
在这里插入图片描述

4.4 双流网络

在这里插入图片描述
关节数量=骨骼数量+1
(1)将骨架中重心近的顶点定义为 Source joint ,相对的,较远的点就是Target joint,而每一段骨架都是一个从 Source 指向 Target 的向量,每一块骨骼都可以分配一个唯一的Target joint,中央关节不分配给任何骨骼,在中央关节处添加一个为0的空骨头
(2)给定一个样本,根据关节的数据计算骨骼的数据。然后,将关节数据和骨骼数据分别输入J-Stream和B-Stream。
最后,将两个流的softmax分数相加,得到融合分数,并预测动作标签。

五、MS-G3D

以往的工作:

  • 现有工作都是使用 spatial GCN 和 temporal GCN 交替进行提取视频骨架点的动作信息,或者是在每一帧上进行 spatial GCN 来提取空间信息再用 RNN 等方法对每一帧提取出来的特征进行时序维度上信息的学习。然而这些都不是真正意义上的三维卷积。

  • 作者还认为原来使用邻接矩阵 A 的高阶幂(Ak)会导致 bias weighting 的问题,即当对一个节点求 K 阶邻居时,对它的 K 阶邻居而言,距离该节点近且度大的权重会比较高。

MS-G3D:

  • 提出了一种时空图卷积的 G3D 算子:把原来的邻接矩阵 A 替换为相邻 τ 帧邻接矩阵合成的一个大邻接矩阵 τN × τN,再对其进行图卷积,确实是聚合了局部时间段内的信息

  • 提出了一种新的多尺度聚合的方法:消除掉远邻权重对近邻的冗余依赖,即如图 2,除了 k 阶领域和节点本身外,其他节点的权重均为 0。

5.1 Preliminaries

5.1.1 Notations

人体骨架图记为:

  • 骨架图: G ( V , E ) \mathcal{G}(V, E) G(V,E),其中 V V V n n n 个身体关节的集合, E E E m m m个骨头的集合。
  • 邻接矩阵: A ∈ { 0 , 1 } n × n A\in \lbrace0,1\rbrace^{n\times n} A{0,1}n×n 。如果第 i i i和第 j j j个关节连接,则 A i j = 1 A_{ij}=1 Aij=1,否则为0。
  • 对角度矩阵: D ∈ R n × n D\in\mathbb{R}^{n\times n} DRn×n。其中, D i , i = ∑ j A i , j D_{i,i} = ∑_j A_{i,j} Di,i=jAi,j

5.1.2 Graph Convolutional Nets (GCNs)

在这里插入图片描述

在这里插入图片描述

5.2 Disentangled Multi-Scale Aggregation

在这里插入图片描述
这段话翻译过来就是:

偏置加权问题:在公式(1)的空间聚合框架下,现有方法[21]利用邻接矩阵的高阶多项式来聚合t时刻的多尺度结构信息,如下:

其中[21]就是AS-GCN这篇论文,这个问题应该是针对于AS-GCN中的S-links提出的,详情可以跳转本文3.4 Structural Links (S-links)
s-links的公式为:
在这里插入图片描述
我不明白的是:s-links只在计算距离矩阵时使用了邻接矩阵的高阶多项式,然后根据距离矩阵初始化得到一个包含多跳信息新的邻接矩阵,随后对这个新的矩阵做归一化处理,再将归一化处理后的邻接矩阵进行拆分。

我找到了下面两种解释:

现有的一些方法利用邻接矩阵的高阶多项式来聚合多尺度的结构信息,为了获得表征k-hops的邻接矩阵,一些方法采用对邻接矩阵A进行矩阵求k次方,即 A k A^k Ak

def get_hop_distance(num_node, edge, max_hop=1):
    A = np.zeros((num_node, num_node))
    for i, j in edge:
        A[j, i] = 1
        A[i, j] = 1

    hop_dis = np.zeros((num_node, num_node)) + np.inf
    transfer_mat = [np.linalg.matrix_power(A, d) for d in range(max_hop + 1)]
    arrive_mat = (np.stack(transfer_mat) > 0)
    for d in range(max_hop, -1, -1):
        hop_dis[arrive_mat[d]] = d
    return hop_dis

上面代码中,作者采用了np.linalg.matrix_power实现对邻接矩阵k次方的计算。

作者认为这样对邻接矩阵 A A A题,即在特征聚合过程中赋予近点较大权重而忽略远点的权重,如下图所示(红点为根节点,颜色越深权重越大)。这样对一些更关注双手动作的行为识别并不友好,因为双手的关节点在骨骼图中具有较远的距离。
在这里插入图片描述
为了解决有偏加权问题,作者定义如下k-邻接矩阵 A ^ ( k ) \hat A_{(k)} A^(k)

在这里插入图片描述
其中 d ( v i , v j ) d(v_i,v_j) d(vi,vj)是节点 v i v_i vi和节点 v j v_j vj的最短步数,且在这里插入图片描述
利用图权的差分即可快速计算出k-邻接矩阵 A ^ ( k ) \hat A_{(k)} A^(k)
在这里插入图片描述

以这种方式计算出的k-邻接矩阵 A ^ ( k ) \hat A_{(k)} A^(k)及其表示的节点关联性如下图所示
在这里插入图片描述

另一种解释是:
这种多项式可以看做一种因式分解,我们举个例子:假如 𝑥3+𝑥2+𝑥+1 代表包含中心节点( 𝑥0 )、距离中心节点 1 跳的节点( 𝑥1 )、 2 跳的节点( 𝑥2 )和 3 跳的节点( 𝑥3 ),因为代表阶次的 𝐾 是从 0 到 𝐾 的,所以 𝐾=0 时只包含 𝑥0 ; 𝐾=1 时包含 𝑥+1 ,这时 1 就是多余的; 𝐾=3 时包含 𝑥3+𝑥2+𝑥+1 ,这时 𝑥2+𝑥+1 就是多余的。由于物理距离的原因,相近的节点无论在哪个阶次都占很大的权重,这种现象就叫做有偏权重问题。如下图,节点 1 代表中心节点,颜色深浅代表权重大小,颜色越深,权重越大。 𝐴~1 中只包含中心节点与相距 1 跳的节点; 𝐴~2 中,相距 2 跳的节点的权重小于 1 跳的节点;同样, 𝐴~3 中, 相距 3 跳节点的权重小于 1 跳和 2 跳的。
在这里插入图片描述

带入新的邻接矩阵得到的公式为:
在这里插入图片描述

5.3 G3D: Unified Spatial-Temporal Modeling

之前的大多数模型说的是时空图卷积,其实它们的空域卷积和时域卷积并没有一起做,而是分开单独做,比如先做空域卷积(GCN)再做时域卷积(TCN)
在这里插入图片描述
这里可以看出,如果将GCN和TCN按顺序单独使用的话,那么空域的信息会先通过GCN沿着红色线流入中心节点,再通过TCN再中心节点的时间轴(蓝色线上流动),作者把这种使用形式的时空图卷积mode叫做factorized model。而这不是作者想要的,作者想要的效果是这样:
在这里插入图片描述
也就是说相邻节点在不同时间的信息一次性流入卷积重心点(黑色点)上,那么为什么上面那个分开流动用不如这种一起流动呢?作者给了一个很生动的例子:

For example, the action “standing up” often has co-occurring movements of upper and lower body across both space and time, where upper body movements (leaning forward) strongly correlate to the lower body’s future movements (standing up). These strong cues for making predictions may be ineffectively captured by factorized modeling.
比如“站起来”这个动作,上半身的动作(前倾)和下本身未来的动作(站起来)存在很强的关联性。上半身前倾能对下半身站起来动作很大程度上起到预测作用。这种关联性factorized model捕捉不到。

那么怎么从数学上,也就是说矩阵上来实现作者想要的这个算子呢?
在这里插入图片描述
上图的 A ~ \tilde{A} A~默认为一阶邻接矩阵。作者通过这个参数τ来构建时间跨度为τ的卷积核算子,具体的做法是将 A ~ \tilde{A} A~在矩阵的两个维度上堆叠来构建具有时间跨度的卷积算子
A τ ~ \tilde{A_τ} Aτ~,这个地方比较难理解,我画了一张图来帮助理解,我们假设现在我们只看腿部的3个点:
在这里插入图片描述
同时对输入也进行相同大小为τ滑动窗口的采样,具体为:
在这里插入图片描述
这里还提到了Dilated Windows间隔采样,间隔采样的好处是在不增加τ的基础上获取时间上更大的感受野(Dilated windows allow larger temporal receptive fields without growing the size of A(τ)),综上,单尺度(k=1)G3D的卷积公式为:
在这里插入图片描述

5.4 Multi-Scale G3D

将上面所讲的多尺度和G3D算子整合到一个公式里面可以得到:
在这里插入图片描述

5.5 MS-G3D网络结构

5.5.1 整体架构

在这里插入图片描述

  • 模型总体由r个STGC模块来提取特征,后接一个全局平均池化层和softmax分类器。
  • 在典型的r = 3块体系结构中,它们分别具有96、192和384个特征通道。
  • 批归一化和ReLU添加到除了最后一层以外的每一层末尾。
  • 除第一个块外,所有STGC块均使用步幅2的时间转换和滑动窗口对时间维度进行下采样。

批归一化和ReLU添加到除了最后一层以外的每一层末尾。
这一块的代码在哪?
x = F.relu(self.sgcn1(x) + self.gcn3d1(x), inplace=True)
ReLu在这里,但是最后一层也有啊

5.5.2 STGC Block

每个STGC块均部署两种类型的路径,以同时捕获复杂的区域时空联合相关性以及远程时空相关性:

  • G3D-pathway:不同尺度以及不同时间窗的MS-G3D模块,同步提取不同空间尺度、不同时间跨度的时空信息。G3D路径首先构造时空窗口,对其进行解纠缠的多尺度图卷积,然后用全连接层将其折叠以读取窗口特征。 额外的虚线G3D路径表明该模型可以同时从多个具有不同的τ和d的时空背景中学习。
  • factorized pathway:MS-GCN+MS-TCN+MS-TCN,先提取空间信息再提取时间信息,对G3D路径进行了远程、纯时间、纯空间模块的增强,第一层是多尺度图卷积层,能够对具有最大K的整个骨架图进行建模; 然后是两个多尺度时间卷积层,以捕获扩展的时间上下文(在下面讨论)。来自所有路径的输出被汇总为STGC块输出
  • 其中MS-TCN模块借鉴了GoogLeNet的Incepetion结构,用来减少参数。
    在这里插入图片描述

5.5.3 G3D

在这里插入图片描述

  • 1、在时间维度滑动大小为τ,空洞间隔为d的时间窗,滑动步幅stride=2,获得 T o u t T_{out} Tout个时间窗;每个时间窗由τ个骨骼图(每个骨骼图的尺度为 N × C i n N×C_{in} N×Cin)构成,因此输出的张量 x τ x_τ xτ的维度为 T o u t × τ N × C i n T_{out}×τN×C_{in} Tout×τN×Cin,(合并了特征维和空间维)

τ d stride=2各自有什么含义
τ是采样的帧数,d是每隔几帧采样

  • 2、将标准化的邻接矩阵 A ~ \tilde{A} A~重复N×N次,获得 A ~ τ \tilde{A}_τ A~τ,其大小为 τ N × τ N τN×τN τN×τN
  • 3、因为时间信息已经嵌入到 A ~ τ \tilde{A}_τ A~τ中,根据爱因斯坦求和约定,利用MS-GCN可直接聚合τ个骨骼图上的时空信息。
  • 4、按照时间窗的时间维度,利用三维卷积对(3)的输出张量进行特征提取与压缩(称为collapse window)。

这一模块的代码是:

class MS_G3D(nn.Module):
    """
    多尺度时空图卷积网络(Multi-Scale Spatial-Temporal Graph Convolutional Network)

    参数:
        in_channels (int): 输入特征的通道数。
        out_channels (int): 输出特征的通道数。
        A_binary (tensor): 图的邻接矩阵,表示节点间的连接关系,通常为二进制形式。
        num_scales (int): 多尺度图卷积中使用的尺度数量。
        window_size (int): 时间窗口的大小,用于提取局部时间特征。
        window_stride (int): 时间窗口的步长。
        window_dilation (int): 时间窗口的膨胀率。
        embed_factor (int, optional): 嵌入因子,用于调整特征嵌入的维度。默认为1。
        activation (str, optional): 激活函数的类型。默认为'relu'。
    """

    def __init__(self,
                 in_channels,
                 out_channels,
                 A_binary,
                 num_scales,
                 window_size,
                 window_stride,
                 window_dilation,
                 embed_factor=1,
                 activation='relu'):
        super().__init__()
        # 设置时间窗口的大小
        self.window_size = window_size
        # 设置输出特征的通道数
        self.out_channels = out_channels
        # 嵌入因子用于调整特征嵌入的维度
        self.embed_channels_in = self.embed_channels_out = out_channels // embed_factor
        # 如果嵌入因子为1,则直接使用输入通道数
        if embed_factor == 1:
            self.in1x1 = nn.Identity()  # 1x1卷积,相当于恒等操作
            self.embed_channels_in = self.embed_channels_out = in_channels
            # 第一个STGC块立即改变通道数;其他块在折叠时改变
            if in_channels == 3:
                self.embed_channels_out = out_channels
        else:
            # 使用MLP进行特征嵌入
            self.in1x1 = MLP(in_channels, [self.embed_channels_in])

        # 定义3D图卷积网络
        self.gcn3d = nn.Sequential(
            # 展开时间窗口
            UnfoldTemporalWindows(window_size, window_stride, window_dilation),
            # 应用多尺度时空图卷积
            SpatialTemporal_MS_GCN(
                in_channels=self.embed_channels_in,
                out_channels=self.embed_channels_out,
                A_binary=A_binary,
                num_scales=num_scales,
                window_size=window_size,
                use_Ares=True  # 是否使用邻接矩阵的稀疏表示
            )
        )

        # 定义输出卷积层
        self.out_conv = nn.Conv3d(self.embed_channels_out, out_channels, kernel_size=(1, self.window_size, 1))
        # 定义批量归一化层
        self.out_bn = nn.BatchNorm2d(out_channels)

    def forward(self, x):
        # 获取输入数据的维度信息
        N, _, T, V = x.shape
        # 应用1x1卷积或特征嵌入
        x = self.in1x1(x)
        # 构建时间窗口并应用多尺度图卷积
        x = self.gcn3d(x)

        # 折叠时间窗口维度
        x = x.view(N, self.embed_channels_out, -1, self.window_size, V)
        # 应用输出卷积
        x = self.out_conv(x).squeeze(dim=3)  # 去除单维度
        # 应用批量归一化
        x = self.out_bn(x)

        # 不添加激活函数
        return x

不明白 embed_factor (int, optional): 嵌入因子,用于调整特征嵌入的维度。默认为1,是用来干吗的,好像它一直是1

其中的核心是 x = self.gcn3d(x)

self.gcn3d = nn.Sequential(
            UnfoldTemporalWindows(window_size, window_stride, window_dilation),
            SpatialTemporal_MS_GCN(
                in_channels=self.embed_channels_in,
                out_channels=self.embed_channels_out,
                A_binary=A_binary,
                num_scales=num_scales,
                window_size=window_size,
                use_Ares=True
            )
        )

其中,UnfoldTemporalWindows位于ms_gcn.py文件中,其作用是:

在时间维度滑动大小为τ,空洞间隔为d的时间窗,滑动步幅stride=2,获得 T o u t T_{out} Tout个时间窗;每个时间窗由τ个骨骼图(每个骨骼图的尺度为 N × C i n N×C_{in} N×Cin)构成,因此输出的张量 x τ x_τ xτ的维度为 T o u t × τ N × C i n T_{out}×τN×C_{in} Tout×τN×Cin,(合并了特征维和空间维)

class UnfoldTemporalWindows(nn.Module):
    def __init__(self, window_size, window_stride, window_dilation=1):
        super().__init__()
        self.window_size = window_size
        self.window_stride = window_stride
        self.window_dilation = window_dilation

        #window_size + (window_size-1) * (window_dilation-1) 空洞卷积实际卷积核大小
        #不改变尺寸计算填充数
        self.padding = (window_size + (window_size-1) * (window_dilation-1) - 1) // 2
        self.unfold = nn.Unfold(kernel_size=(self.window_size, 1),
                                dilation=(self.window_dilation, 1),
                                stride=(self.window_stride, 1),
                                padding=(self.padding, 0))

    def forward(self, x):
        # Input shape: (N,C,T,V), out: (N,C,T,V*window_size)
        N, C, T, V = x.shape
        x = self.unfold(x)
        # Permute extra channels from window size to the graph dimension; -1 for number of windows
        x = x.view(N, C, self.window_size, -1, V).permute(0,1,3,2,4).contiguous()
        x = x.view(N, C, -1, self.window_size * V)
        return x

torch.nn.Unfold:

  • unfold 是展开的意思,在 torch 中则是只卷不积,相当于只滑窗,不进行元素相乘
    参数:
  • kernel_size: 卷积核的大小
  • dilation: 卷积核元素之间的空洞个数
  • padding: 填充特征四周的列数,默认为 0,则不填充
  • stride: 卷积核移动的步长

具体看这篇博客torch.nn.Unfold直观理解
具体的变化过程实在不懂,以后再补充

SpatialTemporal_MS_GCN同样位于ms_gcn.py文件中,其作用是:

将标准化的邻接矩阵 A ~ \tilde{A} A~重复τ×τ次,然后再重复num_scales次,获得 A ~ τ \tilde{A}_τ A~τ,其大小为[τ×N×num_scales,τ×N]
因为时间信息已经嵌入到 A ~ τ \tilde{A}_τ A~τ中,根据爱因斯坦求和约定,利用MS-GCN可直接聚合τ个骨骼图上的时空信息。

class SpatialTemporal_MS_GCN(nn.Module):
    def __init__(self,
                 in_channels,
                 out_channels,
                 A_binary,
                 num_scales,
                 window_size,
                 disentangled_agg=True,
                 use_Ares=True,
                 residual=False,
                 dropout=0,
                 activation='relu'):

        super().__init__()
        self.num_scales = num_scales
        self.window_size = window_size
        self.use_Ares = use_Ares
        #A的形状由[N,N]变为了[num_nodes*window_size,num_nodes*indow_size]
        A = self.build_spatial_temporal_graph(A_binary, window_size)

        if disentangled_agg:
            A_scales = [k_adjacency(A, k, with_self=True) for k in range(num_scales)]
            A_scales = np.concatenate([normalize_adjacency_matrix(g) for g in A_scales])
        else:
            # Self-loops have already been included in A
            A_scales = [normalize_adjacency_matrix(A) for k in range(num_scales)]
            A_scales = [np.linalg.matrix_power(g, k) for k, g in enumerate(A_scales)]
            A_scales = np.concatenate(A_scales)

        '''
        从这个判断出来之后
        A的形状由[num_nodes*indow_size,num_nodes*indow_size]变为了[num_nodes*window_size*num_scales,num_nodes*indow_size]
        '''
        self.A_scales = torch.Tensor(A_scales)
        self.V = len(A_binary)
        
        '''
        作者还引入了自适应图,即引入了一个与邻接矩阵等大的、可学习的参数
        用来调整时空连接的强弱
        '''
        if use_Ares:
            self.A_res = nn.init.uniform_(nn.Parameter(torch.randn(self.A_scales.shape)), -1e-6, 1e-6)
            '''
            torch.randn(self.A_scales.shape) 生成一个形状与self.A_scales相同的张量,其元素是从标准正态分布中抽取的随机数。
            nn.Parameter 将这个随机张量转换为一个模型参数,这意味着它可以在模型训练过程中被优化。
            nn.init.uniform_ 使用均匀分布初始化这个参数,其范围在-1e-6到1e-6之间。
            '''
        else:
            self.A_res = torch.tensor(0)

        self.mlp = MLP(in_channels * num_scales, [out_channels], dropout=dropout, activation='linear')

        # Residual connection
        if not residual:
            self.residual = lambda x: 0
        elif (in_channels == out_channels):
            self.residual = lambda x: x
        else:
        	#使用残差连接,并且输入和输出的特征维度改变,用一个MLP改变
            self.residual = MLP(in_channels, [out_channels], activation='linear')

        self.act = activation_factory(activation)

    def build_spatial_temporal_graph(self, A_binary, window_size):
    	#使用断言来确保A_binary是一个NumPy数组。如果A_binary不是NumPy数组,将抛出异常。
        assert isinstance(A_binary, np.ndarray), 'A_binary should be of type `np.ndarray`'
        V = len(A_binary)
        V_large = V * window_size
        #在原始的邻接矩阵A_binary中添加自环,即使每个节点都与自己相连
        A_binary_with_I = A_binary + np.eye(len(A_binary), dtype=A_binary.dtype)
        # Build spatial-temporal graph
        #使用np.tile函数将带有自环的邻接矩阵A_binary_with_I平铺window_size次
        A_large = np.tile(A_binary_with_I, (window_size, window_size)).copy()
        return A_large #[num_nodes*indow_size,num_nodes*indow_size]

    def forward(self, x):
        N, C, T, V = x.shape    # T = number of windows

        # Build graphs
        A = self.A_scales.to(x.dtype).to(x.device) + self.A_res.to(x.dtype).to(x.device)

        # Perform Graph Convolution
        res = self.residual(x)
        agg = torch.einsum('vu,nctu->nctv', A, x)
        agg = agg.view(N, C, T, self.num_scales, V)#x的形状变为[N,C,T,num_nodes*window_size,num_scales]
        agg = agg.permute(0,3,1,2,4).contiguous().view(N, self.num_scales*C, T, V)
        '''
        agg.permute(0,3,1,2,4):重新排列agg张量的维度,新的维度顺序是[N, num_nodes*window_size, C, T, num_scales]。
        .contiguous():这个操作确保张量在内存中是连续的
        .view(N, self.num_scales*C, T, V):将重新排列后的张量重塑为新的形状
        最终,agg的形状变为[N, self.num_scales*C, T, V]
        '''
        '''
        self.mlp = MLP(in_channels * num_scales, [out_channels], dropout=dropout, activation='linear')
        然后通过一层MLP恢复原来的通道数
        '''
        out = self.mlp(agg)
        out += res
        return self.act(out)

总的来说

  • 输入:x的形状是[N,C,T,V_1],A_binary形状是[N,N]
  • 通过UnfoldTemporalWindows,x变为[N,C,T,V_1*window_sizes]
  • x进入SpatialTemporal_MS_GCN,形状记为[N,C,T,U]
  • A_binary在SpatialTemporal_MS_GCN变为[num_nodeswindow_sizenum_scales,num_nodes*indow_size],记作 [V,U]
  • AX相乘结果就是[N,C,T,V]
  • 结果的维度变化看代码注释

除此之外,还有一个多窗口的MS-G3D

class MultiWindow_MS_G3D(nn.Module):
    def __init__(self,
                 in_channels,
                 out_channels,
                 A_binary,
                 num_scales,
                 window_sizes=[3,5],
                 window_stride=1,
                 window_dilations=[1,1]):

        super().__init__()
        self.gcn3d = nn.ModuleList([
            MS_G3D(
                in_channels,
                out_channels,
                A_binary,
                num_scales,
                window_size,
                window_stride,
                window_dilation
            )
            for window_size, window_dilation in zip(window_sizes, window_dilations)
            # window_size, window_dilation长度相同,执行n次
            
        ])

    def forward(self, x):
        # Input shape: (N, C, T, V)
        out_sum = 0
        for gcn3d in self.gcn3d:
            out_sum += gcn3d(x)
        # no activation
        return out_sum

5.5.4 factorized pathway

5.5.4.1 MS-GCN

k表示什么?和之前ST-GCN、AS-GCN里的k_num有什么不同?

  • 这里的k是num_scales,输入的A_binary形状是[N,N],经过处理后变为[N*num_scales,N]
  • ST-GCN里的k_num=A.size(0)
  • AS-GCN里的k_num=A.size(0) + self.edge_type
  • 本质上都是一样的东西,都是邻接矩阵的个数,不同之处在于,ST-GCN、AS-GCN是通过分区策略将邻接矩阵拆成了K个

在这里插入图片描述
在ms_gcn.py文件中

class MultiScale_GraphConv(nn.Module):
    def __init__(self,
                 num_scales,
                 in_channels,
                 out_channels,
                 A_binary,
                 disentangled_agg=True,
                 use_mask=True,
                 dropout=0,
                 activation='relu'):
        super().__init__()
        self.num_scales = num_scales

        if disentangled_agg:
            A_powers = [k_adjacency(A_binary, k, with_self=True) for k in range(num_scales)]
            A_powers = np.concatenate([normalize_adjacency_matrix(g) for g in A_powers])
        else:
            A_powers = [A_binary + np.eye(len(A_binary)) for k in range(num_scales)]
            A_powers = [normalize_adjacency_matrix(g) for g in A_powers]
            A_powers = [np.linalg.matrix_power(g, k) for k, g in enumerate(A_powers)]
            A_powers = np.concatenate(A_powers)

        self.A_powers = torch.Tensor(A_powers)
        self.use_mask = use_mask
        if use_mask:
            # NOTE: the inclusion of residual mask appears to slow down training noticeably
            self.A_res = nn.init.uniform_(nn.Parameter(torch.Tensor(self.A_powers.shape)), -1e-6, 1e-6)

        self.mlp = MLP(in_channels * num_scales, [out_channels], dropout=dropout, activation=activation)

    def forward(self, x):
        N, C, T, V = x.shape
        self.A_powers = self.A_powers.to(x.device)
        A = self.A_powers.to(x.dtype)
        print(A.shape)
        if self.use_mask:
            A = A + self.A_res.to(x.dtype)
        support = torch.einsum('vu,nctu->nctv', A, x)
        support = support.view(N, C, T, self.num_scales, V)
        support = support.permute(0,3,1,2,4).contiguous().view(N, self.num_scales*C, T, V)
        out = self.mlp(support)
        return out


5.5.4.2 MS-TCN

有点类似于Inception,1×1卷积用于降低特征维度,减少计算量。

不同dilation代表什么,有什么具体的作用?不同的时间分辨率

在这里插入图片描述

在ms_tcn.py文件中,由class TemporalConv和class MultiScale_TemporalConv共同构成

class TemporalConv(nn.Module):
    def __init__(self, in_channels, out_channels, kernel_size, stride=1, dilation=1):
        super(TemporalConv, self).__init__()
        pad = (kernel_size + (kernel_size-1) * (dilation-1) - 1) // 2
        self.conv = nn.Conv2d(
            in_channels,
            out_channels,
            kernel_size=(kernel_size, 1),
            padding=(pad, 0),
            stride=(stride, 1),
            dilation=(dilation, 1))

        self.bn = nn.BatchNorm2d(out_channels)

    def forward(self, x):
        x = self.conv(x)
        x = self.bn(x)
        return x


class MultiScale_TemporalConv(nn.Module):
    def __init__(self,
                 in_channels,
                 out_channels,
                 kernel_size=3,
                 stride=1,
                 dilations=[1,2,3,4],
                 residual=True,
                 residual_kernel_size=1,
                 activation='relu'):

        super().__init__()
        assert out_channels % (len(dilations) + 2) == 0, '# out channels should be multiples of # branches'

        # Multiple branches of temporal convolution
        self.num_branches = len(dilations) + 2
        branch_channels = out_channels // self.num_branches

        # Temporal Convolution branches
        '''
        self.branches是一个nn.ModuleList,它包含了多个不同的网络分支
        每个分支都是一个nn.Sequential模块
        时间卷积分支:
        对于每个dilation值(在dilations列表中),创建一个分支
        nn.Conv2d:一个一维卷积层,用于在通道维度上进行变换,卷积核大小为1x1,不使用填充(padding=0)。
        nn.BatchNorm2d:二维批量归一化层,用于归一化上述卷积层的输出。
        activation_factory(activation):一个激活函数
        TemporalConv:一个时间卷积层,使用指定的kernel_size、stride和dilation。
        '''
        self.branches = nn.ModuleList([
            nn.Sequential(
                nn.Conv2d(
                    in_channels,
                    branch_channels,
                    kernel_size=1,
                    padding=0),
                nn.BatchNorm2d(branch_channels),
                activation_factory(activation),
                TemporalConv(
                    branch_channels,
                    branch_channels,
                    kernel_size=kernel_size,
                    stride=stride,
                    dilation=dilation),
            )
            for dilation in dilations
        ])

        # Additional Max & 1x1 branch
        '''
        最大池化分支(Max Pooling Branch):
        nn.Conv2d:与时间卷积分支开始时相同,一个1x1卷积层
        nn.BatchNorm2d:二维批量归一化层
        activation_factory(activation):激活函数层
        nn.MaxPool2d:最大池化层,使用大小为3x1的池化窗口,步长为1x1,填充为1x0。
        nn.BatchNorm2d:二维批量归一化层,用于归一化最大池化层的输出。
        '''
        self.branches.append(nn.Sequential(
            nn.Conv2d(in_channels, branch_channels, kernel_size=1, padding=0),
            nn.BatchNorm2d(branch_channels),
            activation_factory(activation),
            nn.MaxPool2d(kernel_size=(3,1), stride=(stride,1), padding=(1,0)),
            nn.BatchNorm2d(branch_channels)
        ))
        '''
        1x1卷积分支(1x1 Convolution Branch):
        nn.Conv2d:1x1卷积层,步长为1x1,不使用填充
        nn.BatchNorm2d:二维批量归一化层。
        '''
        self.branches.append(nn.Sequential(
            nn.Conv2d(in_channels, branch_channels, kernel_size=1, padding=0, stride=(stride,1)),
            nn.BatchNorm2d(branch_channels)
        ))

        # Residual connection
        if not residual:
            self.residual = lambda x: 0
        elif (in_channels == out_channels) and (stride == 1):
            self.residual = lambda x: x
        else:
            self.residual = TemporalConv(in_channels, out_channels, kernel_size=residual_kernel_size, stride=stride)

        self.act = activation_factory(activation)

    def forward(self, x):
        # Input dim: (N,C,T,V)
        res = self.residual(x)
        branch_outs = []
        for tempconv in self.branches:
            out = tempconv(x)
            branch_outs.append(out)

        out = torch.cat(branch_outs, dim=1)
        out += res
        out = self.act(out)
        return out

5.6 Graph的构建

这部分代码和2S-AGCN代码相似

在tools.py中提供了处理图的工具

import numpy as np  
  
# 将边列表转换为邻接矩阵  
# link: 边列表,每个元素是一个元组(j, i),表示从节点i到节点j的边  
# num_node: 节点总数  
def edge2mat(link, num_node):  
    A = np.zeros((num_node, num_node))  # 初始化邻接矩阵  
    for i, j in link:  
        A[j, i] = 1  # 邻接矩阵中,如果节点i到节点j有边,则A[j, i] = 1  
    return A  
  
# 计算AD
# A: 图的邻接矩阵  
def normalize_digraph(A):  
    Dl = np.sum(A, 0)  # 计算每列的和,即每个节点的出度  
    h, w = A.shape  # 获取矩阵的行数和列数  
    Dn = np.zeros((w, w))  # 初始化规范化矩阵  
    for i in range(w):  
        if Dl[i] > 0:  
            Dn[i, i] = Dl[i] ** (-1)  # 对角线上是出度的倒数  
    AD = np.dot(A, Dn)  # 计算规范化后的邻接矩阵  
    return AD  
  
# 获取空间图(包含自连接、入边和出边)  
# num_node: 节点总数  
# self_link: 自连接边列表  
# inward: 入边列表  
# outward: 出边列表  
def get_spatial_graph(num_node, self_link, inward, outward):  
    I = edge2mat(self_link, num_node)  # 自连接矩阵  
    In = normalize_digraph(edge2mat(inward, num_node))  # 规范化后的入边矩阵  
    Out = normalize_digraph(edge2mat(outward, num_node))  # 规范化后的出边矩阵  
    A = np.stack((I, In, Out))  # 将三个矩阵堆叠成一个三维数组  
    return A  
  
# 计算k阶邻接矩阵  
# A: 邻接矩阵  
# k: 阶数  
# with_self: 是否包含自连接  
# self_factor: 自连接的权重因子  
def k_adjacency(A, k, with_self=False, self_factor=1):  
    I = np.eye(len(A), dtype=A.dtype)  # 单位矩阵  
    if k == 0:  
        return I  # 如果k为0,直接返回单位矩阵  
    Ak = np.minimum(np.linalg.matrix_power(A + I, k), 1) \  
       - np.minimum(np.linalg.matrix_power(A + I, k - 1), 1)  # 计算k阶邻接矩阵  
    if with_self:  
        Ak += (self_factor * I)  # 如果需要自连接,则加上单位矩阵的倍数  
    return Ak  
  
# 规范化邻接矩阵  
# A: 邻接矩阵  
def normalize_adjacency_matrix(A):  
    node_degrees = A.sum(-1)  # 计算每个节点的度(出度和入度之和)  
    degs_inv_sqrt = np.power(node_degrees, -0.5)  # 度的平方根的倒数  
    norm_degs_matrix = np.eye(len(node_degrees)) * degs_inv_sqrt  # 规范化矩阵  
    return (norm_degs_matrix @ A @ norm_degs_matrix).astype(np.float32)  # 返回规范化后的邻接矩阵  
  
# 根据边列表和节点数生成邻接矩阵  
# edges: 边列表,每个元素是一个元组(i, j),表示从节点i到节点j的边  
# num_nodes: 节点总数  
def get_adjacency_matrix(edges, num_nodes):  
    A = np.zeros((num_nodes, num_nodes), dtype=np.float32)  # 初始化邻接矩阵  
    for edge in edges:  
        A[edge] = 1.  # 设置边的对应位置为1  
    return A  # 返回邻接矩阵

在这里插入图片描述
左图是Kinetics-Skeleton数据集骨骼点示意图,右图是NTU-RGBD数据集骨骼点示意图。
以左图为例:在Kinetics.py中定义了Kinetics骨骼序列图是如何构建的

import sys
sys.path.insert(0, '')
sys.path.extend(['../'])

import numpy as np

from graph import tools

# Joint index:
# {0,  "Nose"}
# {1,  "Neck"},
# {2,  "RShoulder"},
# {3,  "RElbow"},
# {4,  "RWrist"},
# {5,  "LShoulder"},
# {6,  "LElbow"},
# {7,  "LWrist"},
# {8,  "RHip"},
# {9,  "RKnee"},
# {10, "RAnkle"},
# {11, "LHip"},
# {12, "LKnee"},
# {13, "LAnkle"},
# {14, "REye"},
# {15, "LEye"},
# {16, "REar"},
# {17, "LEar"},

num_node = 18
self_link = [(i, i) for i in range(num_node)]
inward = [(4, 3), (3, 2), (7, 6), (6, 5), (13, 12), (12, 11), (10, 9), (9, 8),
          (11, 5), (8, 2), (5, 1), (2, 1), (0, 1), (15, 0), (14, 0), (17, 15),
          (16, 14)]
outward = [(j, i) for (i, j) in inward]
neighbor = inward + outward
# 定义一个图类,使用邻接矩阵来表示图  
class AdjMatrixGraph:  
    def __init__(self, *args, **kwargs):  
        # 初始化节点数  
        self.num_nodes = num_node  
        # 初始化边列表(这里仅使用了邻居边)  
        self.edges = neighbor  
        # 初始化自环列表  
        self.self_loops = [(i, i) for i in range(self.num_nodes)]  
        # 使用工具函数生成不包含自环的二进制邻接矩阵  
        self.A_binary = tools.get_adjacency_matrix(self.edges, self.num_nodes)  
        # 使用工具函数生成包含自环的二进制邻接矩阵  
        self.A_binary_with_I = tools.get_adjacency_matrix(self.edges + self.self_loops, self.num_nodes)  
  


看完这部分代码后,你一定会很奇怪,为什么没有用到def k_adjacency()来创建论文中提到的k阶邻接矩阵:
答案在这里:
在class MultiScale_GraphConv中有这样一个参数:
disentangled_agg=True,即使用解纠缠的多尺度卷积,即使用论文中提出的k阶邻接矩阵
disentangled_agg=False,就使用高阶多项式来求邻接矩阵(但是这里的求法跟AS-GCN好像也不太一样)

这里的代码公式也没太看懂,回头补吧
先理解A_binary是怎么来到这的:

(1) class AdjMatrixGraph:

  • self.A_binary = tools.get_adjacency_matrix(self.edges, self.num_nodes)
  • 这时A_binary的形状是[num_nodes,num_nodes]

(2) class Model:
def init

  • Graph = import_class(graph)
    A_binary = Graph().A_binary
  • 这时A_binary的形状是依然是[num_nodes,num_nodes]

def forward:

  • MS_GCN(num_gcn_scales, 3, c1, A_binary, disentangled_agg=True)
  • 下面这段代码就是MS_GCN中对A_binary的处理
  • 经过处理后,A_powers的性质为[num_gcn_scales*num_nodes,num_nodes]
  • 如果num_nodes=25,num_gcn_scales=15,A_powers的形状就是torch.Size([375, 25])
        if disentangled_agg:
            A_powers = [k_adjacency(A_binary, k, with_self=True) for k in range(num_scales)]
            A_powers = np.concatenate([normalize_adjacency_matrix(g) for g in A_powers])
            '''
            当disentangled_agg为True时
            A_powers是一个列表,其中包含num_scales个n x n的矩阵。
            使用np.concatenate(A_powers)将这些矩阵沿着第一个轴(行)连接起来,得到的A_powers的形状将是(num_scales * n, n)。
            '''
        else:
            A_powers = [A_binary + np.eye(len(A_binary)) for k in range(num_scales)]
            A_powers = [normalize_adjacency_matrix(g) for g in A_powers]
            A_powers = [np.linalg.matrix_power(g, k) for k, g in enumerate(A_powers)]
            A_powers = np.concatenate(A_powers)

5.7 Full Architecture

class Model(nn.Module):
    def __init__(self,
                 num_class,
                 num_point,
                 num_person,
                 num_gcn_scales,
                 num_g3d_scales,
                 graph,
                 in_channels=3):
        super(Model, self).__init__()

        Graph = import_class(graph)
        A_binary = Graph().A_binary

        self.data_bn = nn.BatchNorm1d(num_person * in_channels * num_point)

        # channels
        c1 = 96
        c2 = c1 * 2     # 192
        c3 = c2 * 2     # 384

        # r=3 STGC blocks
        self.gcn3d1 = MultiWindow_MS_G3D(3, c1, A_binary, num_g3d_scales, window_stride=1)
        self.sgcn1 = nn.Sequential(
            MS_GCN(num_gcn_scales, 3, c1, A_binary, disentangled_agg=True),
            MS_TCN(c1, c1),
            MS_TCN(c1, c1))
        self.sgcn1[-1].act = nn.Identity()
        self.tcn1 = MS_TCN(c1, c1)

        self.gcn3d2 = MultiWindow_MS_G3D(c1, c2, A_binary, num_g3d_scales, window_stride=2)
        self.sgcn2 = nn.Sequential(
            MS_GCN(num_gcn_scales, c1, c1, A_binary, disentangled_agg=True),
            MS_TCN(c1, c2, stride=2),
            MS_TCN(c2, c2))
        self.sgcn2[-1].act = nn.Identity()
        self.tcn2 = MS_TCN(c2, c2)

        self.gcn3d3 = MultiWindow_MS_G3D(c2, c3, A_binary, num_g3d_scales, window_stride=2)
        self.sgcn3 = nn.Sequential(
            MS_GCN(num_gcn_scales, c2, c2, A_binary, disentangled_agg=True),
            MS_TCN(c2, c3, stride=2),
            MS_TCN(c3, c3))
        self.sgcn3[-1].act = nn.Identity()
        self.tcn3 = MS_TCN(c3, c3)

        self.fc = nn.Linear(c3, num_class)

    def forward(self, x):
        N, C, T, V, M = x.size()
        x = x.permute(0, 4, 3, 1, 2).contiguous().view(N, M * V * C, T)
        x = self.data_bn(x)
        x = x.view(N * M, V, C, T).permute(0,2,3,1).contiguous()

        # Apply activation to the sum of the pathways
        x = F.relu(self.sgcn1(x) + self.gcn3d1(x), inplace=True)
        x = self.tcn1(x)

        x = F.relu(self.sgcn2(x) + self.gcn3d2(x), inplace=True)
        x = self.tcn2(x)

        x = F.relu(self.sgcn3(x) + self.gcn3d3(x), inplace=True)
        x = self.tcn3(x)

        out = x
        out_channels = out.size(1)
        out = out.view(N, M, out_channels, -1)
        out = out.mean(3)   # Global Average Pooling (Spatial+Temporal)
        out = out.mean(1)   # Average pool number of bodies in the sequence

        out = self.fc(out)
        return out

六、CTR-GCN

之前存在的问题:

  • 早期基于深度学习的方法将人体关节视为一组独立的特征,并将其组织成特征序列或伪图像,然后将其输入RNN或CNN以预测动作标签。(这是GCN之前的方法)然而,这些方法忽略了关节之间的内在相关性。
  • 这些内在相关性揭示了人体的拓扑结构,是人体骨骼的重要信息。Yan等人[32](ST-GCN)首先用图形对人体关节之间的相关性进行建模,并将GCN与时间卷积一起用于提取运动特征。
  • 然而,他们采用的手动定义的拓扑结构是难以实现非自然连接的关节之间的关系建模和限制的GCN的表示能力。
  • 为了提高GCN的能力,最近的方法[24(2S-AGCN),35,34]通过注意力或其他机制自适应地学习人类骨骼的拓扑结构。
  • 它们对所有通道使用拓扑,这迫使GCN在不同通道中聚合具有相同拓扑的特征,从而限制了特征提取的灵活性。
  • 由于不同的通道代表不同类型的运动特征,并且不同运动特征下关节之间的相关性并不总是相同的,因此使用一个共享拓扑并不是最佳的。

总结:

基于GCN的算法所有通道共享同一套拓扑结构,这样限制了模型的能力上限。作者认为拓扑结构(邻接矩阵A)可以继续被细化训练。即A[0],A[1],A[2]分别参与训练。

针对这一问题作者提出了通道拓扑细化网络,以此来提高模型的上限。

6.1 基于GCN的方法分类

许多基于GCN的方法都集中在拓扑建模上。根据拓扑结构的不同,基于GCN的方法可以分为以下几类:

(1)根据在推理过程中拓扑结构是否动态调整,基于GCN的方法可以分为静态方法和动态方法。

  • 静态方法:GCN的拓扑结构在推理过程中保持不变

Yan等人[32]提出了一种ST-GCN,它根据人体结构预先定义拓扑,并且拓扑在训练和测试阶段都是固定的。Liu等人。[20]和Huang等人。[9]将多尺度图拓扑引入GCN,以实现多范围联合关系建模。

动态方法:GCN的拓扑在推理期间被动态地推断

Li等人。[15]提出了一个Alinks推理模块来捕获动作特定的相关性。Shi等人。[24]和Zhang等人。[35]用自我注意机制增强了拓扑学习,该机制对给定相应特征的两个关节之间的相关性进行建模。这些方法推断两个关节之间的相关性

(2)根据不同通道之间是否共享拓扑,基于GCN的方法可以分为拓扑共享方法和拓扑非共享方法。

  • 拓扑共享方法:静态或动态拓扑在所有通道中共享。这些方法迫使GCN聚合具有相同拓扑结构的不同通道中的特征,从而限制了模型性能的上限。

大多数基于GCN的方法都遵循拓扑共享的方式,包括前面提到的静态方法[9,20,32]和动态方法[15,24,34,35]。

  • 拓扑非共享方法:在不同的通道或通道组中使用不同的拓扑,这自然克服了拓扑共享方法的局限性。

Cheng等人。[3]提出了一种DCGCN,它为不同的通道组设置单独的参数化拓扑。
然而,DC-GCN面临的优化困难所造成的过多的参数时,设置通道的拓扑结构。
据我们所知,拓扑非共享图卷积很少在基于卷积的动作识别中被探索,这项工作是第一个建模动态通道拓扑。
请注意,我们的方法也属于动态方法,因为拓扑是在推理过程中动态推断的。

6.2 符号

  • Graph: G = ( V , E , X ) G=(V,E,X) G=(VEX)
  • V是N个顶点的集合
  • E是边集,表示为邻接矩阵 A
  • V i V_i Vi的邻域表示为

在这里插入图片描述

  • X是N个顶点的特征集合
  • 拓扑共享图卷积
    在这里插入图片描述
    对于静态方法, a i j a_{ij} aij被手动定义被手动定义或设置为可训练参数。
    对于动态方法, a i j a_{ij} aij通常由模型根据输入样本生成。

6.3 Channel-wise Topology Refinement Graph Convolution

CTR-GC的一般框架如图所示:

在这里插入图片描述

  • 首先将输入特征转换为高级特征
  • 然后动态地推断通道拓扑以捕获不同类型运动特征下输入样本关节之间的成对相关性
  • 接着将每个通道中的特征与相应的拓扑聚合以获得最终输出。

CTR-GC包含三个部分:

(1)特征转换,由转换函数T(·)完成,对应图中橙色部分

(2)逐层拓扑建模,由相关建模函数M(·)和细化函数R(·)组成,对应图中蓝色部分

(3)逐层聚合,由聚合函数A(·)完成,对应图中黄色部分

6.3.1 Feature Transformation(特征转换)

在这里插入图片描述

通过T(·)将输入特征转换为高级表示。采用简单的线性变换作为拓扑共享图卷积,其公式为:在这里插入图片描述

  • 输入: X ∈ R N × C X\in\mathbb{R}^{N\times C} XRN×C
  • 参数: W ∈ R N × C ′ W\in\mathbb{R}^{N\times C^{\prime}} WRN×C
  • 输出: X ~ ∈ R N × C ′ \tilde{X}\in\mathbb{R}^{N\times C^{\prime}} X~RN×C
  • 除使用线性变化外,也可以使用其它变换,如多层感知机(MLP)

6.3.2 Channel-wise Topology Modeling

  • 使用 φ 和 ψ 降低特征维度降低计算复杂度
  • 引入相关建模函数 M 计算顶点之间的
    相关性,捕捉顶点间的潜在联系

论文设计了两个简单而有效的相关建模函数。

  • M1(·)实质上计算沿通道维度沿着的φ(Xi)和φ(xj)之间的距离,并利用这些距离的非线性变换作为vi和vj之间的通道特定拓扑关系。
    在这里插入图片描述
    在这里插入图片描述
  • 利用线性变换 ξ 提高通道维数得到通道特定相关矩阵Q, q i j q_{ij} qij反映vi和vj之间的特定于通道的拓扑关系。
  • 注意,Q不被强制为对称的,即, q i j ≠ q j i q_{ij} \neq q_{ji} qij=qji,这增加了相关性建模的灵活性。

在这里插入图片描述

  • 通过使用 Q 细化共享拓扑 A 得到针对每个通道的特定拓扑 R,其中α是可训练标量以调整细化的强度,加法以广播方式进行,其中A被加到α ×Q的每个通道。

在这里插入图片描述

在这里插入图片描述

疑问:A的形状是什么?【N,N】?

  • 输入: X ∈ R N × C X\in\mathbb{R}^{N\times C} XRN×C
  • q i j ∈ R C ′ q_{ij}\in\mathbb{R}^{C^{\prime}} qijRC
  • Q ∈ R N × N × C ′ Q\in\mathbb{R}^{N×N×C^{\prime}} QRN×N×C
  • 输出: R ∈ R N × N × C ′ R\in\mathbb{R}^{N×N×C^{\prime}} RRN×N×C

6.3.3 Channel-wise Aggregation(逐层聚合)

  • 给定细化的通道式拓扑R和高级特征X,CTR-GC以通道式的方式聚合特征。
  • CTR-GC为每个通道构造一个通道图,每个通道具有相应的精化拓扑, R C ∈ R N × N R_C\in\mathbb{R}^{N\times N} RCRN×N x : , c ~ \tilde{x:,c} x:,c~分别来自 R R R X ~ \tilde{X} X~的第c个通道, c ∈ 1 , ⋅ ⋅ ⋅ , C ′ c ∈ {1,· · ·,C^{\prime}} c1⋅⋅⋅C
  • 每个通道图反映了在特定类型的运动特征下的顶点的关系。
  • 因此,对每个通道图执行特征聚集,并且通过级联所有信道图的输出特征来获得最终输出Z,其公式化为
  • 在整个过程期间,信道特定相关性Q的推断依赖输入样本。因此,所提出的CTR-GC是一个动态的图形卷积,它随着输入样本的不同自适应地变化。
  • ∣ ∣ || ∣∣是连接操作
    在这里插入图片描述

跟自注意力机制还挺像的
怀疑 R C ∈ R N × N R_C\in\mathbb{R}^{N\times N} RCRN×N x : , c ~ \tilde{x:,c} x:,c~相乘,然后结果拼接,但是我没有证据,这个过程还得看看代码才明白

在这里插入图片描述

  • 输入:
    R ∈ R N × N × C ′ R\in\mathbb{R}^{N×N×C^{\prime}} RRN×N×C
    X ~ ∈ R N × C ′ \tilde{X}\in\mathbb{R}^{N\times C^{\prime}} X~RN×C

6.4 Analysis of Graph Convolutions

这部分内容看不懂我觉得也没关系,就是强调CTR-GCN的卷积具有更少的束缚、更强的灵活性

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

6.5 Model Architecture

下图是一个CTR-GCN基本块:

  • CTR-GCN 网络由十个 CTR-GCN 基本块加上平均池化、softmax 分类器组成
  • 10个块的通道数为64-64-64-64-128-128-128-256-256-256。
  • 在CTR-GCN 基本块中,特征向量先后经过空间建模模块和时间建模模块。

在空间建模阶段:

  • 特征向量首先经过由三个并行的 CTR-GC 模块组成的网络结构
  • 合并三个模块对特征独立处理的结果
  • 然后通过归一化和激活函数作为空间建模的输出。

时间建模模块:

  • 时间建模模块由四个分支组成
  • 前三个分支首先采用 1×1 卷积操作对特征向量的通道维数进行压缩,然后分别执行最大池化操作以及卷积核大小为 5、dilation 分别为 1 和 2 的时间卷积操作
  • 最后一个分支为一个独立的 1×1 卷积,四个分支的结果拼接作为输出。
  • 整个 CTR-GCN 模块采用了残差连接的设计,最终结果与输入特征相加从而保留原始信息并促进梯度传播。

在这里插入图片描述
下图是一个CTR-GC模块:r叫还原率,论文的表里有提到这个参数
在这里插入图片描述
这个图更明白
在这里插入图片描述

6.5.1 Full Architecture

所有用到的初始化函数在这

def conv_init(conv):
    if conv.weight is not None:
        nn.init.kaiming_normal_(conv.weight, mode='fan_out')
    if conv.bias is not None:
        nn.init.constant_(conv.bias, 0)


def bn_init(bn, scale):
    nn.init.constant_(bn.weight, scale)
    nn.init.constant_(bn.bias, 0)


def weights_init(m):
    classname = m.__class__.__name__
    if classname.find('Conv') != -1:
        if hasattr(m, 'weight'):
            nn.init.kaiming_normal_(m.weight, mode='fan_out')
        if hasattr(m, 'bias') and m.bias is not None and isinstance(m.bias, torch.Tensor):
            nn.init.constant_(m.bias, 0)
    elif classname.find('BatchNorm') != -1:
        if hasattr(m, 'weight') and m.weight is not None:
            m.weight.data.normal_(1.0, 0.02)
        if hasattr(m, 'bias') and m.bias is not None:
            m.bias.data.fill_(0)

class Model(nn.Module):
    def __init__(self, num_class=60, num_point=25, num_person=2,  graph_args=dict(), in_channels=3,
                 drop_out=0, adaptive=True):
        super(Model, self).__init__()

        Graph = Graph_mmfs
        self.graph = Graph(**graph_args)

        A = self.graph.A # 3,25,25

        self.num_class = num_class
        self.num_point = num_point
        self.data_bn = nn.BatchNorm1d(num_person * in_channels * num_point)

        base_channel = 64
        self.l1 = TCN_GCN_unit(in_channels, base_channel, A, residual=False, adaptive=adaptive)
        self.l2 = TCN_GCN_unit(base_channel, base_channel, A, adaptive=adaptive)
        self.l3 = TCN_GCN_unit(base_channel, base_channel, A, adaptive=adaptive)
        self.l4 = TCN_GCN_unit(base_channel, base_channel, A, adaptive=adaptive)
        self.l5 = TCN_GCN_unit(base_channel, base_channel*2, A, stride=2, adaptive=adaptive)
        self.l6 = TCN_GCN_unit(base_channel*2, base_channel*2, A, adaptive=adaptive)
        self.l7 = TCN_GCN_unit(base_channel*2, base_channel*2, A, adaptive=adaptive)
        self.l8 = TCN_GCN_unit(base_channel*2, base_channel*4, A, stride=2, adaptive=adaptive)
        self.l9 = TCN_GCN_unit(base_channel*4, base_channel*4, A, adaptive=adaptive)
        self.l10 = TCN_GCN_unit(base_channel*4, base_channel*4, A, adaptive=adaptive)

        self.fc = nn.Linear(base_channel*4, num_class)
        nn.init.normal_(self.fc.weight, 0, math.sqrt(2. / num_class))
        bn_init(self.data_bn, 1)
        if drop_out:
            self.drop_out = nn.Dropout(drop_out)
        else:
            self.drop_out = lambda x: x

    def forward(self, x):
        if len(x.shape) == 3:
            N, T, VC = x.shape
            x = x.view(N, T, self.num_point, -1).permute(0, 3, 1, 2).contiguous().unsqueeze(-1)
        N, C, T, V, M = x.size()

        x = x.permute(0, 4, 3, 1, 2).contiguous().view(N, M * V * C, T)
        x = self.data_bn(x)
        x = x.view(N, M, V, C, T).permute(0, 1, 3, 4, 2).contiguous().view(N * M, C, T, V)
        x = self.l1(x)
        x = self.l2(x)
        x = self.l3(x)
        x = self.l4(x)
        x = self.l5(x)
        x = self.l6(x)
        x = self.l7(x)
        x = self.l8(x)
        x = self.l9(x)
        x = self.l10(x)

        # N*M,C,T,V
        c_new = x.size(1)
        x = x.view(N, M, c_new, -1)
        x = x.mean(3).mean(1)
        x = self.drop_out(x)

        return self.fc(x)

平均池化、softmax 分类器,你们俩又去哪了

6.5.2 CTR-GCN Blocks

class TCN_GCN_unit(nn.Module):
    def __init__(self, in_channels, out_channels, A, stride=1, residual=True, adaptive=True, kernel_size=5, dilations=[1,2]):
        super(TCN_GCN_unit, self).__init__()
        self.gcn1 = unit_gcn(in_channels, out_channels, A, adaptive=adaptive)
        self.tcn1 = MultiScale_TemporalConv(out_channels, out_channels, kernel_size=kernel_size, stride=stride, dilations=dilations,
                                            residual=False)
        self.relu = nn.ReLU(inplace=True)
        if not residual:
            self.residual = lambda x: 0

        elif (in_channels == out_channels) and (stride == 1):
            self.residual = lambda x: x

        else:
            self.residual = unit_tcn(in_channels, out_channels, kernel_size=1, stride=stride)

    def forward(self, x):
        y = self.relu(self.tcn1(self.gcn1(x)) + self.residual(x))
        return y

其中的重点是self.gcn1 = unit_gcn和 self.tcn1 = MultiScale_TemporalConv,分别代表其中的空间建模阶段和时间建模阶段

6.5.3 unit_gcn:空间建模阶段

class unit_gcn(nn.Module):
    def __init__(self, in_channels, out_channels, A, coff_embedding=4, adaptive=True, residual=True):
        super(unit_gcn, self).__init__()  # 调用父类nn.Module的构造函数
        inter_channels = out_channels // coff_embedding  # 计算中间通道数
        self.inter_c = inter_channels  # 中间通道数
        self.out_c = out_channels  # 输出通道数
        self.in_c = in_channels  # 输入通道数
        self.adaptive = adaptive  # 是否使用自适应邻接矩阵
        self.num_subset = A.shape[0]  # 邻接矩阵A的子集数量
        self.convs = nn.ModuleList()  # 创建一个模块列表用于存放卷积层

        # 为每个子集创建一个CTRGC卷积层并添加到模块列表中
        for i in range(self.num_subset):
            self.convs.append(CTRGC(in_channels, out_channels))

        # 残差连接的处理
        if residual:
            if in_channels != out_channels:
                self.down = nn.Sequential(  # 如果输入和输出通道数不同,则添加1x1卷积和批量归一化
                    nn.Conv2d(in_channels, out_channels, 1),
                    nn.BatchNorm2d(out_channels)
                )
            else:
                self.down = lambda x: x  # 如果相同,则残差连接为恒等映射
        else:
            self.down = lambda x: 0  # 如果不使用残差连接,则输出为0

        # 邻接矩阵的处理
        if self.adaptive:
            self.PA = nn.Parameter(torch.from_numpy(A.astype(np.float32)))  # 如果使用自适应邻接矩阵,则将其作为参数
        else:
            self.A = Variable(torch.from_numpy(A.astype(np.float32)), requires_grad=False)  # 否则,作为不可训练的变量

        self.alpha = nn.Parameter(torch.zeros(1))  # 创建一个可训练的参数alpha
        self.bn = nn.BatchNorm2d(out_channels)  # 输出通道的批量归一化层
        self.soft = nn.Softmax(-2)  # 用于计算邻接矩阵的Softmax函数
        self.relu = nn.ReLU(inplace=True)  # 激活函数ReLU

        # 初始化权重和偏差
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                conv_init(m)  # 假设conv_init是用于初始化卷积层的函数
            elif isinstance(m, nn.BatchNorm2d):
                bn_init(m, 1)  # 假设bn_init是用于初始化批量归一化层的函数
        bn_init(self.bn, 1e-6)  # 特别初始化输出的批量归一化层

    def forward(self, x):
        y = None  # 初始化输出
        if self.adaptive:
            A = self.PA  # 使用自适应邻接矩阵
        else:
            A = self.A.cuda(x.get_device())  # 使用固定的邻接矩阵,并将其移动到与输入相同的设备

        # 应用图卷积
        for i in range(self.num_subset):
            z = self.convs[i](x, A[i], self.alpha)  # 对每个子集应用CTRGC卷积
            y = z + y if y is not None else z  # 累加结果

        y = self.bn(y)  # 批量归一化
        y += self.down(x)  # 残差连接
        y = self.relu(y)  # 激活函数

        return y  # 返回最终的输出

unit_gcn里面用了3个CTR-GC块

class CTRGC(nn.Module):
    def __init__(self, in_channels, out_channels, rel_reduction=8, mid_reduction=1):
        super(CTRGC, self).__init__()
        self.in_channels = in_channels
        self.out_channels = out_channels
        if in_channels == 3 or in_channels == 9:
            self.rel_channels = 8
            self.mid_channels = 16
        else:
            self.rel_channels = in_channels // rel_reduction
            self.mid_channels = in_channels // mid_reduction
        self.conv1 = nn.Conv2d(self.in_channels, self.rel_channels, kernel_size=1)
        self.conv2 = nn.Conv2d(self.in_channels, self.rel_channels, kernel_size=1)
        self.conv3 = nn.Conv2d(self.in_channels, self.out_channels, kernel_size=1)
        self.conv4 = nn.Conv2d(self.rel_channels, self.out_channels, kernel_size=1)
        self.tanh = nn.Tanh()
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                conv_init(m)
            elif isinstance(m, nn.BatchNorm2d):
                bn_init(m, 1)

    def forward(self, x, A=None, alpha=1):
        x1, x2, x3 = self.conv1(x).mean(-2), self.conv2(x).mean(-2), self.conv3(x)
        x1 = self.tanh(x1.unsqueeze(-1) - x2.unsqueeze(-2))
        x1 = self.conv4(x1) * alpha + (A.unsqueeze(0).unsqueeze(0) if A is not None else 0)  # N,C,V,V
        x1 = torch.einsum('ncuv,nctv->nctu', x1, x3)
        return x1

6.5.4 MultiScale_TemporalConv

class MultiScale_TemporalConv(nn.Module):
    def __init__(self,
                 in_channels,
                 out_channels,
                 kernel_size=3,
                 stride=1,
                 dilations=[1,2,3,4],
                 residual=True,
                 residual_kernel_size=1):

        super().__init__()
        assert out_channels % (len(dilations) + 2) == 0, '# out channels should be multiples of # branches'

        # Multiple branches of temporal convolution
        self.num_branches = len(dilations) + 2
        branch_channels = out_channels // self.num_branches
        if type(kernel_size) == list:
            assert len(kernel_size) == len(dilations)
        else:
            kernel_size = [kernel_size]*len(dilations)
        # Temporal Convolution branches
        self.branches = nn.ModuleList([
            nn.Sequential(
                nn.Conv2d(
                    in_channels,
                    branch_channels,
                    kernel_size=1,
                    padding=0),
                nn.BatchNorm2d(branch_channels),
                nn.ReLU(inplace=True),
                TemporalConv(
                    branch_channels,
                    branch_channels,
                    kernel_size=ks,
                    stride=stride,
                    dilation=dilation),
            )
            for ks, dilation in zip(kernel_size, dilations)
        ])

        # Additional Max & 1x1 branch
        self.branches.append(nn.Sequential(
            nn.Conv2d(in_channels, branch_channels, kernel_size=1, padding=0),
            nn.BatchNorm2d(branch_channels),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=(3,1), stride=(stride,1), padding=(1,0)),
            nn.BatchNorm2d(branch_channels)  # 为什么还要加bn
        ))

        self.branches.append(nn.Sequential(
            nn.Conv2d(in_channels, branch_channels, kernel_size=1, padding=0, stride=(stride,1)),
            nn.BatchNorm2d(branch_channels)
        ))

        # Residual connection
        if not residual:
            self.residual = lambda x: 0
        elif (in_channels == out_channels) and (stride == 1):
            self.residual = lambda x: x
        else:
            self.residual = TemporalConv(in_channels, out_channels, kernel_size=residual_kernel_size, stride=stride)

        # initialize
        self.apply(weights_init)

    def forward(self, x):
        # Input dim: (N,C,T,V)
        res = self.residual(x)
        branch_outs = []
        for tempconv in self.branches:
            out = tempconv(x)
            branch_outs.append(out)

        out = torch.cat(branch_outs, dim=1)
        out += res
        return out

中间用到了class TemporalConv

class TemporalConv(nn.Module):
    def __init__(self, in_channels, out_channels, kernel_size, stride=1, dilation=1):
        super(TemporalConv, self).__init__()
        pad = (kernel_size + (kernel_size-1) * (dilation-1) - 1) // 2
        self.conv = nn.Conv2d(
            in_channels,
            out_channels,
            kernel_size=(kernel_size, 1),
            padding=(pad, 0),
            stride=(stride, 1),
            dilation=(dilation, 1))

        self.bn = nn.BatchNorm2d(out_channels)

    def forward(self, x):
        x = self.conv(x)
        x = self.bn(x)
        return x

6.6 Graph的构建

在这里插入图片描述
左图是Kinetics-Skeleton数据集骨骼点示意图,右图是NTU-RGBD数据集骨骼点示意图。
以右图为例:在ntu_rgb.py中定义了骨骼序列图是如何构建的

import sys
import numpy as np

sys.path.extend(['../'])
from graph import tools

num_node = 25
self_link = [(i, i) for i in range(num_node)]
inward_ori_index = [(1, 2), (2, 21), (3, 21), (4, 3), (5, 21), (6, 5), (7, 6),
                    (8, 7), (9, 21), (10, 9), (11, 10), (12, 11), (13, 1),
                    (14, 13), (15, 14), (16, 15), (17, 1), (18, 17), (19, 18),
                    (20, 19), (22, 23), (23, 8), (24, 25), (25, 12)]
inward = [(i - 1, j - 1) for (i, j) in inward_ori_index]
outward = [(j, i) for (i, j) in inward]
neighbor = inward + outward

class Graph:
    def __init__(self, labeling_mode='spatial'):
        self.num_node = num_node
        self.self_link = self_link
        self.inward = inward
        self.outward = outward
        self.neighbor = neighbor
        self.A = self.get_adjacency_matrix(labeling_mode)

    def get_adjacency_matrix(self, labeling_mode=None):
        if labeling_mode is None:
            return self.A
        if labeling_mode == 'spatial':
            A = tools.get_spatial_graph(num_node, self_link, inward, outward)
        else:
            raise ValueError()
        return A

Graph.A的维度是[3,num_node,num_node]
tools.get_spatial_graph

def get_spatial_graph(num_node, self_link, inward, outward):
    I = edge2mat(self_link, num_node)
    In = normalize_digraph(edge2mat(inward, num_node))
    Out = normalize_digraph(edge2mat(outward, num_node))
    A = np.stack((I, In, Out))
    return A

edge2mat
normalize_digraph

def edge2mat(link, num_node):
    A = np.zeros((num_node, num_node))
    for i, j in link:
        A[j, i] = 1
    return A
def normalize_digraph(A):
    Dl = np.sum(A, 0)
    h, w = A.shape
    Dn = np.zeros((w, w))
    for i in range(w):
        if Dl[i] > 0:
            Dn[i, i] = Dl[i] ** (-1)
    AD = np.dot(A, Dn)
    return AD

邻接矩阵A的流动过程:

  • Class Model:
    Graph.A [3,25,25]
  • class TCN_GCN_unit
  • class unit_gcn
    在这里就开始对邻接矩阵处理了,A[0]、A[1]、A[2]分别进入一个CTR-GCN
self.num_subset = A.shape[0]
self.convs = nn.ModuleList()
for i in range(self.num_subset):
        self.convs.append(CTRGC(in_channels, out_channels))

#forward中
        for i in range(self.num_subset):
            z = self.convs[i](x, A[i], self.alpha)
            y = z + y if y is not None else z

在这里插入图片描述

  • 在CTR-GC中,用通道特定相关性Q细化共享拓扑A来获得通道级拓扑R
    在这里插入图片描述

在这里插入图片描述

七、总结

  • 要看明白不同的GCN网络关键是看它卷积的方式,说直白点就是看它的图的构建、邻接矩阵A的流动、在哪个模块进行了什么处理,它的维度发生了怎样的变化,看明白这个,对这个网络也就明白了一半
  • 建议去看源码,然后将源码按论文中的模块拆分,只有拆了才能知道代码和论文模块的对应。
  • 博客里不同模块的代码往往是按照从整体到局部进行解释,但是实际在读的过程中往往是反复阅读理解,往往是理解了局部的代码,才能明白它在整体中的作用,往往是理解了局部代码的作用才明白其中语句的实现逻辑
  • 源码和论文是相辅相成的,代码中有很多细节是论文里没有提到的,比如代码中有的地方出现的BN、ReLu,论文中就没有提到,代码中有些语句看起来很难以理解,这时候就需要去看论文来理解它的具体作用是什么
  • 4
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值