Когато търсите в Google „Произволно търсене на хиперпараметри“, намирате само ръководства за това как да рандомизирате скоростта на обучение, импулса, отпадането, затихването на теглото и т.н. Какво ще стане, ако искате да експериментирате и с моделни хиперпараметри като размер на конволюционното ядро , крачка, брой ядра и дори брой напълно свързани слоеве? Без готови отговори, реших го сам.
Рандомизирането на хиперпараметрите на модела има смисъл, когато решавате проблем, различен от общи задачи, като например класифициране на ежедневни обекти. За мен се занимавам с проблем с регресията на времевите серии, който изисква повече експерименти с всички хиперпараметри.
За да илюстрирам по-добре моя подход към търсенето на хиперпараметри на случаен модел, използвам най-основния CNN — LeNet. Имайте предвид, че въпреки че самите слоеве са произволно оразмерени, броят на слоевете (различни от напълно свързаните слоеве) и видовете слоеве са твърдо кодирани (по този начин все още LeNet).
Стойността на търсенето на хиперпараметър на модела е да абстрахира размерите на слоевете от архитектурата. Например, когато говорим за LeNet-5, вече не е необходимо да уточняваме броя на ядрата, размера на ядрото, стъпката на обединяване и т.н. Те така или иначе са произволни и не позволявайте на хартията да ви каже друго!
Част I. Концепцията: Хиперпараметри на модела на CNN
0а. Конволюция и размери на обединяване
Благодарение на Stanford CS231n можем да изразим размерите на навиване и обединяване:
# Convolutional Layer Output Shape conv_output_size = (conv_num_kernels, (input_size - conv_kernel_size)/conv_stride + 1 ) # Pooling Layer Output Shape pool_output_size = (conv_num_kernels, (conv_output_size[1] - pool_kernel_size)/pool_stride + 1 )
Това ни позволява динамично да дефинираме нашия модел при инстанциране на модела:
# excerpt from lenet.py # Model creation (omitting fully connected layers) class LeNet(nn.Module): def __init__(self, input_size, output_size, batch_norm, use_pooling, pooling_method, conv1_kernel_size, conv1_num_kernels, conv1_stride, conv1_dropout, pool1_kernel_size, pool1_stride, conv2_kernel_size, conv2_num_kernels, conv2_stride, conv2_dropout, pool2_kernel_size, pool2_stride, fcs_hidden_size, fcs_num_hidden_layers, fcs_dropout): super(LeNet, self).__init__() self.input_size = input_size self.hidden_size = fcs_hidden_size self.output_size = output_size self.batch_norm = batch_norm input_channel = 2 # If not using pooling, set all pooling operations to 1 by 1. if use_pooling == False: warnings.warn('lenet: not using pooling') pool1_kernel_size = 1 pool1_stride = 1 pool2_kernel_size = 1 pool2_stride = 1 # Conv1 conv1_output_size = (conv1_num_kernels, (input_size - conv1_kernel_size) / conv1_stride + 1) # if not conv1_output_size[1].is_integer(): # raise ValueError('lenet: conv1_output_size[1] %s is not an integer.' % conv1_output_size[1]) # conv1_output_size = (conv1_num_kernels, int(conv1_output_size[1])) self.conv1 = nn.Conv1d(input_channel, conv1_num_kernels, conv1_kernel_size, stride=conv1_stride) # NOTE: THIS IS CORRECT!!!! CONV doesn't depend on num_features! nn.init.kaiming_normal_(self.conv1.weight.data) self.conv1.bias.data.fill_(0) self.conv1_drop = nn.Dropout2d(p=conv1_dropout) if self.batch_norm == True: self.batch_norm1 = nn.BatchNorm1d(conv1_num_kernels) # Pool1 pool1_output_size = (conv1_num_kernels, (conv1_output_size[1] - pool1_kernel_size) / pool1_stride + 1) self.pool1 = nn.MaxPool1d(pool1_kernel_size, stride=pool1_stride) # stride=pool1_kernel_size by default # Conv2 conv2_output_size = (conv2_num_kernels, (pool1_output_size[1] - conv2_kernel_size) / conv2_stride + 1) self.conv2 = nn.Conv1d(conv1_num_kernels, conv2_num_kernels, conv2_kernel_size, stride=conv2_stride) # NOTE: THIS IS CORRECT!!!! CONV doesn't depend on num_features! nn.init.kaiming_normal_(self.conv2.weight.data) self.conv2.bias.data.fill_(0) self.conv2_drop = nn.Dropout2d(p=conv2_dropout) if self.batch_norm == True: self.batch_norm2 = nn.BatchNorm1d(conv2_num_kernels) # Pool2 pool2_output_size = (conv2_num_kernels, (conv2_output_size[1] - pool2_kernel_size) / pool2_stride + 1) self.pool2 = nn.MaxPool1d(pool2_kernel_size, stride=pool2_stride) # stride=pool1_kernel_size by default
0b. Прогресивно оразмеряване на слоя
В горната стъпка може да забележите, че pool_output_size зависи от conv_output_size и че и двата размера трябва да осигурят целочислена делимост в (input_size — conv_kernel_size)/conv_stride и (conv_output_size[1] — pool_kernel_size)/pool_stride + 1, съответно. Ако един слой има грешен размер, по-късните слоеве може да нямат цяло число, което е незаконно и ще доведе до извеждане на мистериозни грешки от PyTorch.
Освен това размерът на напълно свързаните слоеве зависи от формата на техния предишен слой. В нашия случай това е pool2:
# excerpt from lenet.py # Model creation (the omitted fully-connected layers) # FCs fcs_input_size = pool2_output_size[0] * pool2_output_size[1] # if not fcs_input_size.is_integer(): # raise ValueError('lenet: fcs_input_size = ' + fcs_input_size + ' is not an integer') # fcs_input_size = int(fcs_input_size) self.fcs = FullyConnectedNet(fcs_input_size, output_size, fcs_dropout, batch_norm, fcs_hidden_size, fcs_num_hidden_layers)
За да се реши този проблем със зависимостта на размера на кръстосания слой, един наивен подход е да продължите произволно да избирате един хиперпараметър, докато всички слоеве имат цели размери, за всички хиперпараметри на модела. По-ефективен начин е предварително да се определят всички законни комбинации от хиперпараметри на модела и да се избира произволно от тях, като например това:
# Part of create_model.py: Size-constrained random hyperparameter search possible_size_combinations = [] for conv1_kernel_size in conv1_kernel_size_range: for conv1_stride in conv1_stride_range: # Satisfy conv1 condition if (input_size - conv1_kernel_size) % conv1_stride != 0: continue conv1_output_size = (conv1_num_kernels, (input_size - conv1_kernel_size) / conv1_stride + 1) for pool1_kernel_size in pool1_kernel_size_range: for pool1_stride in pool1_stride_range: if (conv1_output_size[1] - pool1_kernel_size) % pool1_stride != 0: continue pool1_output_size = (conv1_num_kernels, (conv1_output_size[1] - pool1_kernel_size) / pool1_stride + 1) for conv2_kernel_size in conv2_kernel_size_range: for conv2_stride in conv2_stride_range: if (pool1_output_size[1] - conv2_kernel_size) % conv2_stride != 0: continue conv2_output_size = (conv2_num_kernels, (pool1_output_size[1] - conv2_kernel_size) / conv2_stride + 1) for pool2_kernel_size in pool2_kernel_size_range: for pool2_stride in pool2_stride_range: if (conv2_output_size[1] - pool2_kernel_size) % pool2_stride != 0: continue pool2_output_size = (conv2_num_kernels, (conv2_output_size[1] - pool2_kernel_size) / pool2_stride + 1) possible_size_combinations.append((conv1_kernel_size, conv1_stride, pool1_kernel_size, pool1_stride, conv2_kernel_size, conv2_stride, pool2_kernel_size, pool2_stride)) if len(possible_size_combinations) == 0: raise ValueError('create_models: no possible combination for pool1 given conv1_output_size[1] = ' + str(conv1_output_size[1]) + '; pool1_kernel_size_ranges = ' + str(pool1_kernel_size_ranges) + '; pool1_stride_ranges = ' + str(pool1_stride_ranges)) conv1_kernel_size, conv1_stride, pool1_kernel_size, pool1_stride, conv2_kernel_size, conv2_stride, pool2_kernel_size, pool2_stride = random.choice(possible_size_combinations)
По този начин можем да сме сигурни, че няма да заседнем в рандомизирането на цикли while поради лоши диапазони на хиперпараметри; ние също така поддържаме кода на едно място и сравнително чист (може да не изглежда частта, но опитайте да замените горното с 10+ цикли while). Продължете да четете за пълните подробности за изпълнението.
Част II. Внедряването: Произволно търсене на хиперпараметър на модела
Цялостният ми подход се състои от процес на създаване и обучение. Причината, поради която са разделени, е научната оценка, възпроизводимостта и паралелните изчисления в мащаб. Първо, функцията create_models взема броя на моделите за създаване и файл с диапазон на хиперпараметри и ги записва във файл.
Забележка: Моля, игнорирайте подмоделите „k“ — запазвам len(k) копия на същия модел за по-късна оценка, специфична за домейна.
# create_models.py # The creation process - randomize model and training hyperparameters and write them to file. # Example usage: python lib/create_models.py 50 hyperparam_ranges.json import os import datetime import random import argparse import json import warnings import sys from utils import save_model_params, ensure_dir def choose_hyperparameters_from_file(hyperparameter_ranges_file): with open(hyperparameter_ranges_file) as f: ranges = json.load(f) # Load constants. input_size = ranges['input_size'] batch_norm = random.choice(ranges['batch_norm']) use_pooling = random.choice(ranges['use_pooling']) conv1_num_kernels = random.choice(list(range(*ranges['conv1_num_kernels']))) conv1_dropout = random.uniform(*ranges['conv1_dropout']) conv2_num_kernels = random.choice(list(range(*ranges['conv2_num_kernels']))) conv2_dropout = random.uniform(*ranges['conv2_dropout']) # Randomly choose model hyperparameters from ranges. conv1_kernel_size_range = list(range(*ranges['conv1_kernel_size'])) conv1_stride_range = ranges['conv1_stride'] pool1_kernel_size_range = ranges['pool1_kernel_size'] pool1_stride_range = ranges['pool1_stride'] conv2_kernel_size_range = list(range(*ranges['conv2_kernel_size'])) conv2_stride_range = ranges['conv2_stride'] pool2_kernel_size_range = ranges['pool2_kernel_size'] pool2_stride_range = ranges['pool2_stride'] # Size-constrained random hyperparameter search possible_size_combinations = [] for conv1_kernel_size in conv1_kernel_size_range: for conv1_stride in conv1_stride_range: # Satisfy conv1 condition if (input_size - conv1_kernel_size) % conv1_stride != 0: continue conv1_output_size = (conv1_num_kernels, (input_size - conv1_kernel_size) / conv1_stride + 1) for pool1_kernel_size in pool1_kernel_size_range: for pool1_stride in pool1_stride_range: if (conv1_output_size[1] - pool1_kernel_size) % pool1_stride != 0: continue pool1_output_size = (conv1_num_kernels, (conv1_output_size[1] - pool1_kernel_size) / pool1_stride + 1) for conv2_kernel_size in conv2_kernel_size_range: for conv2_stride in conv2_stride_range: if (pool1_output_size[1] - conv2_kernel_size) % conv2_stride != 0: continue conv2_output_size = (conv2_num_kernels, (pool1_output_size[1] - conv2_kernel_size) / conv2_stride + 1) for pool2_kernel_size in pool2_kernel_size_range: for pool2_stride in pool2_stride_range: if (conv2_output_size[1] - pool2_kernel_size) % pool2_stride != 0: continue pool2_output_size = (conv2_num_kernels, (conv2_output_size[1] - pool2_kernel_size) / pool2_stride + 1) possible_size_combinations.append((conv1_kernel_size, conv1_stride, pool1_kernel_size, pool1_stride, conv2_kernel_size, conv2_stride, pool2_kernel_size, pool2_stride)) if len(possible_size_combinations) == 0: raise ValueError('create_models: no possible combination for pool1 given conv1_output_size[1] = ' + str(conv1_output_size[1]) + '; pool1_kernel_size_ranges = ' + str(pool1_kernel_size_ranges) + '; pool1_stride_ranges = ' + str(pool1_stride_ranges)) conv1_kernel_size, conv1_stride, pool1_kernel_size, pool1_stride, conv2_kernel_size, conv2_stride, pool2_kernel_size, pool2_stride = random.choice(possible_size_combinations) # print('create_models: ranges[\'fcs_hidden_size\'] =', ranges['fcs_hidden_size']) # print('create_models: list(range(*ranges[\'fcs_hidden_size\'])) =', list(range(*ranges['fcs_hidden_size']))) fcs_hidden_size = random.choice(list(range(*ranges['fcs_hidden_size']))) fcs_num_hidden_layers = random.choice(list(range(*ranges['fcs_num_hidden_layers']))) fcs_dropout = random.uniform(*ranges['fcs_dropout']) # Randomly choose training hyperparameters from ranges. cost_function = random.choice(ranges['cost_function']) optimizer = random.choice(ranges['optimizer']) if optimizer == 'SGD': momentum = random.uniform(*ranges['momentum']) learning_rate = random.uniform(*ranges['learning_rate_sgd']) elif optimizer == 'Adam': momentum = None learning_rate = random.uniform(*ranges['learning_rate_adam']) hyperparameters = { 'input_size': input_size, 'output_size': ranges['output_size'], 'batch_norm': batch_norm, 'use_pooling': use_pooling, 'pooling_method': ranges['pooling_method'], 'conv1_kernel_size': conv1_kernel_size, 'conv1_num_kernels': conv1_num_kernels, 'conv1_stride': conv1_stride, 'conv1_dropout': conv1_dropout, 'pool1_kernel_size': pool1_kernel_size, 'pool1_stride': pool1_stride, 'conv2_kernel_size': conv2_kernel_size, 'conv2_num_kernels': conv2_num_kernels, 'conv2_stride': conv2_stride, 'conv2_dropout': conv2_dropout, 'pool2_kernel_size': pool2_kernel_size, 'pool2_stride': pool2_stride, 'fcs_hidden_size': fcs_hidden_size, 'fcs_num_hidden_layers': fcs_num_hidden_layers, 'fcs_dropout': fcs_dropout, 'cost_function': cost_function, 'optimizer': optimizer, 'learning_rate': learning_rate, 'momentum': momentum, } return hyperparameters def create_models(num_networks, hyperparameter_ranges_file): identifier = datetime.datetime.now().strftime('%Y%m%d%H%M%S') data_is_target_list = [0] num_scat_list = [1, 2, 3] batch_size_list = [32] data_noise_gaussian_list = [0, 1] #dropout_input_list = [0, 0.1, 0.2] #dropout_list = [0, 0.1, 0.2, 0.3, 0.4, 0.5] weight_decay_list = [0] for count in range(num_networks): data_is_target = random.choice(data_is_target_list) n_scat = random.choice(num_scat_list) bs = random.choice(batch_size_list) data_noise_gaussian = random.choice(data_noise_gaussian_list) #dropout_input = random.choice(dropout_input_list) weight_decay = random.choice(weight_decay_list) # get params model_params = choose_hyperparameters_from_file(hyperparameter_ranges_file) # set other params model_params['data_is_target'] = data_is_target home = os.path.expanduser('~') model_params['data_train'] = os.path.join(home,'Downloads', '20180402_L74_70mm', 'train_' + str(n_scat) + '.h5') model_params['data_val'] = os.path.join(home, 'Downloads', '20180402_L74_70mm', 'val_' + str(n_scat) + '.h5') model_params['batch_size'] = bs model_params['data_noise_gaussian'] = data_noise_gaussian #model_params['dropout_input'] = dropout_input model_params['weight_decay'] = weight_decay model_params['patience'] = 20 model_params['cuda'] = 1 model_params['save_initial'] = 0 k_list = [3, 4, 5] for k in k_list: model_params['k'] = k model_params['save_dir'] = os.path.join('DNNs', identifier + '_' + str(count+1) + '_created', 'k_' + str(k)) # print(model_params['save_dir']) ensure_dir(model_params['save_dir']) save_model_params(os.path.join(model_params['save_dir'], 'model_params.txt'), model_params) return identifier def main(): # parse input arguments parser = argparse.ArgumentParser() parser.add_argument('num_networks', type=int, help='The number of networks to train.') parser.add_argument('hyperparameter_ranges_file', type=str, help='The number of networks to train.') args = parser.parse_args() num_networks = args.num_networks hyperparameter_ranges_file = args.hyperparameter_ranges_file return create_models(num_networks, hyperparameter_ranges_file) if __name__ == '__main__': main()
Ето един пример за моя файл с диапазон на hyperparams, hyperparam_ranges.json:
{ "input_size": 65, "output_size": 130, "batch_norm": [0, 1], "use_pooling": [0, 1], "pooling_method": "max", "conv1_kernel_size": [6, 32], "conv1_num_kernels": [8, 51], "conv1_stride": [1], "conv1_dropout": [0, 0], "pool1_kernel_size": [2, 3], "pool1_stride": [2], "conv2_kernel_size": [2, 20], "conv2_num_kernels": [2, 40], "conv2_stride": [1], "conv2_dropout": [0, 1], "pool2_kernel_size": [2], "pool2_stride": [2], "fcs_hidden_size": [25, 520], "fcs_num_hidden_layers": [1, 4], "fcs_dropout": [0, 1], "cost_function": ["MSE"], "optimizer": ["Adam", "SGD"], "momentum": [0.8, 1], "learning_rate_adam": [0, 0.0002], "learning_rate_sgd": [0, 0.02] }
И накрая, ние обучаваме създадените произволни модели със стандартен скрипт за обучение:
# train.py # Usage: python train.py "*" # python train.py 201807201428 import torch import os import numpy as np import time import argparse import glob import warnings from pprint import pprint from utils import read_model_params, save_model_params, ensure_dir, add_suffix_to_path from dataloader import ApertureDataset from lenet import LeNet from logger import Logger from trainer import Trainer def train(identifier): models = glob.glob(os.path.join('DNNs', str(identifier) + '_created')) for model_folder in models: ks = glob.glob(os.path.join(model_folder, 'k_*')) for k in ks: model_params_path = k + '/model_params.txt' print('train.py: training model', model_params_path, 'with hyperparams') # load model params. model_params = read_model_params(model_params_path) # print model and training parameters. pprint(model_params) # cuda flag using_cuda = model_params['cuda'] and torch.cuda.is_available() if using_cuda == True: print('train.py: Using ' + str(torch.cuda.get_device_name(0))) else: warnings.warn('train.py: Not using CUDA') # Load primary training data num_samples = 10 ** 5 dat_train = ApertureDataset(model_params['data_train'], num_samples, model_params['k'], model_params['data_is_target']) loader_train = torch.utils.data.DataLoader(dat_train, batch_size=model_params['batch_size'], shuffle=True, num_workers=1) # Load secondary training data - used to evaluate training loss after every epoch num_samples = 10 ** 4 dat_train2 = ApertureDataset(model_params['data_train'], num_samples, model_params['k'], model_params['data_is_target']) loader_train_eval = torch.utils.data.DataLoader(dat_train2, batch_size=model_params['batch_size'], shuffle=False, num_workers=1) # Load validation data - used to evaluate validation loss after every epoch num_samples = 10 ** 4 dat_val = ApertureDataset(model_params['data_val'], num_samples, model_params['k'], model_params['data_is_target']) loader_val = torch.utils.data.DataLoader(dat_val, batch_size=model_params['batch_size'], shuffle=False, num_workers=1) # create model model = LeNet(model_params['input_size'], model_params['output_size'], model_params['batch_norm'], model_params['use_pooling'], model_params['pooling_method'], model_params['conv1_kernel_size'], model_params['conv1_num_kernels'], model_params['conv1_stride'], model_params['conv1_dropout'], model_params['pool1_kernel_size'], model_params['pool1_stride'], model_params['conv2_kernel_size'], model_params['conv2_num_kernels'], model_params['conv2_stride'], model_params['conv2_dropout'], model_params['pool2_kernel_size'], model_params['pool2_stride'], model_params['fcs_hidden_size'], model_params['fcs_num_hidden_layers'], model_params['fcs_dropout']) if using_cuda == True: model.cuda() # save initial weights if model_params['save_initial'] and model_params['save_dir']: suffix = '_initial' path = add_suffix_to_path(model_parmas['save_dir'], suffix) print('Saving model weights in : ' + path) ensure_dir(path) torch.save(model.state_dict(), os.path.join(path, 'model.dat')) save_model_params(os.path.join(path, 'model_params.txt'), model_params) # loss loss = torch.nn.MSELoss() # optimizer if model_params['optimizer'] == 'Adam': optimizer = torch.optim.Adam(model.parameters(), lr=model_params['learning_rate'], weight_decay=model_params['weight_decay']) elif model_params['optimizer'] == 'SGD': optimizer = torch.optim.SGD(model.parameters(), lr=model_params['learning_rate'], momentum=model_params['momentum'], weight_decay=model_params['weight_decay']) else: raise ValueError('model_params[\'optimizer\'] must be either Adam or SGD. Got ' + model_params['optimizer']) logger = Logger() trainer = Trainer(model=model, loss=loss, optimizer=optimizer, patience=model_params['patience'], loader_train=loader_train, loader_train_eval=loader_train_eval, loader_val=loader_val, cuda=using_cuda, logger=logger, data_noise_gaussian=model_params['data_noise_gaussian'], save_dir=model_params['save_dir']) # run training trainer.train() os.rename(model_folder, model_folder.replace('_created', '_trained')) def main(): parser = argparse.ArgumentParser() parser.add_argument('identifier', help='Option to load model params from a file. Values in this file take precedence.') args = parser.parse_args() identifier = args.identifier train(identifier) if __name__ == '__main__': main()
Какво мислиш? Моля, споделете вашите коментари и отзиви!