Часть II: Реализация

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

Обе части основаны на записной книжке, опубликованной на Kaggle. Отправляйтесь туда, чтобы просмотреть полный код и запустить его.

Предварительные требования: базовые принципы науки о данных, общее понимание распределения вероятностей и некоторый опыт работы с нейронными сетями.

Давайте начнем с краткого изложения основных идей, рассмотренных в Части I. Промышленных применений обнаружения аномалий слишком много, чтобы их перечислять: обнаружение мошенничества в банковской сфере, профилактическое обслуживание в тяжелой промышленности, обнаружение угроз в кибербезопасности и т. д. Во всех этих задачах явное определение выбросов может оказаться очень сложной задачей. Вариационные автоэнкодеры (VAE) автоматически изучают общую структуру обучающих данных, чтобы изолировать только их отличительные признаки, которые суммируются в компактном скрытом векторе. Скрытый вектор представляет собой информационное узкое место, которое заставляет модель быть очень избирательной в отношении того, что кодировать. Мы обучаем кодировщик создавать скрытый вектор, а декодер — максимально точно восстанавливать исходные данные из скрытого вектора. При представлении образца вне распределения (выброса) система не сможет выполнить точную реконструкцию. Обнаружив неточные реконструкции, мы можем сказать, какие примеры являются выбросами. Пожалуйста, обратитесь к Части I для более глубокого и интуитивно понятного обсуждения и к [1] ​​для получения дополнительной информации о VAE.

Во второй части мы применим эти принципы для создания машины обнаружения аномалий на основе VAE. Я не буду подробно описывать весь код, но приведу наиболее важные части. Остальное и поиграть с ним можно здесь.

Обзор

Для этого проекта мы будем использовать один из общедоступных наборов данных, доступных на Kaggle: https://www.kaggle.com/boltzmannbrain/nab. Он содержит множество наборов данных для обнаружения аномалий. В этом примере мы используем machine_temperature_system_failure.csv, одномерный временной ряд, отслеживающий внутреннюю температуру некоторой крупной промышленной машины, о которой мы ничего не знаем. Мы разработаем функции и сделаем данные многомерными, чтобы сделать их более интересными и актуальными.

Мы выполним следующие шаги:

  • Сбор и предварительная обработка данных, включая разделение обучения/тестирования,
  • Создайте VAE и обучите его на тренировочном наборе,
  • Передайте тестовые образцы в VAE и запишите потери реконструкции для каждого,
  • Определите тестовые образцы с высокими потерями реконструкции и отметьте их как аномалии.

Предварительная обработка данных и разработка функций

Весь временной ряд показан ниже, аномалии обведены красным:

В статье, в которой представлен этот временной ряд [2], объясняется:

Первой аномалией было запланированное отключение. Третьей аномалией был катастрофический сбой системы. Вторая аномалия, тонкое, но заметное изменение в поведении, указывало на возникновение проблемы, которая в конечном итоге привела к сбою системы.

Данные выглядят так:

У нас не так много информации о том, с какой машиной или отраслью мы имеем дело, что является проблемой при разработке полезных функций. Поэтому нам придется сделать предположения.

Временные метки охватывают рождественские и новогодние праздники. Поскольку мы имеем дело с промышленной машиной, само собой разумеется, что на ее загруженность могут влиять праздники и, возможно, даже близость (по времени) к празднику. Без дополнительной информации мы будем считать, что соответствующие праздники являются типичными для Европы и Америки, т. е. Рождество и Новый год. Точно так же нам может понадобиться знать день недели или час дня и предположить, что выходные — это суббота и воскресенье. После некоторых манипуляций получаем следующий фрейм данных:

Затем мы разделяем данные на обучающий и тестовый наборы. Поскольку мы имеем дело с временным рядом, мы берем последние 30% наблюдений (в хронологическом порядке) в качестве нашего тестового набора. Тестовый период составляет около 6 800 временных меток, из которых около 15 900 остается для обучения. Мы можем перейти к кодированию категориальных переменных.

У нас есть четыре категориальные переменные: day, month, day_week, holiday. Нам нужно убедиться, что нет значений категориальных переменных, которые присутствуют в тестовом наборе, а не в обучающем наборе. Но, поскольку мы разделяем данные в хронологическом порядке, это, скорее всего, произойдет с переменными day и month. Мы могли бы попытаться решить эту проблему с помощью дальнейшей разработки функций, но здесь мы просто проигнорируем эти две функции при вызове данных во время обучения. Мы кодируем day_week от 0 до 6, а holiday как 0 или 1. Мы передаем эти категориальные переменные как вложения, которые мы подробно рассмотрим позже.

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

Пользовательский класс набора данных PyTorch будет обслуживать данные. Мы используем категориальные признаки day_of_week, holiday и непрерывные признаки gap_holiday, t, value. Как упоминалось ранее, мы отбрасываем day, month при передаче данных в модель.

Модель VAE

Теперь переходим к самой интересной части: самой модели. Он состоит из двух частей: кодера и декодера. С точки зрения архитектуры мы определяем ее как зеркальное отражение друг друга для простоты. Ядром каждой нейронной сети является последовательность полносвязных слоев, количество и размеры которых задаются гиперпараметром layer_dims (список целых чисел). После некоторых экспериментов я остановился на 64,128,256,128,64, то есть на пяти слоях. Каждый слой может быть нормализован в пакетном режиме. Первый уровень кодировщика получил конкатенацию непрерывных переменных и векторов внедрения, кодирующих категориальные переменные.

Векторы внедрения широко используются в обработке естественного языка и, в более общем смысле, при работе с дискретными данными. Это обучаемые векторы (размерность 16 в наших экспериментах), которые выражают некоторые неявные свойства переменных. Например, вектор day_of_week может принимать семь значений (по одному на каждый день), каждое из которых 16-мерное. В зависимости от значения этой переменной для каждой выборки соответствующий вектор извлекается в таблице поиска, передается в сеть и обновляется во время обратного распространения. После обучения вектор для day_of_week==0 может, например, отразить тот факт, что активность машины ниже по воскресеньям. Важно то, что это обучение выполняется без нашего вмешательства, и нет простого способа интерпретировать изученные вложения.

Давайте сначала определим слой как последовательность {полностью подключенный блок, пакетная нормализация, активация дырявого ReLU}:

class Layer(nn.Module):
    '''
    A single fully connected layer with optional batch 
    normalisation and activation.
    '''
    def __init__(self, in_dim, out_dim, bn = True):
        super().__init__()
        layers = [nn.Linear(in_dim, out_dim)]
        if bn: layers.append(nn.BatchNorm1d(out_dim))
        layers.append(nn.LeakyReLU(0.1, inplace=True))
        self.block = nn.Sequential(*layers)
    
    def forward(self, x):
        return self.block(x)

Мы можем использовать этот объект для создания кодировщика:

class Encoder(nn.Module):
    '''
    The encoder part of our VAE. Takes a data sample and returns the
    mean and the log-variance of the vector's distribution.
    '''
def __init__(self, **hparams):
        super().__init__()
        self.hparams = Namespace(**hparams)
        self.embeds = nn.ModuleList(
            [
                nn.Embedding(
                    n_cats, emb_size) for (n_cats, emb_size) \
                        in self.hparams.embedding_sizes
                )
            ]
        )
# The input to the first layer is the concatenation 
        # of all embedding vectors and continuous values
        in_dim = sum(emb.embedding_dim for emb in self.embeds) \
            + len(self.hparams.cont_vars)
        layer_dims = [in_dim] \
            + [int(s) for s in self.hparams.layer_sizes.split(',')]
        bn = self.hparams.batch_norm
        self.layers = nn.Sequential(
            *[Layer(layer_dims[i], layer_dims[i + 1], bn) \
                for i in range(len(layer_dims) - 1)],
        )
        self.mu = nn.Linear(layer_dims[-1], self.hparams.latent_dim)
        self.logvar = nn.Linear(
            layer_dims[-1], 
            self.hparams.latent_dim,
        )
    
    def forward(self, x_cont, x_cat):
        x_embed = [
            e(x_cat[:, i]) for i, e in enumerate(self.embeds)
        ]        
        x_embed = torch.cat(x_embed, dim=1)
        x = torch.cat((x_embed, x_cont), dim=1)
        h = self.layers(x)
        mu_ = self.mu(h)
        logvar_ = self.logvar(h)
        
        # we return the concatenated input vector for use in loss 
        return mu_, logvar_, x

… и декодер:

class Decoder(nn.Module):
    '''
    The decoder part of our VAE. Takes a latent vector (sampled from
    the distribution learned by the encoder) and converts it back 
    to a reconstructed data sample.
    '''
def __init__(self, **hparams):
        super().__init__()
        self.hparams = Namespace(**hparams)
        hidden_dims = [self.hparams.latent_dim] \
            + [
                   int(s) for s in \
                       reversed(self.hparams.layer_sizes.split(','))
        ]
        out_dim = sum(
            emb_size for _, emb_size in self.hparams.embedding_sizes
        ) + len(self.hparams.cont_vars) 
        bn = self.hparams.batch_norm
        self.layers = nn.Sequential(
            *[Layer(hidden_dims[i], hidden_dims[i + 1], bn) \
                for i in range(len(hidden_dims) - 1)],
        )
        self.reconstructed = nn.Linear(hidden_dims[-1], out_dim)
        
    def forward(self, z):
        h = self.layers(z)
        return self.reconstructed(h)

Чтобы упростить написание и чтение кода, я определяю VAE как Pytorch-Lightning LightningModule. Pytorch-Lightning — это удобный слой поверх Pytorch, который устраняет необходимость в большей части шаблонного кода, связанного с моделями обучения, и обеспечивает большую гибкость. Я настоятельно рекомендую вам взглянуть, если вы этого не знаете.

class VAE(pl.LightningModule):
    def __init__(self, **hparams):
        super().__init__()
        self.save_hyperparameters()
        self.encoder = Encoder(**hparams)
        self.decoder = Decoder(**hparams)
        
    def reparameterize(self, mu, logvar):
        '''
        The reparameterisation trick allows us to backpropagate
        through the encoder.
        '''
        if self.training:
            std = torch.exp(logvar / 2.)
            eps = torch.randn_like(std) * self.hparams.stdev
            return eps * std + mu
        else:
            return mu
        
    def forward(self, batch):
        x_cont, x_cat = batch
        assert x_cat.dtype == torch.int64
        mu, logvar, x = self.encoder(x_cont, x_cat)
        z = self.reparameterize(mu, logvar)
        recon = self.decoder(z)
        return recon, mu, logvar, x
        
    def loss_function(self, obs, recon, mu, logvar):
        recon_loss = F.smooth_l1_loss(recon, obs, reduction='mean')
        kld = -0.5 * torch.mean(1 + logvar - mu ** 2 - logvar.exp())
        return recon_loss, kld
                               
    def training_step(self, batch, batch_idx): 
        ''' 
        Executed with each batch of data during training
        '''
        recon, mu, logvar, x = self.forward(batch)
        
        # The loss function compares the concatenated input vector
        # including embeddings to the reconstructed vector
        recon_loss, kld = self.loss_function(x, recon, mu, logvar)
        loss = recon_loss + self.hparams.kld_beta * kld
        # We log some values to monitor the training process
        self.log(
            'total_loss', loss.mean(dim=0), 
            on_step=True, prog_bar=True, logger=True,
        )
        self.log(
            'recon_loss', recon_loss.mean(dim=0), 
            on_step=True, prog_bar=True, logger=True,
        )
        self.log(
            'kld', kld.mean(dim=0), 
            on_step=True, prog_bar=True, logger=True,
        )
        return loss
    
    def test_step(self, batch, batch_idx):       
        ''' 
        Executed with each batch of data during testing
        '''
        recon, mu, logvar, x = self.forward(batch)
        recon_loss, kld = self.loss_function(x, recon, mu, logvar)
        loss = recon_loss + self.hparams.kld_beta * kld
        self.log('test_loss', loss)
        return loss
        
    def configure_optimizers(self):
        # Define the Adam optimiser:
        opt = torch.optim.AdamW(
            self.parameters(), lr=self.hparams.lr,
            weight_decay=self.hparams.weight_decay, eps=1e-4,
        )
        # Set up a cosine annealing schedule for the learning rate
        sch = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(
            opt, T_0=25, T_mult=1, eta_min=1e-9, last_epoch=-1,
        )
        return [opt], [sch]
# The next two methods create the training and test data loaders 
    # based on the custom Dataset class.
    def train_dataloader(self):
        dataset = TSDataset(
            'train', cont_vars=self.hparams.cont_vars, 
            cat_vars = self.hparams.cat_vars, lbl_as_feat=True,
        )
        return DataLoader(
            dataset, batch_size=self.hparams.batch_size,
            num_workers=2, pin_memory=True, shuffle=True,
        )
    
    def test_dataloader(self):
        dataset = TSDataset(
            'test', cont_vars=self.hparams.cont_vars,
            cat_vars=self.hparams.cat_vars, lbl_as_feat=True,
        )
        return DataLoader(
            dataset, batch_size=self.hparams.batch_size, 
            num_workers=2, pin_memory=True, 
        )

Несколько слов о методе reparameterize(): как упоминалось в части I, во время обучения мы многократно отбираем образцы из промежуточного распределения, чтобы изучить распределение, а не отдельные точки. Проблема в том, что операция выборки не дифференцируема, поэтому мы не можем выполнить обратное распространение для обучения сети. Вместо этого мы используем «трюк репараметризации», который стал возможен благодаря существенному свойству нормального распределения:

На английском языке это означает, что выборка из нормального распределения дисперсии 𝜎² с центром вокруг среднего 𝜇 строго эквивалентна выборке из стандартного нормального распределения (среднее 0 и дисперсия 1), умножению ее на 𝜎 и добавлению 𝜇.

Таким образом, мы сэмплируем независимый шум ϵ, который нам не нужно распространять обратно, и распространять обратно через детерминированный процесс, производящий 𝜇 и 𝜎² [на самом деле, log(𝜎²), но это незначительная техническая деталь]. Опять же, прочитайте [1] для дальнейшего понимания.

Нам нужно установить некоторые гиперпараметры. stdev (стандартное отклонение нормального распределения, из которого мы выбираем 𝜖 в методе reparameterize()) обычно равно 1. Однако в нескольких статьях были обнаружены улучшения с меньшими значениями, такими как 0,1, которое мы будем использовать. Еще один гиперпараметр, о котором стоит упомянуть, — это kld_beta, коэффициент, применяемый к термину потерь KLD при расчете общих потерь. Этот фактор помогает, потому что потерю реконструкции обычно сложнее исправить, чем KLD; поэтому, если бы оба были взвешены одинаково, модель начала бы с оптимизации потерь KLD, прежде чем существенно улучшить потери реконструкции. Установив для этого коэффициента значение ‹ 1, мы пытаемся обеспечить, чтобы оба значения потерь улучшались с одинаковой скоростью. Ниже представлена ​​вся настройка гиперпараметра, которую модель получает в виде словаря:

hparams = OrderedDict(
    run='vae_anomaly_v1',
    cont_vars = ['value', 'hour_min', 'gap_holiday', 't'], 
    # Remember to remove 'day' and 'month':
    cat_vars = ['day_of_week', 'holiday'],
    embedding_sizes = [
        (embed_cats[i], 16) for i in range(len(embed_cats))
    ],
    latent_dim = 16,
    layer_sizes = '64,128,256,128,64',
    batch_norm = True,
    stdev = 0.1,
    kld_beta = 0.05,
    lr = 0.001,
    weight_decay = 1e-5,
    batch_size = 128,
    epochs = 60,
)

Обучение модели

С Pytorch-Lightning реализовать ведение журнала, контрольные точки и обучение очень просто:

model = VAE(**hparams)
logger = WandbLogger(
    name=hparams['run'], project='VAE_Anomaly', 
    version=hparams['run'], save_dir='kaggle/working/checkpoints'
)
ckpt_callback = pl.callbacks.ModelCheckpoint(
    dirpath='.', filename='vae_weights'
)
trainer = pl.Trainer(
    accelerator='gpu', devices=1, logger=logger,
    max_epochs=hparams['epochs'], auto_lr_find=False,
    callbacks=[ckpt_callback], gradient_clip_val=10.,
    enable_model_summary=True,
)
trainer.fit(model)

Мы готовы установить нашу модель! Обучение на графических процессорах Kaggle P100 проходит довольно быстро. Общие потери почти равны нулю:

Значение потерь на тестовом наборе выше, но все же относительно низкое.

Оценка на тестовом периоде

Передавая экземпляры тестового периода в модель, мы получаем отдельные значения потерь, которые мы можем добавить к данным:

На этом этапе нам нужно принять еще одно решение: как установить порог приемлемости того, что представляет собой аномалию? Здесь мы устанавливаем его на основе его квантиля в распределении потерь. Мы выбираем очень высокий квантиль, чтобы убедиться, что мы получаем только хвост этого распределения, в данном случае 0,999 (что соответствует значению потери 0,1563).

Затем мы можем пометить любое значение потерь выше этого порога как аномалию и построить график распределения потерь:

Что это означает с точки зрения распределения значений температуры? После обращения преобразований, примененных к данным, чтобы вернуть каждую функцию в диапазоны значений исходного набора данных, мы можем построить распределение температуры:

На этом рисунке сложно сказать, но экземпляры, помеченные как аномальные, не находятся в нижней части температурного спектра, как это было бы, если бы мы использовали более наивный подход, основанный только на распределении температуры. Давайте посмотрим на эти отмеченные примеры во временном ряду:

С порогом 99,9 процентиля наша модель правильно обнаруживает аномалию, присутствующую в тестовом периоде, хотя и с некоторым опозданием, и не генерирует ложных срабатываний. Определенный нами порог может быть скорректирован в пользу точности (уменьшения количества ложных срабатываний) или отзыва (уменьшения количества ложноотрицательных результатов) в зависимости от бизнес-задачи.

Несомненно, эти результаты можно было бы улучшить с помощью более сложной разработки функций и оптимизации гиперпараметров. Цель этой статьи состояла в том, чтобы проиллюстрировать некоторые ценные концепции, и я надеюсь, что она была достигнута!

Заключение

Задача VAE состоит в том, чтобы свести данные к их отличительным признакам, что позволяет нам обнаруживать точки данных, которые не соответствуют этой изученной структуре, поскольку они приводят к худшему восстановлению. Прелесть использования VAE для этой задачи заключается в том, что нам не нужно явно указывать, как такие примеры будут отклоняться от нормы. В результате мы можем получить модель обнаружения аномалий с более тонким определением выброса.

В части I я дал вам интуитивное представление о VAE и о том, как они могут решить проблему обнаружения аномалий. В этой статье мы увидели конкретную реализацию и то, как ее использовать для обнаружения новых аномалий. Я надеюсь, вы лучше понимаете, как эти идеи воплощаются в эффективную модель обучения.

PS: Если статья показалась вам интересной, спамьте значком хлопка и поделитесь ссылкой!

[1] Doersch, C., 2016. Учебное пособие по вариационным автоэнкодерам. Препринт arXiv arXiv:1606.05908.
[2] Ахмад С., Лавин А., Парди С. и Ага З., 2017 г. Неконтролируемое обнаружение аномалий в реальном времени для потоковых данных. Нейрокомпьютинг, 262, стр. 134–147.