返回列表

1st place solution - 1DCNN combined with Transformer

546. Google - Isolated Sign Language Recognition | asl-signs

开始: 2023-02-23 结束: 2023-05-01 音视频处理 数据算法赛
第一名解决方案:结合1D CNN与Transformer

第一名解决方案:结合1D CNN与Transformer

作者:hoyso48(Kaggle Master)
发布时间:2023-05-03
投票数:184

首先,我要感谢谷歌主办这场精彩的竞赛。我一直以来都是谷歌服务、框架和平台(Colab、GCP、TensorFlow等)的忠实粉丝。如果没有谷歌提供的这些优秀资源,我不可能赢得这场比赛。同时,我也感谢所有分享想法的参赛者们,特别是@hengck23的见解让我受益匪浅。

简要总结

我的解决方案结合了1D CNN和Transformer模型,仅使用比赛数据从头训练,并通过4个不同种子的模型集成进行提交。最初使用PyTorch+GPU开发,后来切换到TensorFlow+Colab TPU(tpuv2-8)以确保与TensorFlow Lite的兼容性。

1D CNN vs. Transformer?

我的假设是:在序列数据建模中,如果存在强烈的帧间相关性,1D CNN会比Transformer更高效。

实验证明,纯1D CNN模型轻松超越了Transformer,仅使用1D CNN就在Public LB上达到了0.80的分数。

然而,Transformer仍然有其价值,它可以作为1D CNN的上层结构(我们可以将1D CNN视为一种可训练的tokenizer)。

模型架构

def get_model(max_len=64, dropout_step=0, dim=192):
    inp = tf.keras.Input((max_len,CHANNELS))
    x = tf.keras.layers.Masking(mask_value=PAD,input_shape=(max_len,CHANNELS))(inp)
    ksize = 17
    x = tf.keras.layers.Dense(dim, use_bias=False,name='stem_conv')(x)
    x = tf.keras.layers.BatchNormalization(momentum=0.95,name='stem_bn')(x)

    x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
    x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
    x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
    x = TransformerBlock(dim,expand=2)(x)

    x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
    x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
    x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
    x = TransformerBlock(dim,expand=2)(x)

    if dim == 384: #for the 4x sized model
        x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
        x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
        x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
        x = TransformerBlock(dim,expand=2)(x)

        x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
        x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
        x = Conv1DBlock(dim,ksize,drop_rate=0.2)(x)
        x = TransformerBlock(dim,expand=2)(x)

    x = tf.keras.layers.Dense(dim*2,activation=None,name='top_conv')(x)
    x = tf.keras.layers.GlobalAveragePooling1D()(x)
    x = LateDropout(0.8, start_step=dropout_step)(x)
    x = tf.keras.layers.Dense(NUM_CLASSES,name='classifier')(x)
    return tf.keras.Model(inp, x)

CNN与Transformer的结合是近年来前沿模型的常见设计思路(如coatnet、conformer、Maxvit、nextvit等)。我从192维8层1D CNN开始,后来切换到192维(3+1)×2的conv-transformer结构,使CV和LB分数提升了+0.01。

1D CNN模型采用深度可分离卷积和因果填充。Transformer使用BatchNorm + Swish替代常规的LayerNorm + GELU,在保持相同精度的同时略微减轻了推理负担。单个模型约有185万参数。

掩码处理

正确处理可变长度输入对确保训练-测试一致性和高效推理至关重要,因为短视频不一定需要填充。训练时使用max_len=384并进行填充和截断,推理时仅应用截断。这种方法提供了足够的推理速度,并允许使用相当大的模型。为了准确地将掩码应用于1D CNN,我使用因果填充来保持掩码索引。在TensorFlow中,只需在模型开头使用tf.keras.layers.Masking即可轻松实现掩码。此外,必须确保掩码被准确应用于批归一化和全局平均池化等可能受掩码影响的操作。

正则化策略

  • Drop Path(随机深度,p=0.2)
  • 高比例Dropout(p=0.8)
  • AWP(对抗性权重扰动,lambda = 0.2)

由于需要从头训练模型,正则化技术发挥了关键作用。我使用了drop_path(0.2,应用于每个块之后)、dropout(0.8,应用于GAP之后)和AWP(对抗性权重扰动,lambda=0.2),其中AWP和dropout在第15个epoch后应用。所有这些方法对于防止超过300个epoch的长周期训练过拟合都至关重要。这三种方法对CV和排行榜分数都有显著影响,移除其中任意一个都会导致性能明显下降。

预处理

class Preprocess(tf.keras.layers.Layer):
    def __init__(self, max_len=MAX_LEN, point_landmarks=POINT_LANDMARKS, **kwargs):
        super().__init__(**kwargs)
        self.max_len = max_len
        self.point_landmarks = point_landmarks

    def call(self, inputs):
        if tf.rank(inputs) == 3:
            x = inputs[None,...]
        else:
            x = inputs
        
        mean = tf_nan_mean(tf.gather(x, [17], axis=2), axis=[1,2], keepdims=True)
        mean = tf.where(tf.math.is_nan(mean), tf.constant(0.5,x.dtype), mean)
        x = tf.gather(x, self.point_landmarks, axis=2) #N,T,P,C
        std = tf_nan_std(x, center=mean, axis=[1,2], keepdims=True)
        
        x = (x - mean)/std

        if self.max_len is not None:
            x = x[:,:self.max_len]
        length = tf.shape(x)[1]
        x = x[...,:2]

        dx = tf.cond(tf.shape(x)[1]>1,lambda:tf.pad(x[:,1:] - x[:,:-1], [[0,0],[0,1],[0,0],[0,0]]),lambda:tf.zeros_like(x))

        dx2 = tf.cond(tf.shape(x)[1]>2,lambda:tf.pad(x[:,2:] - x[:,:-2], [[0,0],[0,2],[0,0],[0,0]]),lambda:tf.zeros_like(x))

        x = tf.concat([
            tf.reshape(x, (-1,length,2*len(self.point_landmarks))),
            tf.reshape(dx, (-1,length,2*len(self.point_landmarks))),
            tf.reshape(dx2, (-1,length,2*len(self.point_landmarks))),
        ], axis = -1)
        
        x = tf.where(tf.math.is_nan(x),tf.constant(0.,x.dtype),x)
        
        return x

我使用了左右手、眼睛、鼻子和嘴唇的 landmarks。归一化时使用位于鼻子的第17个 landmark 作为参考点,因为它通常位于中心位置([0.5, 0.5])。我还使用了滞后1阶(x[:1] - x[1:])和滞后2阶(x[:2] - x[2:])的运动特征(滞后超过2阶帮助不大)。

数据增强

时间增强

  1. 随机重采样(原始长度的0.5x ~ 1.5x)
  2. 随机掩码

空间增强

  1. 水平翻转
  2. 随机仿射变换(缩放、平移、旋转、剪切)
  3. 随机Cutout

训练配置

训练曲线图
  • 周期数 = 400
  • 学习率 = 5e-4 × 副本数 = 4e-3
  • 调度策略 = 余弦衰减(无预热)
  • 优化器 = RAdam with Lookahead(优于调优后的AdamW)
  • 损失函数 = 带标签平滑=0.1的分类交叉熵或普通CCE

最终单模型结果
CV(参与者5折分割):0.80
Public LB:0.80
Private LB:0.88

使用Colab TPUv2-8训练大约需要4小时。单模型在参与者5折分割的CV上最终达到约0.80。我集成了4个不同种子的模型(训练配置有细微差异)得到最终LB 0.81。另外,使用相同设置提交4倍大小(384维,16层)的单模型分数略低,我认为通过更好的配置可以达到或超越该分数。

尝试但未成功的方法

  • 图卷积网络(GCNs)
  • 更复杂的数据增强:基于landmarks角度和距离的增强、时空轴网格扭曲等
  • CutMix和MixUp:效果不佳,主要问题是如何为两种不同长度的输入定义新标签
  • 知识蒸馏:尝试使用单4倍大小模型进行蒸馏,但因时间不足未能成功

我总是惊叹于这样一个事实:我们尝试彼此的方法,却得到了不同的结果。我也尝试了2D-CNN(类似@kolyaforrat的精彩方案)和纯Transformer方法,但由于初始性能不佳以及对我自己假设的信心,我没有深入挖掘。看到其他团队用我遇到困难的方法通过他们的技能和努力取得成功,真的令人鼓舞。我从这场比赛中学到了很多,感谢这次经历!谢谢大家!!! :)

编辑:我已公开代码,查看https://www.kaggle.com/competitions/asl-signs/discussion/406978

同比赛其他方案