4.5.9 分类项-三元损失(Triplet Loss)

迭代公式:

Dni=negativeiyi2Dpi=positivei yi2Loss=1Ni=1Nmax( 0, DpiDni+m ) {\displaystyle \begin{aligned} Dn_i &= \sqrt{|| negative_i - y_i||^2} \\ Dp_i &= \sqrt{|| positive_i \ - y_i||^2} \\ Loss &= \frac{1}{N} \sum_{i=1}^N \max( \ 0, \ {Dp_i} - {Dn_i} + m \ ) \\ \end{aligned} }

图像:

图 4.5.9-1 Triplet Loss 函数图[15]

特性:

  1. 使具有相同标签的样本(positive)之间的距离,尽量接近
  2. 使具有不同标签的样本(negative)之间的距离,尽量远离
  3. 要求输入 3 个分类样本子集:相似正样本集、相反负样本集、原样本对照集,并行训练
  4. mm 项代表被认为相似的确认半径,Loss 最小则理论上 Dn->m, Dp->0
  5. 越接近样本情况,损失越小
  6. 光滑(smooth),适合优化算法
  7. 非指数计算,算力消耗相对较低

三元损失(Triplet Loss) 函数来自于论文《FaceNet: A Unified Embedding for Face Recognition and Clustering》中 [15] ,提出的通过拆分 三元组(Triplet),选取正负样本与原样本进行差异化处理,来让预测值趋近于原样本而远离负样本的一种损失函数。

三元组(Triplet) 来自于输入分批的卷积神经网络(CNN)结果,我们需要将输入样本分为三类,在每一时代(Epoch)中都进行相同神经网络隐藏层权重(Wights)影响下的结果计算。累计 单次样本 的损失计算(Loss),以求得分批的损失函数(Cost Function)输出评分。

Triplet Loss 的使用

介于三元组损失提出最初目的,是为了进行人脸识别(FD [Face Detection]),我们因此取用人脸样本集举例。类似于人脸样本集,一般由 PP 位不同人的 DD 张该人不同脸的图片样本组成的,样本总量 PDP \cdot D 大小的数据集。

以此为基础,三元组损失要求的三种样本分类子集分别是:

  • 相似正样本集(Positives),由同人不同脸组成的 D1D -1 大小子集
  • 相反负样本集(Negatives),由不同人不同脸组成的 (P1)(D1)(P-1) \cdot (D -1) 大小子集
  • 原样本对照集(Anchors),由不同人同脸(选一校订)组成的 PP 大小子集

这三类子集,在数据分批后,会被分为相同批数并组合为一批数据,作为单次迭代输入数据,参与训练。我们仍然采用角标 [i][_i] 来表示分批,那么有:

batch_size=(Di1)+(Pi1)(Di1)+Pi=DiPi {batch\_size} = (D_i-1) + (P_i-1)(D_i-1)+P_i = D_iP_i

则,在分批数据参与一次批计算后,最终会构成 batch_size{batch\_size} 大小的一组 嵌入集(Embeddings),被我们用来计算损失函数(Loss)的实际处理对象。

最终,计算损失后的三元组,按照质量 来划分,可以分为三个类别:

  • 易辨三元组(easy triplets),可以使得 loss 基本趋近于 0 的类型
  • 难辩三元组(hard triplets),有 Dn < Dp 的三元组,一定会误判,尽量避免
  • 模糊三元组(semi-hard triplets),有 Dp < Dn < Dp + m,这是我们重点训练的类型

可见,如果构成的三元组一上来就是易辨三元组,那只能证明模型训练参数的启动配置,使模型陷入了过拟合。通常,我们希望每一时代(Epoch)被计算的三元组都具有一定的模糊特性,而方便权重更新。因此,模糊三元组(semi-hard triplets)才是迭代的选择

那么怎么评估当前的三元组,是否是模糊三元组呢?

其实很简单,通过当前正样本集所占有效样本的百分比,就能大致估算是否属于模糊类型。记正样本集百分比为 fraction_positive{fraction\_positive} ,则有:

fraction_positive=num_positivenum_available=count(loss>0)count(vector) {\displaystyle \begin{aligned} {fraction\_positive} &= \frac{num\_positive}{num\_available} \\ &= \frac{count( loss > 0)}{count(vector)} \\ \end{aligned} }

我们一般取 fraction_positive>0.2{fraction\_positive} > 0.2 认为是一次有效训练中的模糊三元组数据。

三元损失在对比损失的基础上更近一步,引入了正负样本概念,来使得分类预测结果更加聚集,且使分类间能够更加远离。本身计算并不算非常复杂,因此可以用在如人脸识别、车辆识别等模型的移动端迁移上。但是,三元损失只是在对比损失上引入正负概念,实际处理过程中,每次只能对比一个负样本而忽略了其他的非关联性。这样就很容易造成迭代结果陷入不稳定(在多个距离相近但实际不同的负样本间抖动),或者局部最优解。

Triplet Loss 算子化

利用 C 语言实现对算子的封装,有:

#include <math.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

#define BATCH_SIZE 10     // Batch_size = Samples_of_Person x Data/Person
#define VECTOR_SIZE 128   // Extract output layer Feature vector's dimissions
#define DEVIDE_SAFE 1e-12 // protect when gridant at 0 will be to lage

// Pairwise Distance Calculation
void pairwise_distance(double embeddings[BATCH_SIZE][VECTOR_SIZE],
                       double distances[BATCH_SIZE][BATCH_SIZE], bool squared) {
  for (int i = 0; i < BATCH_SIZE; i++) {
    for (int j = 0; j < BATCH_SIZE; j++) {
      double dot_product = 0.0;
      double square_norm_i = 0.0;
      double square_norm_j = 0.0;

      for (int k = 0; k < VECTOR_SIZE; k++) {
        dot_product += embeddings[i][k] * embeddings[j][k];
        square_norm_i += embeddings[i][k] * embeddings[i][k];
        square_norm_j += embeddings[j][k] * embeddings[j][k];
      }

      distances[i][j] = square_norm_i - (2.0 * dot_product) + square_norm_j;

      if (!squared) {
        if (distances[i][j] < 0.0) {
          distances[i][j] = 0.0;
        } else {
          distances[i][j] = sqrt(distances[i][j]);
        }
      }
    }
  }
}

// Get Triplet Mask
void get_triplet_mask(int labels[BATCH_SIZE],
                      bool mask[BATCH_SIZE][BATCH_SIZE][BATCH_SIZE]) {
  for (int i = 0; i < BATCH_SIZE; i++) {
    for (int j = 0; j < BATCH_SIZE; j++) {
      for (int k = 0; k < BATCH_SIZE; k++) {
        // indices_equal
        bool i_not_j = (i != j);
        bool i_not_k = (i != k);
        bool j_not_k = (j != k);
        bool distinct_indices = (i_not_j && i_not_k && j_not_k);

        // label_equal
        bool i_equal_j = (labels[i] == labels[j]);
        bool i_equal_k = (labels[i] == labels[k]);
        bool valid_labels = (i_equal_j && !i_equal_k);

        // mask depends on both
        mask[i][j][k] = (distinct_indices && valid_labels);
      }
    }
  }
}

// Batch All Triplet Loss
double triplet_loss(int labels[BATCH_SIZE],
                    double embeddings[BATCH_SIZE][VECTOR_SIZE], double margin,
                    bool squared, double *fraction_positives) {

  // So, this only caused once per epoch for certain storage space
  double pairwise_distances[BATCH_SIZE][BATCH_SIZE];
  bool triplets_avail_masks[BATCH_SIZE][BATCH_SIZE][BATCH_SIZE];

  // Pairwise distance calculation
  pairwise_distance(embeddings, pairwise_distances, squared);

  // Get triplet mask
  get_triplet_mask(labels, triplets_avail_masks);

  // Triplet loss calculation
  int num_positive = 0;
  int num_validate = 0;
  double triplet_cost = 0.0;
  {
    for (int i = 0; i < BATCH_SIZE; i++) {     // i for Anchor
      for (int j = 0; j < BATCH_SIZE; j++) {   // j for positive
        for (int k = 0; k < BATCH_SIZE; k++) { // k for negative
          double current_mask = triplets_avail_masks[i][j][k];
          double current_loss =
              fmax(0, current_mask * (pairwise_distances[i][j] -
                                      pairwise_distances[i][k] + margin));
          triplet_cost += current_loss;

          // Calculate number of positive triplets and valid triplets
          if (current_loss > 0) {
            num_positive++;
          }
          if (current_mask > 0) {
            num_validate++;
          }
        }
      }
    }
  }
  // Calculate fraction of positive triplets
  *fraction_positives = (double)num_positive / ((double)num_validate + DEVIDE_SAFE);
  return triplet_cost / (double)(num_positive + DEVIDE_SAFE);
}

int main() {
  // Example input (fulfill to BATCH_SIZE x VECTOR_SIZE)
  // Use Random labels and embeddings for testing
  // Use three classes as different type, to generate labels
  int type = 3;
  int labels[BATCH_SIZE];
  double embeddings[BATCH_SIZE][VECTOR_SIZE];
  for (int i = 0; i < BATCH_SIZE; i++) {
    labels[i] = rand() % type;
    for (int j = 0; j < VECTOR_SIZE; j++) {
      embeddings[i][j] = (double)rand() / (double)RAND_MAX;
    }
  }

  double margin = 0.2;
  double fraction_positives = 0.0;
  double triplet_cost_value = triplet_loss(labels, embeddings, margin, false, &fraction_positives);
  printf("The triplet loss is %f with positives %f \n", triplet_cost_value, fraction_positives);
  return 0;
}

运行验证可得到结果:

The triplet loss is 0.270146 with positives 0.668605

虽然看上去比较复杂,然而在实际执行过程中, 一个时代(Epoch)只会执行一次三元组损失的计算,而空间复杂度上,仅额外增加了距离矩阵和遮罩的共 O(batch_size2+batch_size3)O({batch\_size}^2 + {batch\_size}^3) 的空间大小。是完全可以接受的。

代码中,我们所使用的 遮罩(Mask)矩阵,实际上相当于将原论文中对三元组的三分类计算,用遮罩来代替了有效处理流程。这样做可行的基本原因,在于距离矩阵本身,在以整体分批不做区别输入的情况下,仍旧可以用全体分批包含样本的欧式距离,构成 batch_size×batch_size{batch\_size} \times {batch\_size} 大小的差异矩阵,记为 MdistM_{dist} 。以人脸检测为例,同人物同一张样本脸的情况,就相当于 MdistM_{dist} 的对角线位置。而对角线两侧的数据,则涵盖了同人不同脸、不同人的两种类型。

如此,计算所得 MdistM_{dist} 实际就包含和三元组的三分类计算中,不同分类的 所有距离类型。与此同时,最终损失函数的计算,是要叠加所有分类独立计算的单次损失的。进而,让我们有机会通过遮罩矩阵就能直接规划不同分类情况,应该取用哪一个距离值,来直接获取当次损失值叠加。如果记遮罩矩阵为 MmaskM_{mask} ,那么三元损失有工程公式:

Loss=MdistMmask {\displaystyle \begin{aligned} Loss &= M_{dist} \cdot M_{mask} \\ \end{aligned} }

而既然是矩阵乘法,除了本书例子中采用的纯 C 语言实现外,也可以通过 GPU 算子来实现进一步加速。类似于 CUDA 算子,或部分成熟的推理引擎(如 Keras、py-Touch 等)就是这样处理的。

从这个例子就能看出, 有效的工程化能够极大提升算法的训练效率,减小耗时。

这即是工程师在此处的关键作用。

Copyright © Since 2021 李述博 (Arikan.Li) , All Rights Reserved all right reserved,powered by GitbookLast Updated: 2024-03-22 18:02:18

results matching ""

    No results matching ""