在计算机视觉任务中,很多时候会面对棘手的多标签数据集,比如多标签分类数据集 (即一张图像有多个标签)、目标检测数据集、语义分割数据集
对于类别数量均衡的数据集,留出法可以为模型分配更多的训练数据,从而使模型具备更好的效果;而对于类别极其不均衡的数据集,通过欠采样为模型提供类别均衡的数据集,可以避免模型在某一类别上的识别出现严重的缺陷
在大模型的训练中,留出法是最常用的数据集划分方法;而欠采样因为可以得到类别均衡的数据集,被广泛应用于少样本迁移学习
针对计算机视觉任务的多标签数据集,本文定义了一种通用的数据集标签表示格式:
- 多标签分类数据集:每张图像可以具有不固定数量的类别,比如第 1 张图像属于“类别 0”和“类别 2”
0 | 1 | 2 | |
train_0.jpg | 1 | 0 | 1 |
train_1.jpg | 1 | 1 | 0 |
train_2.jpg | 0 | 0 | 1 |
…… | … | … | … |
train_99.jpg | 1 | 1 | 1 |
- 目标检测数据集:第 i 行第 j 列的数据表示了第 i 张图像中第 j 个类别的边界框总数
0 | 1 | 2 | |
train_0.jpg | 1 | 9 | 9 |
train_1.jpg | 3 | 5 | 8 |
train_2.jpg | 9 | 3 | 5 |
…… | … | … | … |
train_99.jpg | 8 | 0 | 6 |
- 语义分割数据集:第 i 行第 j 列的数据表示,第 i 张图像中第 j 个类别的语义区域在全图的比例
0 | 1 | 2 | |
train_0.jpg | 0.293 | 0.383 | 0.322 |
train_1.jpg | 0.337 | 0.262 | 0.400 |
train_2.jpg | 0.191 | 0.389 | 0.420 |
…… | … | … | … |
train_99.jpg | 0.363 | 0.592 | 0.045 |
像这样的 pd.Dataframe (记为 ),不同的数值类型、不同的数值范围可以表示不同种类的数据集 (当然,所有数值必须为非负数)
下面将详细阐述留出法、欠采样如何对这种格式的数据集进行划分
留出法 hold-out
留出法即将数据集划分为两个互斥的子集,一个作为训练集,另一个作为验证集
一般情况下我们会使用随机划分来实现留出法,以目标检测数据集为例,可能有以下情况:
- 如果数据集中某一类别的边界框均聚集在训练集,神经网络在对该类别的学习可能过拟合,在验证集上的表现可信度较低
- 如果数据集中某一类别的边界框均聚集在验证集,神经网络在训练集上无法充分学习到该类别的特征,同时在验证集上的表现也将较差
如果要将目标检测的数据集按照 4:1 的比例分割,那么最优的解决方案显然不是暴力地按图像数量分割,而是按各类别的边界框数量分割,使得:
- 类别 1 的边界框在训练集中的数量 : 在验证集中的数量 ≈ 4:1
- 类别 2 的边界框在训练集中的数量 : 在验证集中的数量 ≈ 4:1
- ……
为了实现以上效果,定义以下变量 (其中 ):
训练集统计:训练集中各个类别的边界框总数,初始全为 0 | |
验证集统计:验证集中各个类别的边界框总数,初始全为 0 | |
图像统计:第 i 张图像各个类别的边界框总数 |
初始状态下,;给定比例为 4:1,定义某状态的 SSE 损失值为:
打乱数据集的图像顺序后,依次取出数据集中的图像 id、图像统计 ,将该图像加入训练集 / 验证集,则衍生 2 个子状态:
- 加入训练集:训练集统计变为
,验证集统计仍为
,对应损失值为
- 加入验证集:训练集统计仍为
,验证集统计变为
,对应损失值为
执行较小的损失值所对应的动作后,更新 ,最后可使得数据集的分割结果逼近我们想要的类别均衡
加上存储图像 id 的操作,该算法的大致流程如下:
import logging
import numpy as np
import pandas as pd
from tqdm import tqdm
logging.basicConfig(format='%(message)s', level=logging.INFO)
LOGGER = logging.getLogger(__name__)
def hold_out(cls_cnt: pd.DataFrame, scale: float, seed=0):
''' cls_cnt: Dataframe[classes, img_id]
scale: 各类别分布在训练集中的比例
return: 训练集 id 列表, 验证集 id 列表'''
dtype = np.int64 if 'int' in str(next(iter(cls_cnt.dtypes))) else np.float64
radio = scale / (1 - scale)
# 打乱图像的次序
idx = cls_cnt.index.values
np.random.seed(seed)
np.random.shuffle(idx)
# 记录训练集、验证集当前各类别数量
data_cnt = np.zeros([2, len(cls_cnt.columns)], dtype=np.float64)
data_cnt[1] += 1e-4
# 存放训练集、验证集数据的 id
data_pool = [[] for _ in range(2)]
pbar = tqdm(idx)
# 留出法: 计算期望比例 与 执行动作后比例的 SSE 损失
loss_func = lambda x: np.square(x[0] / x[1] - radio).sum()
for i in pbar:
cnt = cls_cnt.loc[i]
loss = np.zeros(2, dtype=np.float64)
for j, next_sit in enumerate([data_cnt.copy() for _ in range(2)]):
next_sit[j] += cnt
loss[j] = loss_func(next_sit)
# 根据损失值选择加入的数据集
choose = loss.argmin()
data_cnt[choose] += cnt
data_pool[choose].append(i)
# 输出当前的分割情况
cur_scale = data_cnt[0] / data_cnt.sum(axis=0) - scale
pbar.set_description(f'Category scale error ∈ [{cur_scale.min():.3f}, {cur_scale.max():.3f}]')
# 输出训练集、验证集信息
data_cnt = data_cnt.round(3).astype(dtype)
LOGGER.info(f'Train Set ({len(data_pool[0])}): {data_cnt[0]}')
LOGGER.info(f'Eval Set ({len(data_pool[1])}): {data_cnt[1]}')
return data_pool
欠采样 undersampling
欠采样用于处理类别不平衡的数据集。该代码的主要思想是通过随机采样的方式平衡各类别的数量,使得每个类别的实例数量达到设定的数量:
- 首先将数据集按照是否有标签进行分组,将无标记的数据二分后分别放入训练集、验证集
- 对于有标签的数据集,选择当前实例最少的类别,将阳性样本 (当前类别的实例) 和阴性样本 (非当前类别的实例) 分别进行处理
- 对于阳性样本,如果该类别的实例数量小于设定的数量,则将其加入到训练集中,直到达到设定数量为止。具体实现是,先将阳性样本随机排序,然后计算累积实例数量,找到使得实例数量最接近设定数量的位置。将该位置之前的所有样本都加入到训练集中,其它样本加入验证集
- 对于阴性样本,将其全部放回数据集。 重复 2-4 步,直到所有的样本都被加入到训练集 / 验证集
在函数中,通过参数 n 来控制每个类别的实例数量,n 可以是 int、float、list 或者 tuple 类型。如果是 int 或 float 类型,则表示所有类别的实例数量都相同;如果是 list 或 tuple 类型,则表示各个类别的实例数量可以不同
import logging
import numpy as np
import pandas as pd
logging.basicConfig(format='%(message)s', level=logging.INFO)
LOGGER = logging.getLogger(__name__)
def undersampling(cls_cnt: pd.DataFrame, n, seed=0):
''' cls_cnt: Dataframe[classes, img_id]
n: 各类别实例的采样数量 (int, float, list, tuple)
return: 训练集 id 列表, 验证集 id 列表'''
dtype = np.int64 if 'int' in str(next(iter(cls_cnt.dtypes))) else np.float64
np.random.seed(seed)
cls_cnt_backup = cls_cnt
n_cls = len(cls_cnt.columns)
# 对参数 n 进行修改 / 校验
if not hasattr(n, '__len__'): n = [n] * n_cls
assert len(n) == n_cls, 'The parameter n does not match the number of categories'
# 筛选出无标签数据
g = dict(list(cls_cnt.groupby(cls_cnt.sum(axis=1) == 0, sort=False)))
unlabeled, cls_cnt = map(lambda k: g.get(k, pd.DataFrame()), (True, False))
unlabeled = list(unlabeled.index)
np.random.shuffle(unlabeled)
# 存放训练集、验证集数据的 id
m = len(unlabeled) // 2
data_pool = [unlabeled[:m], unlabeled[m:]]
data_cnt = np.zeros(n_cls, dtype=np.float64)
while not cls_cnt.empty:
# 取出当前 cls_cnt 最少的类
j = cls_cnt.sum().apply(lambda x: np.inf if x == 0 else x).argmin()
g = dict(list(cls_cnt.groupby(cls_cnt[j] > 0, sort=False)))
# 对阳性样本进行划分, 放回阴性样本
posi, cls_cnt = map(lambda k: g.get(k, pd.DataFrame()), (True, False))
m, idx = -1, list(posi.index)
if not posi.empty:
lack = n[j] - data_cnt[j]
if lack > 0:
# 选取前 m 个加入训练集
np.random.shuffle(idx)
posi = posi.loc[idx]
cumsum = np.cumsum(posi[j].to_numpy())
m = np.abs(cumsum - lack).argmin()
# 考虑极端情况下, 不加入更好
if m == 0 and cumsum[0] > lack: m = -1
data_pool[0] += idx[:m + 1]
data_cnt += posi.iloc[:m + 1].sum() if m >= 0 else 0
# 其余放入验证集
data_pool[1] += idx[m + 1:]
# 输出训练集、验证集信息
data_cnt = data_cnt.to_numpy()
LOGGER.info(f'Train Set ({len(data_pool[0])}): {data_cnt.round(3).astype(dtype)}')
eval_cnt = cls_cnt_backup.sum().to_numpy() - data_cnt
LOGGER.info(f'Eval Set ({len(data_pool[1])}): {eval_cnt.round(3).astype(dtype)}')
return data_pool