Многие люди, когда либо имевшие дело с нейронными сетями, наверняка задумывались, можно ли написать нейросеть, которая сама будет создавать нейросети для решения каких-либо задач. Так вот в этом цикле статей я решил реализовать это. Одним из этапов алгоритма будет генерирование нейросети из списка слоёв. В связи с некоторыми ограничениями, накладываемыми методами реализации (о которых будет сказано в следующих частях, когда мы начнём объединять код из этой статьи с RL ʕ⊙ᴥ⊙ʔ ), входные данные для генератора будут представлены в виде строки случайной длины, содержащей упорядоченный набор слоёв с их параметрами. Генерировать сеть будем для задачи классификации картинок (разобьём это пугало первым).

Функция создания описания последовательности слоёв

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

layers_set = {
                # increase channels with kernel 3
                'conv=channel_factor:2,kernel_size:3,stride:1,padding:0-',
                'conv=channel_factor:3,kernel_size:3,stride:1,padding:0-',
                'conv=channel_factor:4,kernel_size:3,stride:1,padding:0-',

                # decrease channels with kernel 3
                'conv=channel_factor:0.4,kernel_size:3,stride:1,padding:0-',
                'conv=channel_factor:0.6,kernel_size:3,stride:1,padding:0-',
                'conv=channel_factor:0.8,kernel_size:3,stride:1,padding:0-',


                 'batchnorm=eps:0.00001-',
                 'batchnorm=eps:0.0001-',

                 'avgpool=kernel_size:2,stride:2,padding:0-',
                 'avgpool=kernel_size:4,stride:4,padding:0-',

                 'maxpool=kernel_size:2,stride:2,padding:0-',
                 'maxpool=kernel_size:4,stride:4,padding:0-',

                 'dropout=p:0.2-',
                 'dropout=p:0.4-',
  }

channel_factor - множитель кол-ва каналов.
"-" - разделитель слоёв
"," - разделитель параметров слоя
":" - разделитель названия параметра и его значения
"=" - разделитель названия слоя от параметров

Реализуем функцию, создающую строку слоёв с длиной min_len <= L <= max_len слоёв.

def create_layers_string(min_len=2, max_len=10):
    if min_len < 1 or max_len < min_len:
      print('Parameters are incorrect!')
      return None
    length = random.randint(min_len, max_len)
    text = random.sample(layers_set, length)
    text = ''.join((layer for layer in text))
    return text

Класс нейросети

В качестве класса нейросети возьмём простой пустой шаблон, поле "layers" которого будет содержать нашу нейросетевую архитектуру, когда мы её сгенерируем.

class NN(nn.Module):

    def __init__(self, in_channels):
      super().__init__()

      self.layers = nn.Sequential()

    def forward(self, x):
      x = self.layers(x)
      return x

    def __call__(self, x):
      return self.forward(x)

Замечу, что классификатора в виде линейного слоя тут нет. Он будет добавлен в конец layers после вычисления входных параметров для него.

Класс nnGenerator

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

class nnGenerator():
    def __init__(self):
      self.text_layers_dict = dict({})
      self.nn_len = -1

Класс содержит словарь, хранящий результат парсинга строки слоёв.
Структура словаря следующая: 1 элемент его состоит из название слоя, сконкатенированное с его id + пара: id + словарь параметров. Пример одного элемента словаря text_layers_dict:
"dropout1": (1, {"p": 0.2} )

    def parseTextNet(self, text_net):
      self.text_layers_dict = dict({})
      self.nn_len = -1
      print(text_net)
      if text_net[-1] == '-':
        text_net = text_net[:-1]
      text_layers = text_net.split('-')
      id = 0
      for text_layer in text_layers:
        tmp = text_layer.split('=')
        layer_name, layer_params = tmp[0], tmp[1].split(',')
        layer_params_dict = dict({})
        for param in layer_params:
          param = param.split(':')
          param_name, param_value = param[0], param[1]
          layer_params_dict[param_name] = param_value
        self.text_layers_dict[layer_name + str(id)] = (id, layer_params_dict)
        id += 1
      print(self.text_layers_dict)

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

    def conv_output_shape(self, h, w, kernel_size=1, stride=1, pad=0, dilation=1):
      h = math.floor( ((h + (2 * pad) - ( dilation * (kernel_size - 1) ) - 1 )/ stride) + 1)
      w = math.floor( ((w + (2 * pad) - ( dilation * (kernel_size - 1) ) - 1 )/ stride) + 1)
      return h, w

Метод conv_output_shape реализует вычисление выхода нейросетевого слоя.
Для свёрток и пуллингов эта функция универсальна, dropout и batchnorm не меняют размерностей, а значит в вычислении выхода слоя не нуждаются. Формула вычисления этих значений представлена в документации библиотеки PyTorch.

квадратные скобки - округление вниз (math.floor)
квадратные скобки - округление вниз (math.floor)

Далее пойдёт самое интересное - автопостроение нашей сети по словарю слоёв.

    def generateNN(self, n_classes, test_batch):
      success_state = False

      backbone = nn.Sequential()
      classifier = nn.Sequential()
      optimizer = None
      try:
        data_shape = np.array(test_batch).shape # [B, C, H, W]
        last_shape = data_shape
        for layer_name in self.text_layers_dict.keys():
            layer = None
            layer_params= self.text_layers_dict[layer_name][1]
            if layer_name.find('conv') >= 0:
              kernel_size = int(layer_params['kernel_size'])
              channel_factor = float(layer_params['channel_factor'])
              stride = int(layer_params['stride'])
              padding = int(layer_params['padding'])
              dilation = 1

              activation = nn.ReLU(inplace=True)

              in_chan = last_shape[1]
              assert(in_chan <= last_shape[2] and in_chan <= last_shape[3])
              out_chan = math.floor(in_chan * channel_factor)
              assert(out_chan > 0)

              backbone.append(nn.Conv2d(in_chan, out_chan, kernel_size, stride, padding, dilation))
              backbone.append(activation)

              h, w = self.conv_output_shape(last_shape[2], last_shape[3], kernel_size, stride, padding, dilation)
              last_shape = (last_shape[0], out_chan, h, w)

            elif layer_name.find('batchnorm') >= 0:
              eps = float(layer_params['eps'])
              in_chan = last_shape[1]
              backbone.append(nn.BatchNorm2d(in_chan, eps))

            elif layer_name.find('avgpool') >= 0:
              kernel_size = int(layer_params['kernel_size'])
              stride = int(layer_params['stride'])
              padding = int(layer_params['padding'])
              dilation = 1
              out_chan = last_shape[1]
              backbone.append(nn.AvgPool2d(kernel_size, stride, padding))

              h, w = self.conv_output_shape(last_shape[2], last_shape[3], kernel_size, stride, padding, dilation)
              last_shape = (last_shape[0], out_chan, h, w)


            elif layer_name.find('maxpool') >= 0:
              kernel_size = int(layer_params['kernel_size'])
              stride = int(layer_params['stride'])
              padding = int(layer_params['padding'])
              dilation = 1

              backbone.append(nn.MaxPool2d(kernel_size, stride, padding))

              h, w = self.conv_output_shape(last_shape[2], last_shape[3], kernel_size, stride, padding, dilation)
              last_shape = (last_shape[0], last_shape[1], h, w)

            elif layer_name.find('dropout') >= 0:
              p = float(layer_params['p'])
              backbone.append(nn.Dropout2d(p))

        linear_in_shape = last_shape[1] * last_shape[2] * last_shape[3]
        classifier = nn.Linear(linear_in_shape, n_classes)
        success_state = True
        print('NN build successfull!')
        
      except Exception as e:
        print('NN build failed!')
        print(str(e))

      net = nn.Sequential()
      net.append(backbone)
      net.append(nn.Flatten(start_dim=1))
      net.append(classifier)
      self.text_layers_dict = dict({})
      return success_state, net

Псевдокодом можно описать алгоритм так:
Проходим по всем слоям из словаря:
1) Берём параметры слоя
2) Вычисляем выходной shape
3) Создаём слой, добавляем в конец архитектуры
4) Сохраняем значение выходного размера последнего слоя
В конце добавляем линейный классификатор.
Для вычисления входного размера данных подаётся тестовый батч, на ваше усмотрение можно переделать в более удобный вид.

Особые моменты, которые необходимо отлавливать, это случай, при котором в ходе построения сети ядро становится больше размера данных, и при котором кол-во каналов уменьшается до значения < 1.

Далее код создания dataloader'ов под MNIST, а также обучающего цикла, думаю, в комментариях это не нуждается, отмечу только, что входные данные должны быть (B, C, H, W) = (Batch_Size, Channels, height, width), значит данные из МНИСТа с shape'ом (N, 28,28) нужно представить в виде (N, 1, 28, 28)


class ds(Dataset):
    def __init__(self, X, y):

      self.X = X
      self.y = y

    def __len__(self):
        return len(self.y)

    def __getitem__(self, idx):
        x_ = self.X[idx]
        y_ = self.y[idx]
        return x_, y_

train_data = dsets.MNIST(root = './data', train = True,
                        transform = transforms.ToTensor(), download = True)

test_data = dsets.MNIST(root = './data', train = False,
                       transform = transforms.ToTensor())


train_samples = np.expand_dims(np.array(train_data.data), axis=1)[:5000]
train_labels = np.array(train_data.targets)[:5000]


dataset = ds(X=train_samples, y=train_labels)

train_set, valid_set = random_split(dataset, [0.8, 0.2], generator=torch.Generator().manual_seed(42))

train_dataloader = torch.utils.data.DataLoader(
  train_set,
  batch_size=bs,
  shuffle=True,
  drop_last=True)

valid_dataloader = torch.utils.data.DataLoader(
  valid_set,
  batch_size=bs,
  drop_last=True,
  shuffle=True)

test_batch = None
for b, _ in train_dataloader:
  test_batch = b
  break

Тесты

Генерируем последовательность слоёв

test_batch = None
for b, _ in train_dataloader:
  test_batch = b
  break

generator = nnGenerator()

success_state = False
while not success_state:
  text_layers = create_layers_string()
  generator.parseTextNet(text_layers)
  success_state, seq = generator.generateNN(n_classes=10, test_batch=test_batch)

Результатом будет текст следующего содержания:

conv=channel_factor:0.4,kernel_size:3,stride:1,padding:0-batchnorm=eps:0.00001-conv=channel_factor:4,kernel_size:3,stride:1,padding:0-conv=channel_factor:0.8,kernel_size:3,stride:1,padding:0-maxpool=kernel_size:4,stride:4,padding:0-
{'conv0': (0, {'channel_factor': '0.4', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'batchnorm1': (1, {'eps': '0.00001'}), 'conv2': (2, {'channel_factor': '4', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'conv3': (3, {'channel_factor': '0.8', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'maxpool4': (4, {'kernel_size': '4', 'stride': '4', 'padding': '0'})}
NN build failed!

batchnorm=eps:0.00001-conv=channel_factor:3,kernel_size:3,stride:1,padding:0-avgpool=kernel_size:4,stride:4,padding:0-conv=channel_factor:0.6,kernel_size:3,stride:1,padding:0-maxpool=kernel_size:4,stride:4,padding:0-dropout=p:0.2-conv=channel_factor:0.8,kernel_size:3,stride:1,padding:0-dropout=p:0.4-
{'batchnorm0': (0, {'eps': '0.00001'}), 'conv1': (1, {'channel_factor': '3', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'avgpool2': (2, {'kernel_size': '4', 'stride': '4', 'padding': '0'}), 'conv3': (3, {'channel_factor': '0.6', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'maxpool4': (4, {'kernel_size': '4', 'stride': '4', 'padding': '0'}), 'dropout5': (5, {'p': '0.2'}), 'conv6': (6, {'channel_factor': '0.8', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'dropout7': (7, {'p': '0.4'})}
NN build failed!

avgpool=kernel_size:4,stride:4,padding:0-conv=channel_factor:0.6,kernel_size:3,stride:1,padding:0-dropout=p:0.4-conv=channel_factor:0.8,kernel_size:3,stride:1,padding:0-
{'avgpool0': (0, {'kernel_size': '4', 'stride': '4', 'padding': '0'}), 'conv1': (1, {'channel_factor': '0.6', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'dropout2': (2, {'p': '0.4'}), 'conv3': (3, {'channel_factor': '0.8', 'kernel_size': '3', 'stride': '1', 'padding': '0'})}
NN build failed!

conv=channel_factor:0.6,kernel_size:3,stride:1,padding:0-avgpool=kernel_size:2,stride:2,padding:0-conv=channel_factor:0.8,kernel_size:3,stride:1,padding:0-conv=channel_factor:0.4,kernel_size:3,stride:1,padding:0-conv=channel_factor:4,kernel_size:3,stride:1,padding:0-conv=channel_factor:3,kernel_size:3,stride:1,padding:0-maxpool=kernel_size:4,stride:4,padding:0-batchnorm=eps:0.00001-maxpool=kernel_size:2,stride:2,padding:0-batchnorm=eps:0.0001-
{'conv0': (0, {'channel_factor': '0.6', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'avgpool1': (1, {'kernel_size': '2', 'stride': '2', 'padding': '0'}), 'conv2': (2, {'channel_factor': '0.8', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'conv3': (3, {'channel_factor': '0.4', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'conv4': (4, {'channel_factor': '4', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'conv5': (5, {'channel_factor': '3', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'maxpool6': (6, {'kernel_size': '4', 'stride': '4', 'padding': '0'}), 'batchnorm7': (7, {'eps': '0.00001'}), 'maxpool8': (8, {'kernel_size': '2', 'stride': '2', 'padding': '0'}), 'batchnorm9': (9, {'eps': '0.0001'})}
NN build failed!

conv=channel_factor:0.6,kernel_size:3,stride:1,padding:0-dropout=p:0.2-batchnorm=eps:0.00001-maxpool=kernel_size:4,stride:4,padding:0-avgpool=kernel_size:2,stride:2,padding:0-conv=channel_factor:0.4,kernel_size:3,stride:1,padding:0-conv=channel_factor:4,kernel_size:3,stride:1,padding:0-avgpool=kernel_size:4,stride:4,padding:0-conv=channel_factor:0.8,kernel_size:3,stride:1,padding:0-batchnorm=eps:0.0001-
{'conv0': (0, {'channel_factor': '0.6', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'dropout1': (1, {'p': '0.2'}), 'batchnorm2': (2, {'eps': '0.00001'}), 'maxpool3': (3, {'kernel_size': '4', 'stride': '4', 'padding': '0'}), 'avgpool4': (4, {'kernel_size': '2', 'stride': '2', 'padding': '0'}), 'conv5': (5, {'channel_factor': '0.4', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'conv6': (6, {'channel_factor': '4', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'avgpool7': (7, {'kernel_size': '4', 'stride': '4', 'padding': '0'}), 'conv8': (8, {'channel_factor': '0.8', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'batchnorm9': (9, {'eps': '0.0001'})}
NN build failed!

batchnorm=eps:0.00001-dropout=p:0.4-batchnorm=eps:0.0001-conv=channel_factor:0.8,kernel_size:3,stride:1,padding:0-maxpool=kernel_size:4,stride:4,padding:0-conv=channel_factor:3,kernel_size:3,stride:1,padding:0-
{'batchnorm0': (0, {'eps': '0.00001'}), 'dropout1': (1, {'p': '0.4'}), 'batchnorm2': (2, {'eps': '0.0001'}), 'conv3': (3, {'channel_factor': '0.8', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'maxpool4': (4, {'kernel_size': '4', 'stride': '4', 'padding': '0'}), 'conv5': (5, {'channel_factor': '3', 'kernel_size': '3', 'stride': '1', 'padding': '0'})}
NN build failed!

dropout=p:0.2-conv=channel_factor:3,kernel_size:3,stride:1,padding:0-dropout=p:0.4-
{'dropout0': (0, {'p': '0.2'}), 'conv1': (1, {'channel_factor': '3', 'kernel_size': '3', 'stride': '1', 'padding': '0'}), 'dropout2': (2, {'p': '0.4'})}
NN build successfull!
<ipython-input-20-4e57f99010fc>:6: DeprecationWarning: Sampling from a set deprecated
since Python 3.9 and will be removed in a subsequent version.
  text = random.sample(layers_set, length)

С седьмой попытки получилось (;一_一)

Создаём объект пустой сети, генерируем сеть

model = NN(in_channels=1)
model.layers = seq
display(model)

Получаем какую-то нашу случайную сеть

NN(
  (layers): Sequential(
    (0): Sequential(
      (0): Dropout2d(p=0.2, inplace=False)
      (1): Conv2d(1, 3, kernel_size=(3, 3), stride=(1, 1))
      (2): ReLU(inplace=True)
      (3): Dropout2d(p=0.4, inplace=False)
    )
    (1): Flatten(start_dim=1, end_dim=-1)
    (2): Linear(in_features=2028, out_features=10, bias=True)
  )
)

Далее оптимайзер + ф-я ошибки

criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=lr)

Тренировочный цикл самый простой

def train():
    train_losses = []
    valid_losses = []
    # TODO calculate metrics and return them after train
    def CalcValLoss():
        with torch.no_grad():
            losses = []
            for X, Y in valid_dataloader:
                X = X.float().to(device)
                Y = Y.float().to(device)
                preds = model(X)
                preds, _ = torch.max(preds,1)
                loss = criterion(preds,Y)
                losses.append(loss.item())
            print("Valid Loss : {:.6f}".format(torch.tensor(losses).mean()))
            valid_losses.append(torch.tensor(losses).mean())

    for i in range(1, epochs):
        losses = []
        for X, Y in tqdm(train_dataloader):
            X = X.float().to(device)
            Y = Y.float().to(device)
            preds = model(X)
            preds, _ = torch.max(preds,1)
            loss = criterion(preds, Y)
            losses.append(loss.item())

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

        print("Train Loss : {:.6f}".format(torch.tensor(losses).mean()))
        train_losses.append(torch.tensor(losses).mean())
    return train_losses, valid_losses

Запускаем, обучаем, фиксируем прибыль

train_losses, valid_losses = train()

100%|██████████| 125/125 [00:00<00:00, 213.39it/s]
Train Loss : 8579.939453
100%|██████████| 125/125 [00:00<00:00, 219.21it/s]
Train Loss : 493.860870
100%|██████████| 125/125 [00:00<00:00, 224.44it/s]
Train Loss : 493.228210
100%|██████████| 125/125 [00:00<00:00, 347.04it/s]
Train Loss : 493.066498
100%|██████████| 125/125 [00:00<00:00, 359.57it/s]
Train Loss : 492.938690
100%|██████████| 125/125 [00:00<00:00, 340.28it/s]
Train Loss : 492.947876
100%|██████████| 125/125 [00:00<00:00, 339.37it/s]
Train Loss : 492.833740
100%|██████████| 125/125 [00:00<00:00, 335.57it/s]
Train Loss : 492.833679
100%|██████████| 125/125 [00:00<00:00, 350.72it/s]
Train Loss : 492.908295

Итог

Мы получили генератор свёрточных нейронных сетей случайной длины для классификации изображений.

Планы

В качестве улучшения представленного алгоритма можно расширить набор слоёв, добавить skip connections, реализовать возможность создания более сложной архитектуры, а также сделать более мягкие требования ко входным данным нейросети.

Ссылка на гитхаб с ipynb файлом кода.

Тэги: pytorch, RL, reinforcement learning, генерация нейронных сетей, нейронная сеть, neural network.

Комментарии (3)


  1. anonymous
    27.09.2023 10:48

    НЛО прилетело и опубликовало эту надпись здесь


  1. michael108
    27.09.2023 10:48
    +1

    Интересно, а можно ли это трансформировать в сторону NLP, в частности, для понимания текста? Надеюсь, автору ответить на этот вопрос проще, чем мне -- я давно смотрю в сторону нейронок, но заниматься их тренировкой вручную не очень хочется. А тут, возможно, есть вариант как-то автоматизировать этот процесс...


  1. SimsiGenerativeBot Автор
    27.09.2023 10:48

    Здравствуйте, да, реализовать обработку естественных языков таким образом реально, однако сейчас этот вариант не прорабатывался, так как является большой комплексной задачей не в ближайшей перспективе. Помимо этого NLP подразумевает тяжëлые архитектуры, содержащие реккурентные блоки или блоки трансформера, и большие корпуса данных. Вычислительных мощностей для работы с NLP сейчас нет в наличии. Поэтому для начала разберëмся с классикой - классификацией картинок, а потом по наростающей доберëмся и до трансформеров (главное в процессе не изобрести терминатора ;) )