numpy 多标签数据集划分 留出法 / 欠采样

介绍计算机视觉任务中多标签数据集的留出法与欠采样两种划分方法,旨在解决类别不均衡问题,提高模型泛化能力。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在计算机视觉任务中,很多时候会面对棘手的多标签数据集,比如多标签分类数据集 (即一张图像有多个标签)、目标检测数据集、语义分割数据集

对于类别数量均衡的数据集,留出法可以为模型分配更多的训练数据,从而使模型具备更好的效果;而对于类别极其不均衡的数据集,通过欠采样为模型提供类别均衡的数据集,可以避免模型在某一类别上的识别出现严重的缺陷

在大模型的训练中,留出法是最常用的数据集划分方法;而欠采样因为可以得到类别均衡的数据集,被广泛应用于少样本迁移学习

针对计算机视觉任务的多标签数据集,本文定义了一种通用的数据集标签表示格式:

  • 多标签分类数据集:每张图像可以具有不固定数量的类别,比如第 1 张图像属于“类别 0”和“类别 2”
012
train_0.jpg 101
train_1.jpg110
train_2.jpg001
……
train_99.jpg111
  • 目标检测数据集:第 i 行第 j 列的数据表示了第 i 张图像中第 j 个类别的边界框总数
012
train_0.jpg 199
train_1.jpg358
train_2.jpg935
……
train_99.jpg806
  • 语义分割数据集:第 i 行第 j 列的数据表示,第 i 张图像中第 j 个类别的语义区域在全图的比例
012
train_0.jpg 0.2930.3830.322
train_1.jpg0.3370.2620.400
train_2.jpg0.1910.3890.420
……
train_99.jpg0.3630.5920.045

像这样的 pd.Dataframe (记为 cls\_cnt),不同的数值类型、不同的数值范围可以表示不同种类的数据集 (当然,所有数值必须为非负数)

下面将详细阐述留出法、欠采样如何对这种格式的数据集进行划分

留出法 hold-out

留出法即将数据集划分为两个互斥的子集,一个作为训练集,另一个作为验证集

一般情况下我们会使用随机划分来实现留出法,以目标检测数据集为例,可能有以下情况:

  • 如果数据集中某一类别的边界框均聚集在训练集,神经网络在对该类别的学习可能过拟合,在验证集上的表现可信度较低
  • 如果数据集中某一类别的边界框均聚集在验证集,神经网络在训练集上无法充分学习到该类别的特征,同时在验证集上的表现也将较差

如果要将目标检测的数据集按照 4:1 的比例分割,那么最优的解决方案显然不是暴力地按图像数量分割,而是按各类别的边界框数量分割,使得:

  • 类别 1 的边界框在训练集中的数量 : 在验证集中的数量 ≈ 4:1
  • 类别 2 的边界框在训练集中的数量 : 在验证集中的数量 ≈ 4:1
  • ……

为了实现以上效果,定义以下变量 (其中 cls\_cnt = \begin{bmatrix} \vec{C}_1 & \vec{C}_2 & \cdots & \vec{C}_n \end{bmatrix}):

\vec{N_t}=\begin{bmatrix} t_1 & t_2 & \cdots & t_n \end{bmatrix}训练集统计:训练集中各个类别的边界框总数,初始全为 0
\vec{N_e}=\begin{bmatrix} e_1 & e_2 & \cdots & e_n \end{bmatrix}验证集统计:验证集中各个类别的边界框总数,初始全为 0
\vec{C_i}=\begin{bmatrix} c_{i1} & c_{i2} & \cdots & c_{in} \end{bmatrix}图像统计:第 i 张图像各个类别的边界框总数

初始状态下,\vec{N_t}=\vec{N_e}=\begin{bmatrix} 0 & 0 & 0 \end{bmatrix};给定比例为 4:1,定义某状态的 SSE 损失值为:

\mathcal{L}(\vec{N_t}, \vec{N_e})=\sum (\frac{\vec{N_t}}{\vec{N_e}}-4)^2

打乱数据集的图像顺序后,依次取出数据集中的图像 id、图像统计 \vec{C_i},将该图像加入训练集 / 验证集,则衍生 2 个子状态:

  • 加入训练集:训练集统计变为 \vec{N_t} + \vec{C_i},验证集统计仍为 \vec{N_e},对应损失值为 \mathcal{L}(\vec{N_t} + \vec{C_i}, \vec{N_e})
  • 加入验证集:训练集统计仍为 \vec{N_t},验证集统计变为 \vec{N_e} + \vec{C_i},对应损失值为 \mathcal{L}(\vec{N_t}, \vec{N_e} + \vec{C_i})

执行较小的损失值所对应的动作后,更新 \vec{N_t}, \vec{N_e},最后可使得数据集的分割结果逼近我们想要的类别均衡

加上存储图像 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

欠采样用于处理类别不平衡的数据集。该代码的主要思想是通过随机采样的方式平衡各类别的数量,使得每个类别的实例数量达到设定的数量:

  1. 首先将数据集按照是否有标签进行分组,将无标记的数据二分后分别放入训练集、验证集
  2. 对于有标签的数据集,选择当前实例最少的类别,将阳性样本 (当前类别的实例) 和阴性样本 (非当前类别的实例) 分别进行处理
  3. 对于阳性样本,如果该类别的实例数量小于设定的数量,则将其加入到训练集中,直到达到设定数量为止。具体实现是,先将阳性样本随机排序,然后计算累积实例数量,找到使得实例数量最接近设定数量的位置。将该位置之前的所有样本都加入到训练集中,其它样本加入验证集
  4. 对于阴性样本,将其全部放回数据集。 重复 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
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

荷碧TongZJ

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值