データ拡張の手法一覧 — 画像・テキスト・時系列への適用

データ拡張(Data Augmentation)は、既存の訓練データに変換を加えて人工的にデータを増やす技術です。深層学習において、過学習を抑制しモデルの汎化性能を向上させる最も効果的な手法の一つです。

本記事では、画像・テキスト・時系列データに対するデータ拡張手法を、数学的な定義とPython実装を交えて解説します。

本記事の内容

  • データ拡張の基本概念と理論的背景
  • 画像データに対する拡張手法
  • テキストデータに対する拡張手法
  • 時系列データに対する拡張手法
  • Pythonでの実装と可視化

データ拡張とは

基本概念

データ拡張は、訓練データ $\mathcal{D} = \{(x_i, y_i)\}_{i=1}^N$ に対して、ラベルを保存する変換 $T$ を適用して拡張データセット $\mathcal{D}’$ を生成する手法です。

$$ \mathcal{D}’ = \{(T(x_i), y_i) \mid (x_i, y_i) \in \mathcal{D}, T \in \mathcal{T}\} $$

ここで、$\mathcal{T}$ は適用可能な変換の集合です。

理論的背景

データ拡張は、以下の数学的な観点から正当化されます。

1. 不変性の導入

変換 $T$ に対して不変な表現を学習させることで、モデルは本質的な特徴を捉えます:

$$ f(T(x)) = f(x) \quad \forall T \in \mathcal{T} $$

2. 正則化効果

データ拡張は暗黙的な正則化として機能し、期待損失を以下のように変更します:

$$ \mathcal{L}_{\text{aug}} = \mathbb{E}_{T \sim \mathcal{T}}[\ell(f(T(x)), y)] $$

3. データ分布の拡大

データ拡張は、訓練分布 $p_{\text{train}}(x)$ をより広い分布に拡張し、テスト分布 $p_{\text{test}}(x)$ との乖離を減らします。

画像データに対する拡張手法

幾何学的変換

画像 $I \in \mathbb{R}^{H \times W \times C}$ に対する幾何学的変換は、座標変換として定義されます。

アフィン変換

$$ \begin{pmatrix} x’ \\ y’ \end{pmatrix} = \begin{pmatrix} a & b \\ c & d \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} + \begin{pmatrix} t_x \\ t_y \end{pmatrix} $$

具体的な変換例:

変換 行列 効果
回転 $\begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix}$ 角度 $\theta$ の回転
スケーリング $\begin{pmatrix} s_x & 0 \\ 0 & s_y \end{pmatrix}$ 拡大・縮小
せん断 $\begin{pmatrix} 1 & \lambda \\ 0 & 1 \end{pmatrix}$ 水平方向のせん断

色空間変換

RGB画像に対する色空間変換は、各チャネルの値を変更します。

明度・コントラスト調整

$$ I'(x, y) = \alpha \cdot I(x, y) + \beta $$

ここで、$\alpha$ はコントラスト、$\beta$ は明度を制御します。

カラージッター

各チャネルにランダムな摂動を加えます:

$$ I’_c = I_c + \epsilon_c, \quad \epsilon_c \sim \mathcal{U}(-\delta, \delta) $$

Cutout / Random Erasing

画像の一部をマスクする手法です。

$$ I'(x, y) = \begin{cases} 0 & \text{if } (x, y) \in \mathcal{R} \\ I(x, y) & \text{otherwise} \end{cases} $$

ここで、$\mathcal{R}$ はランダムに選択された矩形領域です。

Mixup

2つのサンプルを線形補間して新しいサンプルを生成します。

$$ \tilde{x} = \lambda x_i + (1 – \lambda) x_j $$ $$ \tilde{y} = \lambda y_i + (1 – \lambda) y_j $$

ここで、$\lambda \sim \text{Beta}(\alpha, \alpha)$ です。

CutMix

一方の画像の領域を他方の画像で置き換えます。

$$ \tilde{x} = M \odot x_i + (1 – M) \odot x_j $$

ここで、$M \in \{0, 1\}^{H \times W}$ はバイナリマスクです。

テキストデータに対する拡張手法

同義語置換(Synonym Replacement)

単語 $w$ を同義語 $w’$ で置き換えます:

$$ T_{\text{syn}}(w) = w’ \quad \text{where } w’ \in \text{Synonyms}(w) $$

ランダム挿入(Random Insertion)

ランダムな位置に同義語を挿入します。

ランダム削除(Random Deletion)

確率 $p$ で各単語を削除します:

$$ P(\text{delete } w_i) = p $$

Back Translation

テキストを他言語に翻訳し、再び元の言語に翻訳することで言い換えを生成します:

$$ x’ = \text{Translate}_{B \to A}(\text{Translate}_{A \to B}(x)) $$

時系列データに対する拡張手法

ジッタリング(Jittering)

時系列 $x_t$ にランダムノイズを加えます:

$$ x’_t = x_t + \epsilon_t, \quad \epsilon_t \sim \mathcal{N}(0, \sigma^2) $$

スケーリング

時系列全体をスケーリングします:

$$ x’_t = \alpha \cdot x_t, \quad \alpha \sim \mathcal{N}(1, \sigma^2) $$

時間ワーピング(Time Warping)

時間軸を非線形に変形します。スプライン補間を用いて時間軸の伸縮を行います。

ウィンドウスライシング

時系列の一部を切り出します:

$$ x’_{1:L’} = x_{s:s+L’}, \quad s \sim \mathcal{U}(1, T – L’) $$

Pythonでの実装

画像データ拡張

import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
import torchvision.transforms as T

# サンプル画像の作成(グラデーション画像)
def create_sample_image(size=224):
    """サンプル画像を作成"""
    x = np.linspace(0, 1, size)
    y = np.linspace(0, 1, size)
    X, Y = np.meshgrid(x, y)

    # RGB画像を作成
    R = (np.sin(2 * np.pi * X) + 1) / 2
    G = (np.sin(2 * np.pi * Y) + 1) / 2
    B = (np.sin(2 * np.pi * (X + Y)) + 1) / 2

    img = np.stack([R, G, B], axis=-1)
    return (img * 255).astype(np.uint8)

# 画像拡張の定義
transforms_dict = {
    'Original': T.Compose([T.ToTensor()]),
    'HorizontalFlip': T.Compose([T.RandomHorizontalFlip(p=1.0), T.ToTensor()]),
    'Rotation': T.Compose([T.RandomRotation(30), T.ToTensor()]),
    'ColorJitter': T.Compose([T.ColorJitter(brightness=0.5, contrast=0.5), T.ToTensor()]),
    'RandomCrop': T.Compose([T.RandomResizedCrop(224, scale=(0.6, 1.0)), T.ToTensor()]),
    'GaussianBlur': T.Compose([T.GaussianBlur(kernel_size=15), T.ToTensor()]),
}

# 可視化
img = create_sample_image()
pil_img = Image.fromarray(img)

fig, axes = plt.subplots(2, 3, figsize=(12, 8))
axes = axes.flatten()

for ax, (name, transform) in zip(axes, transforms_dict.items()):
    transformed = transform(pil_img)
    # Tensor to numpy (C, H, W) -> (H, W, C)
    img_np = transformed.permute(1, 2, 0).numpy()
    ax.imshow(img_np)
    ax.set_title(name)
    ax.axis('off')

plt.tight_layout()
plt.savefig('image_augmentation.png', dpi=150, bbox_inches='tight')
plt.show()

Mixupの実装

import torch
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt

def mixup_data(x, y, alpha=0.4):
    """
    Mixupデータ拡張を適用

    Parameters:
    -----------
    x : torch.Tensor
        入力データ (batch_size, ...)
    y : torch.Tensor
        ラベル (batch_size,) or (batch_size, num_classes)
    alpha : float
        Beta分布のパラメータ

    Returns:
    --------
    mixed_x : torch.Tensor
        混合された入力
    y_a, y_b : torch.Tensor
        混合元のラベル
    lam : float
        混合比率
    """
    if alpha > 0:
        lam = np.random.beta(alpha, alpha)
    else:
        lam = 1.0

    batch_size = x.size(0)
    index = torch.randperm(batch_size)

    mixed_x = lam * x + (1 - lam) * x[index, :]
    y_a, y_b = y, y[index]

    return mixed_x, y_a, y_b, lam

def mixup_criterion(criterion, pred, y_a, y_b, lam):
    """Mixup用の損失関数"""
    return lam * criterion(pred, y_a) + (1 - lam) * criterion(pred, y_b)

# Mixupの可視化
np.random.seed(42)
img1 = np.random.rand(64, 64, 3) * 0.5 + np.array([0.5, 0, 0])  # 赤っぽい
img2 = np.random.rand(64, 64, 3) * 0.5 + np.array([0, 0, 0.5])  # 青っぽい

fig, axes = plt.subplots(1, 5, figsize=(15, 3))
lambdas = [1.0, 0.75, 0.5, 0.25, 0.0]

for ax, lam in zip(axes, lambdas):
    mixed = lam * img1 + (1 - lam) * img2
    ax.imshow(np.clip(mixed, 0, 1))
    ax.set_title(f'λ = {lam}')
    ax.axis('off')

plt.suptitle('Mixup Visualization')
plt.tight_layout()
plt.savefig('mixup_visualization.png', dpi=150, bbox_inches='tight')
plt.show()

テキストデータ拡張

import random
import re

class TextAugmenter:
    """テキストデータ拡張クラス"""

    def __init__(self, synonym_dict=None):
        # 簡易的な同義語辞書(実際にはWordNetなどを使用)
        self.synonym_dict = synonym_dict or {
            'good': ['great', 'excellent', 'fine', 'nice'],
            'bad': ['poor', 'terrible', 'awful', 'horrible'],
            'big': ['large', 'huge', 'enormous', 'massive'],
            'small': ['tiny', 'little', 'miniature', 'compact'],
            'fast': ['quick', 'rapid', 'swift', 'speedy'],
            'happy': ['joyful', 'cheerful', 'delighted', 'pleased'],
        }

    def synonym_replacement(self, text, n=1):
        """同義語置換"""
        words = text.split()
        new_words = words.copy()

        # 置換可能な単語を見つける
        replaceable = [(i, w.lower()) for i, w in enumerate(words)
                       if w.lower() in self.synonym_dict]

        if not replaceable:
            return text

        # n個の単語を置換
        random.shuffle(replaceable)
        for i, word in replaceable[:n]:
            synonyms = self.synonym_dict[word]
            new_words[i] = random.choice(synonyms)

        return ' '.join(new_words)

    def random_deletion(self, text, p=0.1):
        """ランダム削除"""
        words = text.split()
        if len(words) == 1:
            return text

        new_words = [w for w in words if random.random() > p]

        if len(new_words) == 0:
            return random.choice(words)

        return ' '.join(new_words)

    def random_swap(self, text, n=1):
        """ランダム入れ替え"""
        words = text.split()
        new_words = words.copy()

        for _ in range(n):
            if len(new_words) < 2:
                break
            idx1, idx2 = random.sample(range(len(new_words)), 2)
            new_words[idx1], new_words[idx2] = new_words[idx2], new_words[idx1]

        return ' '.join(new_words)

# 使用例
augmenter = TextAugmenter()
original = "This is a good example of fast learning"

print(f"Original: {original}")
print(f"Synonym Replacement: {augmenter.synonym_replacement(original, n=2)}")
print(f"Random Deletion: {augmenter.random_deletion(original, p=0.2)}")
print(f"Random Swap: {augmenter.random_swap(original, n=2)}")

時系列データ拡張

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import CubicSpline

def jitter(x, sigma=0.03):
    """ジッタリング:ランダムノイズを追加"""
    return x + np.random.normal(0, sigma, x.shape)

def scaling(x, sigma=0.1):
    """スケーリング:振幅を変化"""
    factor = np.random.normal(1, sigma)
    return x * factor

def time_warp(x, sigma=0.2, knot=4):
    """時間ワーピング:時間軸を非線形に変形"""
    orig_steps = np.arange(len(x))

    # ランダムな制御点を生成
    random_warps = np.random.normal(1, sigma, knot + 2)
    warp_steps = np.linspace(0, len(x) - 1, knot + 2)

    # スプライン補間で時間軸の変換を計算
    time_warp_func = CubicSpline(warp_steps, warp_steps * random_warps)
    warped_steps = time_warp_func(orig_steps)
    warped_steps = np.clip(warped_steps, 0, len(x) - 1)

    # 新しい時間軸で補間
    data_spline = CubicSpline(orig_steps, x)
    return data_spline(warped_steps)

def window_slice(x, reduce_ratio=0.9):
    """ウィンドウスライシング:一部を切り出し"""
    target_len = int(len(x) * reduce_ratio)
    if target_len >= len(x):
        return x

    start = np.random.randint(0, len(x) - target_len)
    return x[start:start + target_len]

# 時系列データの生成
np.random.seed(42)
t = np.linspace(0, 4 * np.pi, 200)
original_signal = np.sin(t) + 0.5 * np.sin(3 * t)

# 拡張手法の適用
augmentations = {
    'Original': original_signal,
    'Jittering': jitter(original_signal, sigma=0.1),
    'Scaling': scaling(original_signal, sigma=0.2),
    'Time Warping': time_warp(original_signal, sigma=0.3),
}

# 可視化
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
axes = axes.flatten()

for ax, (name, signal) in zip(axes, augmentations.items()):
    ax.plot(signal, 'b-', alpha=0.8)
    ax.plot(original_signal, 'r--', alpha=0.3, label='Original')
    ax.set_title(name)
    ax.set_xlabel('Time')
    ax.set_ylabel('Value')
    ax.legend()
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('timeseries_augmentation.png', dpi=150, bbox_inches='tight')
plt.show()

実験:データ拡張の効果

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
import matplotlib.pyplot as plt

# 簡単な分類問題で効果を検証
np.random.seed(42)
torch.manual_seed(42)

# データ生成(小さなデータセット)
n_samples = 100
X = np.random.randn(n_samples, 2)
y = (X[:, 0] + X[:, 1] > 0).astype(np.float32)

# 訓練データを少なくして過学習しやすい状況を作る
X_train, y_train = X[:20], y[:20]
X_test, y_test = X[20:], y[20:]

# データ拡張関数
def augment_data(X, y, n_aug=5, noise_std=0.1):
    """ノイズ追加によるデータ拡張"""
    X_aug = [X]
    y_aug = [y]

    for _ in range(n_aug):
        noise = np.random.randn(*X.shape) * noise_std
        X_aug.append(X + noise)
        y_aug.append(y)

    return np.vstack(X_aug), np.concatenate(y_aug)

# 簡単なニューラルネットワーク
class SimpleNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(2, 16)
        self.fc2 = nn.Linear(16, 16)
        self.fc3 = nn.Linear(16, 1)

    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        return torch.sigmoid(self.fc3(x))

def train_and_evaluate(X_train, y_train, X_test, y_test, epochs=200):
    """訓練と評価"""
    model = SimpleNet()
    criterion = nn.BCELoss()
    optimizer = optim.Adam(model.parameters(), lr=0.01)

    X_train_t = torch.FloatTensor(X_train)
    y_train_t = torch.FloatTensor(y_train).unsqueeze(1)
    X_test_t = torch.FloatTensor(X_test)
    y_test_t = torch.FloatTensor(y_test).unsqueeze(1)

    train_losses = []
    test_accs = []

    for epoch in range(epochs):
        model.train()
        optimizer.zero_grad()
        outputs = model(X_train_t)
        loss = criterion(outputs, y_train_t)
        loss.backward()
        optimizer.step()

        train_losses.append(loss.item())

        model.eval()
        with torch.no_grad():
            test_pred = (model(X_test_t) > 0.5).float()
            acc = (test_pred == y_test_t).float().mean().item()
            test_accs.append(acc)

    return train_losses, test_accs

# 拡張なしで学習
losses_no_aug, accs_no_aug = train_and_evaluate(X_train, y_train, X_test, y_test)

# 拡張ありで学習
X_train_aug, y_train_aug = augment_data(X_train, y_train, n_aug=10, noise_std=0.2)
losses_aug, accs_aug = train_and_evaluate(X_train_aug, y_train_aug, X_test, y_test)

# 結果の可視化
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

axes[0].plot(losses_no_aug, label='Without Augmentation')
axes[0].plot(losses_aug, label='With Augmentation')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Training Loss')
axes[0].set_title('Training Loss Comparison')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].plot(accs_no_aug, label='Without Augmentation')
axes[1].plot(accs_aug, label='With Augmentation')
axes[1].set_xlabel('Epoch')
axes[1].set_ylabel('Test Accuracy')
axes[1].set_title('Test Accuracy Comparison')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('augmentation_effect.png', dpi=150, bbox_inches='tight')
plt.show()

print(f"最終テスト精度(拡張なし): {accs_no_aug[-1]:.3f}")
print(f"最終テスト精度(拡張あり): {accs_aug[-1]:.3f}")

まとめ

本記事では、データ拡張(Data Augmentation)について解説しました。

  • 基本概念: ラベルを保存する変換を適用してデータを人工的に増やす手法
  • 理論的背景: 不変性の導入、正則化効果、データ分布の拡大という3つの観点
  • 画像データ: 幾何学的変換、色空間変換、Cutout、Mixup、CutMix
  • テキストデータ: 同義語置換、ランダム削除、Back Translation
  • 時系列データ: ジッタリング、スケーリング、時間ワーピング

データ拡張は実装が容易でありながら、モデルの汎化性能を大きく向上させる強力な手法です。タスクやデータの特性に応じて適切な拡張手法を選択することが重要です。

次のステップとして、以下の記事も参考にしてください。