Разработка признаков может компенсировать нехватку данных.

В быстро развивающемся мире искусственного интеллекта (ИИ) данные стали источником жизненной силы бесчисленных инновационных приложений и решений. Действительно, большие наборы данных часто считаются основой надежных и точных моделей ИИ. Однако что происходит, когда набор данных относительно невелик? В этой статье мы исследуем критическую роль разработки признаков в преодолении ограничений, создаваемых небольшими наборами данных.

Набор данных игрушек

Наше путешествие начинается с создания набора данных. В этом примере мы выполним красивую и простую классификацию сигналов. Набор данных имеет два класса; синусоидальные волны частоты 1 относятся к классу 0, а синусоидальные волны частоты 2 относятся к классу 1. Код для генерации сигнала представлен ниже. Код генерирует синусоиду, применяет аддитивный гауссов шум и рандомизирует фазовый сдвиг. Из-за добавления шума и фазового сдвига мы получаем разнородные сигналы, и задача классификации становится нетривиальной (хотя все еще легкой при правильном проектировании признаков).

def signal0(samples_per_signal, noise_amplitude):
    x = np.linspace(0, 4.0, samples_per_signal)
    y = np.sin(x * np.pi * 0.5)
    n = np.random.randn(samples_per_signal) * noise_amplitude
    
    s = y + n
    
    shift = np.random.randint(low=0, high=int(samples_per_signal / 2))
    s = np.concatenate([s[shift:], s[:shift]])
    
    return np.asarray(s, dtype=np.float32)

def signal1(samples_per_signal, noise_amplitude):
    x = np.linspace(0, 4.0, samples_per_signal)
    y = np.sin(x * np.pi)
    n = np.random.randn(samples_per_signal) * noise_amplitude
    
    s = y + n
    
    shift = np.random.randint(low=0, high=int(samples_per_signal / 2))
    s = np.concatenate([s[shift:], s[:shift]])
    
    return np.asarray(s, dtype=np.float32)

Производительность глубокого обучения

Современные модели обработки сигналов — это сверточные нейронные сети (CNN). Итак, давайте создадим его. Эта конкретная сеть содержит два одномерных сверточных слоя и два полносвязных слоя. Код указан ниже.

class Network(nn.Module):
    
    def __init__(self, signal_size):
        
        c = int(signal_size / 10)
        if c < 3:
            c = 3
        
        super().__init__()
        self.cnn = nn.Sequential(
            nn.Conv1d(1, 8, c),
            nn.ReLU(),
            nn.AvgPool1d(2),
            nn.Conv1d(8, 16, c),
            nn.ReLU(),
            nn.AvgPool1d(2),
            nn.ReLU(),
            nn.Flatten()
        )
        
        l = 0
        with torch.no_grad():
            s = torch.randn((1,1,SAMPLES_PER_SIGNAL))
            o = self.cnn(s)
            l = o.shape[1]
        
        self.head = nn.Sequential(
            nn.Linear(l, 2 * l),
            nn.ReLU(),
            nn.Linear(2 * l, 2),
            nn.ReLU(),
            nn.Softmax(dim=1)
        )
        
    def forward(self, x):
        
        x = self.cnn(x)
        x = self.head(x)
        
        return x

CNN — это модели, которые могут обрабатывать необработанный сигнал. Однако из-за своей архитектуры с большим количеством параметров им, как правило, требуется много данных. Однако для начала предположим, что у нас достаточно данных для обучения нейронных сетей. Я использовал генерацию сигналов для создания набора данных с 200 сигналами. Каждый эксперимент повторялся десять раз, чтобы уменьшить влияние случайных величин. Код показан ниже:

SAMPLES_PER_SIGNAL = 100
SIGNALS_IN_DATASET = 20
NOISE_AMPLITUDE = 0.1
REPEAT_EXPERIMENT = 10

X, Y = [], []

stop = int(SIGNALS_IN_DATASET / 2)
for i in range(SIGNALS_IN_DATASET):

    if i < stop:
        x = signal0(SAMPLES_PER_SIGNAL, NOISE_AMPLITUDE)
        y = 0
    else:
        x = signal1(SAMPLES_PER_SIGNAL, NOISE_AMPLITUDE)
        y = 1

    X.append(x.reshape(1,-1))
    Y.append(y)

X = np.concatenate(X)
Y = np.array(Y, dtype=np.int64)

train_x, test_x, train_y, test_y = train_test_split(X, Y, test_size=0.1)

accs = []
train_accs = []

for i in range(REPEAT_EXPERIMENT):

    net = NeuralNetClassifier(
        lambda: Network(SAMPLES_PER_SIGNAL),
        max_epochs=200,
        criterion=nn.CrossEntropyLoss(),
        lr=0.1,
        callbacks=[
            #('lr_scheduler', LRScheduler(policy=ReduceLROnPlateau, monitor="valid_acc", mode="min", verbose=True)),
            ('lr_scheduler', LRScheduler(policy=CyclicLR, base_lr=0.0001, max_lr=0.01, step_size_up=10)),
        ],
        verbose=False,
        batch_size=128
    )

    net = net.fit(train_x.reshape(train_x.shape[0], 1, SAMPLES_PER_SIGNAL), train_y)
    pred = net.predict(test_x.reshape(test_x.shape[0], 1, SAMPLES_PER_SIGNAL))
    acc = accuracy_score(test_y, pred)
    
    print(f"{i} - {acc}")


    accs.append(acc)
    
    pred_train = net.predict(train_x.reshape(train_x.shape[0], 1, SAMPLES_PER_SIGNAL))
    train_acc = accuracy_score(train_y, pred_train)
    train_accs.append(train_acc)
    
    print(f"Train Acc: {train_acc}, Test Acc: {acc}")
    
accs = np.array(accs)
train_accs = np.array(train_accs)

print(f"Average acc: {accs.mean()}")
print(f"Average train acc: {train_accs.mean()}")
print(f"Average acc where training was successful: {accs[train_accs > 0.6].mean()}")
print(f"Training success rate: {(train_accs > 0.6).mean()}")

CNN получили точность теста 99,2%, чего и следовало ожидать от модели State-of-The-Art. Однако эта метрика была получена для этих прогонов эксперимента, где обучение прошло успешно. Под «успешным» я подразумеваю, что точность обучающего набора данных превысила 60%. В этом примере инициализация весов CNN является решающим фактором для обучения, и иногда это происходит, поскольку CNN представляют собой сложные модели, склонные к проблемам с неудачной рандомизированной инициализацией весов. Успешность обучения составила 70%.

Теперь давайте посмотрим, что происходит, когда набор данных короткий. Я уменьшил количество сигналов в наборе данных до 20. В результате CNN получили точность теста 71,4%, а точность упала на 27,8 процентных пункта. Это неприемлемо. Тем не менее, что теперь делать? Набор данных должен быть длиннее, чтобы использовать современные модели. В промышленных приложениях получение большего количества данных либо невозможно, либо, по крайней мере, очень дорого. Должны ли мы отказаться от проекта и двигаться дальше?

Нет. Когда набор данных небольшой, функции — ваши друзья.

Разработка функций

Этот конкретный пример включает классификацию сигналов на основе их частоты. Итак, мы можем применить старое доброе преобразование Фурье. Преобразование Фурье разлагает сигнал на серию синусоидальных волн, параметризованных по частоте и амплитуде. В результате мы можем использовать преобразование Фурье, чтобы изучить важность каждой частоты в формировании сигнала. Такое представление данных должно упростить задачу настолько, чтобы хватило небольшого набора данных. Кроме того, преобразование Фурье структурирует данные, чтобы мы могли использовать более простые модели, такие как, например, классификатор случайного леса.

Код для преобразования сигнала и обучения Random Forest Classifier показан ниже:

X, Y = [], []

stop = int(SIGNALS_IN_DATASET / 2)
for i in range(SIGNALS_IN_DATASET):

    if i < stop:
        x = signal0(SAMPLES_PER_SIGNAL, NOISE_AMPLITUDE)
        y = 0
    else:
        x = signal1(SAMPLES_PER_SIGNAL, NOISE_AMPLITUDE)
        y = 1

    # Transforming signal into spectrum
    x = np.abs(fft(x[:int(SAMPLES_PER_SIGNAL /2 )]))    
    
    X.append(x.reshape(1,-1))
    Y.append(y)

X = np.concatenate(X)
Y = np.array(Y, dtype=np.int64)

train_x, test_x, train_y, test_y = train_test_split(X, Y, test_size=0.1)

accs = []
train_accs = []

for i in range(REPEAT_EXPERIMENT):
    model = RandomForestClassifier()
    model.fit(train_x, train_y)
    
    pred = model.predict(test_x)
    acc = accuracy_score(test_y, pred)
    
    print(f"{i} - {acc}")


    accs.append(acc)
    
    pred_train = model.predict(train_x)
    train_acc = accuracy_score(train_y, pred_train)
    train_accs.append(train_acc)
    
    print(f"Train Acc: {train_acc}, Test Acc: {acc}")
    
accs = np.array(accs)
train_accs = np.array(train_accs)

print(f"Average acc: {accs.mean()}")
print(f"Average train acc: {train_accs.mean()}")
print(f"Average acc where training was successful: {accs[train_accs > 0.6].mean()}")
print(f"Training success rate: {(train_accs > 0.6).mean()}")

Классификатор Random Forest достиг 100% точности тестирования на наборах данных с 20 и 200 сигналами, а показатель успеха обучения также составляет 100% для каждого набора данных. В результате мы получили даже лучшие результаты, чем CNN, с меньшим объемом требуемых данных — все благодаря разработке признаков.

Риск переобучения

Хотя проектирование признаков — мощный инструмент, необходимо также помнить об удалении ненужных признаков из входных данных. Чем больше признаков во входных векторах, тем выше вероятность переобучения, особенно в небольших наборах данных. Каждая ненужная функция создает риск внесения случайных колебаний, которые модель машинного обучения может учитывать как важные закономерности. Чем меньше данных в наборе данных, тем выше риск случайных колебаний, создающих корреляцию, которой не существует в реальном мире.

Одним из механизмов, который может помочь в сокращении слишком больших наборов признаков, является эвристика поиска, такая как генетический алгоритм. Сокращение признаков может быть выражено как задача найти наименьшее количество признаков, способствующих успешному обучению модели машинного обучения. Его можно закодировать, создав двоичный вектор длины, равной размеру данных признаков. «0» определяет, что функция отсутствует в наборе данных, а «1» указывает, что функция присутствует. Тогда фитнес-функция такого вектора представляет собой сумму точности модели машинного обучения, достигнутой на сокращенном наборе данных, и количества нулей в векторе, уменьшенном на достаточный вес.

Это только одно из многих решений по удалению ненужных функций. Тем не менее, он довольно мощный.

Заключение

Хотя представленный пример относительно прост, он представляет типичные проблемы с применением систем искусственного интеллекта в отрасли. В настоящее время Deep Neural Networks может делать почти все, что мы хотим, при условии предоставления достаточного количества данных. Однако данные обычно скудны и дороги. Таким образом, промышленные приложения искусственного интеллекта обычно включают в себя обширную разработку функций, чтобы упростить проблему и, как следствие, уменьшить объем данных, необходимых для обучения модели.

Спасибо за прочтение. Код для генерации этого примера доступен по ссылке: https://github.com/aimagefrombydgoszcz/Notebooks/blob/main/when_dataset_is_small_features_are_your_friend.ipynb

Все изображения, если не указано иное, принадлежат автору.