如何用自动机器学习实现神经网络进化
对大多数从事机器学习工作的人来说,设计一个神经网络无异于制作一项艺术作品。神经网络通常始于一个常见的架构,然后我们需要对参数不断地进行调整和优化,直到找到一个好的组合层、激活函数、正则化器和优化参数。在一些知名的神经网络架构,如VGG、Inception、ResNets、DenseNets等的指导下,我们需要对网络的变量进行重复的操作,直到网络达到我们期望的速度与准确度。随着网络处理能力的不断提高,将网络优化处理程序自动化变得越来越可行。
在像Random Forests和SVMs这样的浅模型中,我们已经能够使超参数优化的操作自动化进行了。一些常用的工具包,比如sk-learn,向我们提供了搜索超参数空间的方法。在其最简单的、最基础的格式中,“超参数”是我们在所有可能的参数中搜索得到的,或者是通过从参数分布中任意采样得到的。(详情请点击此链接查看。)这两种方法都面临着两个问题:第一,在错误参数区域进行搜索时会造成资源浪费;第二,处理大量的动态特征参数集将导致效率过低。因此,改变处理器的架构变得相当困难。虽然现在有很多看似高效的方法,比如Bayesian优化方法。但Bayesian优化法虽然能够解决了第一个问题,却对第二个问题无能为力;另外,在Bayesian优化设置中也很难进行探索模型。
自动识别最佳模型的想法就现在来说已经不算新鲜了,再加上最近大幅度提升的处理能力,实现这一想法比以往任何时候都要容易。
问题设定
考虑超参数优化的方式之一,就是将它看做一个“元学习问题”。
我们究竟能否打造出一个可以用于判断网络性能好坏的算法呢?
注意:接下来我将继续使用“元学习”这个术语,即使将这个问题描述为“元学习”有点混淆视听,但我们千万不能把它与“学习”相关的一些方法弄混了。
我们的目标是定义网络隐含层(绿色)的数量以及每个隐含层的参数。
具体而言,就是探究模型架构和模型的参数空间,从而在给定的数据集上优化其性能。这个问题复杂难解,而回报稀薄。之所以说它回报稀薄,是因为我们需要对网络进行足够的训练,还要对它进行评估;而在训练、评估完成后,我们得到回报的仅仅是一些得分。这些得分反映了整个系统的性能表现,而这种类型的回报并不是可导函数!说到这,是不是让你想起了什么呢?没错,这就是一个典型的“强化学习”情境!
维基百科对“强化学习”的定义:
“强化学习”(RL)是一种重要的机器学习方法,它的灵感来自于心理学的行为主义理论。具体来说,“强化学习”是关于有机体(agent)如何在环境(environment)的刺激下,将累计奖励最大化的方法。
“强化学习”与标准的监督式学习之间的区别在于它不需要出现正确的输入或输出对,也不需要精准校正其次优化行为。另外,“在线性能”也是“强化学习”关注的焦点,即在未知领域的探索与现有知识的开发之间找到平衡。
上图情境中的有机体(agent)是一个模型,环境(environment)就是我们用于训练和评估的数据集。解释器(interpreter)是对每一行为进行分析以及设置有机体状态(在我们这个情境中,解释器设置的是网络参数)的过程。
通常情况下,“强化学习”问题都被定义为一个Markov决策过程。其目的就是优化有机体的总回报。每一步,你需要对优化模型输出作出决策,或者是探索出一个新的行为。在环境的刺激下,有机体将根据得到的反馈,形成一个调整政策,不断改进其行为。
注意:这个话题超出了本文讨论的范围,R.Sutton和A. Barto的《强化学习介绍》可能是关于这个主题的最佳入门指导书。
进化算法
解决“强化学习”问题的另一种方法是“进化算法”。在生物进化的启发下,进化算法通过创建一个解决方案的集合,寻找解决方案的空间;然后,它会对每一解决方案进行评估,并根据评估得分不断调整这个方案集合。生物进化论中所讲的“进化”涉及到一个种群中最佳成员的选择和变异。因此,我们的解决方案集合也会不断进化发展,以提高其整体适应性,并为问题找到提供可行的解决方案。
上图的左边介绍了进化的过程,设计一个“进化算法”涉及到两个部分——“选择”,以及需要遵循的“跨界”或“变异”策略。
“选择”:对于“选择”,我们通常的做法是挑选最佳的个体和一些任意的个体,以达到多样性。更先进的选择方法是在种群下设立不同的“次群”,即“物种”;然后在物种中选择最佳的个体,以保护其多样性。另一种比较受欢迎的做法是“竞赛选择”,即任意选择一些个体参与竞赛,挑选出胜者(基因优胜的个体)。
“跨界”:“跨界”也称“交叉跨界”,指的是两组或两组以上亲本交叉混合,产生后代。“交叉跨界”高度依赖于问题结构的方式。常见的方法是用一个项目列表(一般是数值)对亲本进行描述,然后从亲本中挑选任意部分来生成新的基因组合。
“变异”:“变异”或“突变”指的是任意改变基因组的过程。这是主要的开发因素,有助于保持种群的多样性。
实施启用
“进化算法”的实施启用使用了PyTorch来建立代理,这个代理将会探索用于完成简单分类任务的DNNs。这个实验使用的是MNIST,因为它小且快,即使在CPU上也能完成训练。我们将建立一组DNN模型,并将其发展进化为N个步骤。
我们所讲的“进化”主题实际上就是“物竞天择”的实施,完整的高水平“进化算法”如下所示:
<ol> <li>new_population = [] </li> <li> while size(new_population) < population_size: </li> <li> choose k(tournament) individuals from the population at random </li> <li> choose the best from pool/tournament with probability p1 </li> <li> choose the second best individual with probability p2 </li> <li> choose the third best individual with probability p3 </li> <li> mutate and append selected to the new_population </li> </ol>
附注:当涉及到架构合并时,跨界问题就变得相当复杂了。究竟该如何将两个亲本的架构合并呢?缺陷图样及环境整合训练将对此产生什么影响呢?近期的一篇来自Miikkulainen等人的论文提出了一种被称为CoDeepNEAT的解决方案。基于Evolino进化理论,一个架构由部分单元模块组成,其中的每一单元模块都是服从于进化理论的。这个架构是一个合并了所有组成成分的理想蓝图。在这样的情境下,将亲本的组成成分混合是十分合理的,因为其成分是一个完整的微型网络。为了使文章更简洁易懂,我在这个算法实施过程中避开了跨界交叉的问题,而是简单介绍了类似NEAT(或CoDeepNEAT)这样的解决方案。(我打算在下一篇文章中详细介绍这些解决方案。)
基本的构件
我们需要定义的第一件事情就是每一模型的解决方案空间,每一个个体都代表着一个架构。简洁起见,我们堆叠了n层,每一层都包含三个参数:a)隐藏单元的数量;b)激活类型;c)丢失率。对于通用参数,我们在不同的优化器、学习率、权重衰减和层数量中进行选择。
<ol> <li># definition of a space </li> <li># lower bound - upper bound, type param, mutation rate </li> <li>LAYER_SPACE = dict() </li> <li>LAYER_SPACE['nb_units'] = (128, 1024, 'int', 0.15) </li> <li>LAYER_SPACE['dropout_rate'] = (0.0, 0.7, 'float', 0.2) </li> <li>LAYER_SPACE['activation'] =\ </li> <li> (0, ['linear', 'tanh', 'relu', 'sigmoid', 'elu'], 'list', 0.2) </li> <li> </li> <li>NET_SPACE = dict() </li> <li>NET_SPACE['nb_layers'] = (1, 3, 'int', 0.15) </li> <li>NET_SPACE['lr'] = (0.0001, 0.1, 'float', 0.15) </li> <li>NET_SPACE['weight_decay'] = (0.00001, 0.0004, 'float', 0.2) </li> <li>NET_SPACE['optimizer'] =\ </li> <li> (0, ['sgd', 'adam', 'adadelta', 'rmsprop'], 'list', 0.2) </li> </ol>
完成以上操作以后,我们已经定义了模型的空间。接着我们还需要建立三个基本功能:
随机选择一个网络
<ol> <li>def random_value(space): </li> <li> """Sample random value from the given space.""" </li> <li> val = None </li> <li> if space[2] == 'int': </li> <li> val = random.randint(space[0], space[1]) </li> <li> if space[2] == 'list': </li> <li> val = random.sample(space[1], 1)[0] </li> <li> if space[2] == 'float': </li> <li> val = ((space[1] - space[0]) * random.random()) + space[0] </li> <li> return {'val': val, 'id': random.randint(0, 2**10)} </li> <li> </li> <li> </li> <li>def randomize_network(bounded=True): </li> <li> """Create a random network.""" </li> <li> global NET_SPACE, LAYER_SPACE </li> <li> net = dict() </li> <li> for k in NET_SPACE.keys(): </li> <li> net[k] = random_value(NET_SPACE[k]) </li> <li> </li> <li> if bounded: </li> <li> net['nb_layers']['val'] = min(net['nb_layers']['val'], 1) </li> <li> </li> <li> layers = [] </li> <li> for i in range(net['nb_layers']['val']): </li> <li> layer = dict() </li> <li> for k in LAYER_SPACE.keys(): </li> <li> layer[k] = random_value(LAYER_SPACE[k]) </li> <li> layers.append(layer) </li> <li> net['layers'] = layers </li> <li> return net </li> </ol>
首先,我们任意地对层数量和每一层的参数进行采样,样本值会在预先定义好的范围边缘内出现下降。在初始化一个参数的同时,我们还会产生一个任意的参数id。现在它还不能使用,但我们可以追踪所有的层。当一个新的模型发生突变时,旧的层会进行微调,同时仅对发生突变的层进行初始化。这样的做法应该能够显著地加快速度,并稳定解决方案。
注意:根据问题性质的不同,我们可能需要不同的限制条件,比如参数的总量或层的总数量。
使网络发生变异
<ol> <li>def mutate_net(net): </li> <li> """Mutate a network.""" </li> <li> global NET_SPACE, LAYER_SPACE </li> <li> </li> <li> # mutate optimizer </li> <li> for k in ['lr', 'weight_decay', 'optimizer']: </li> <li> </li> <li> if random.random() < NET_SPACE[k][-1]: </li> <li> net[k] = random_value(NET_SPACE[k]) </li> <li> </li> <li> # mutate layers </li> <li> for layer in net['layers']: </li> <li> for k in LAYER_SPACE.keys(): </li> <li> if random.random() < LAYER_SPACE[k][-1]: </li> <li> layer[k] = random_value(LAYER_SPACE[k]) </li> <li> # mutate number of layers -- RANDOMLY ADD </li> <li> if random.random() < NET_SPACE['nb_layers'][-1]: </li> <li> if net['nb_layers']['val'] < NET_SPACE['nb_layers'][1]: </li> <li> if random.random()< 0.5: </li> <li> layer = dict() </li> <li> for k in LAYER_SPACE.keys(): </li> <li> layer[k] = random_value(LAYER_SPACE[k]) </li> <li> net['layers'].append(layer) </li> <li> # value & id update </li> <li> net['nb_layers']['val'] = len(net['layers']) </li> <li> net['nb_layers']['id'] +=1 </li> <li> else: </li> <li> if net['nb_layers']['val'] > 1: </li> <li> net['layers'].pop() </li> <li> net['nb_layers']['val'] = len(net['layers']) </li> <li> net['nb_layers']['id'] -=1 </li> <li> return net </li> </ol>
每一个网络元素都存在变异的可能性,每一次变异都将重新采样参数空间,进而使参数发生变化。
建立网络
<ol> <li>class CustomModel(): </li> <li> </li> <li> def __init__(self, build_info, CUDA=True): </li> <li> </li> <li> previous_units = 28 * 28 </li> <li> self.model = nn.Sequential() </li> <li> self.model.add_module('flatten', Flatten()) </li> <li> for i, layer_info in enumerate(build_info['layers']): </li> <li> i = str(i) </li> <li> self.model.add_module( </li> <li> 'fc_' + i, </li> <li> nn.Linear(previous_units, layer_info['nb_units']['val']) </li> <li> ) </li> <li> self.model.add_module( </li> <li> 'dropout_' + i, </li> <li> nn.Dropout(p=layer_info['dropout_rate']['val']) </li> <li> ) </li> <li> if layer_info['activation']['val'] == 'tanh': </li> <li> self.model.add_module( </li> <li> 'tanh_'+i, </li> <li> nn.Tanh() </li> <li> ) </li> <li> if layer_info['activation']['val'] == 'relu': </li> <li> self.model.add_module( </li> <li> 'relu_'+i, </li> <li> nn.ReLU() </li> <li> ) </li> <li> if layer_info['activation']['val'] == 'sigmoid': </li> <li> self.model.add_module( </li> <li> 'sigm_'+i, </li> <li> nn.Sigmoid() </li> <li> ) </li> <li> if layer_info['activation']['val'] == 'elu': </li> <li> self.model.add_module( </li> <li> 'elu_'+i, </li> <li> nn.ELU() </li> <li> ) </li> <li> previous_units = layer_info['nb_units']['val'] </li> <li> </li> <li> self.model.add_module( </li> <li> 'classification_layer', </li> <li> nn.Linear(previous_units, 10) </li> <li> ) </li> <li> self.model.add_module('sofmax', nn.LogSoftmax()) </li> <li> self.model.cpu() </li> <li> </li> <li> if build_info['optimizer']['val'] == 'adam': </li> <li> optimizer = optim.Adam(self.model.parameters(), </li> <li> lr=build_info['weight_decay']['val'], </li> <li> weight_decay=build_info['weight_decay']['val']) </li> <li> </li> <li> elif build_info['optimizer']['val'] == 'adadelta': </li> <li> optimizer = optim.Adadelta(self.model.parameters(), </li> <li> lr=build_info['weight_decay']['val'], </li> <li> weight_decay=build_info['weight_decay']['val']) </li> <li> </li> <li> elif build_info['optimizer']['val'] == 'rmsprop': </li> <li> optimizer = optim.RMSprop(self.model.parameters(), </li> <li> lr=build_info['weight_decay']['val'], </li> <li> weight_decay=build_info['weight_decay']['val']) </li> <li> else: </li> <li> optimizer = optim.SGD(self.model.parameters(), </li> <li> lr=build_info['weight_decay']['val'], </li> <li> weight_decay=build_info['weight_decay']['val'], </li> <li> momentum=0.9) </li> <li> self.optimizer = optimizer </li> <li> self.cuda = False </li> <li> if CUDA: </li> <li> self.model.cuda() </li> <li> self.cuda = True </li> </ol>
上面的类别将会实例化模型的“基因组”。
现在,我们已经具备了建立一个任意网络、变更其架构并对其进行训练的基本构件,那么接下来的步骤就是建立“遗传算法”,“遗传算法”将会对最佳个体进行选择和变异。每个模型的训练都是独立进行的,不需要其他有机体的任何信息。这就使得优化过程可以随着可用的处理节点进行线性扩展。
GP优化器的编码
<ol> <li>"""Genetic programming algorithms.""" </li> <li>from __future__ import absolute_import </li> <li> </li> <li>import random </li> <li>import numpy as np </li> <li>from operator import itemgetter </li> <li>import torch.multiprocessing as mp </li> <li>from net_builder import randomize_network </li> <li>import copy </li> <li>from worker import CustomWorker, Scheduler </li> <li> </li> <li> </li> <li>class TournamentOptimizer: </li> <li> """Define a tournament play selection process.""" </li> <li> </li> <li> def __init__(self, population_sz, init_fn, mutate_fn, nb_workers=2, use_cuda=True): </li> <li> """ </li> <li> Initialize optimizer. </li> <li> </li> <li> params:: </li> <li> </li> <li> init_fn: initialize a model </li> <li> mutate_fn: mutate function - mutates a model </li> <li> nb_workers: number of workers </li> <li> """ </li> <li> </li> <li> self.init_fn = init_fn </li> <li> self.mutate_fn = mutate_fn </li> <li> self.nb_workers = nb_workers </li> <li> self.use_cuda = use_cuda </li> <li> </li> <li> # population </li> <li> self.population_sz = population_sz </li> <li> self.population = [init_fn() for i in range(population_sz)] </li> <li> self.evaluations = np.zeros(population_sz) </li> <li> </li> <li> # book keeping </li> <li> self.elite = [] </li> <li> self.stats = [] </li> <li> self.history = [] </li> <li> </li> <li> def step(self): </li> <li> """Tournament evolution step.""" </li> <li> print('\nPopulation sample:') </li> <li> for i in range(0,self.population_sz,2): </li> <li> print(self.population[i]['nb_layers'], </li> <li> self.population[i]['layers'][0]['nb_units']) </li> <li> self.evaluate() </li> <li> children = [] </li> <li> print('\nPopulation mean:{} max:{}'.format( </li> <li> np.mean(self.evaluations), np.max(self.evaluations))) </li> <li> n_elite = 2 </li> <li> sorted_pop = np.argsort(self.evaluations)[::-1] </li> <li> elite = sorted_pop[:n_elite] </li> <li> </li> <li> # print top@n_elite scores </li> <li> # elites always included in the next population </li> <li> self.elite = [] </li> <li> print('\nTop performers:') </li> <li> for i,e in enumerate(elite): </li> <li> self.elite.append((self.evaluations[e], self.population[e])) </li> <li> print("{}-score:{}".format( str(i), self.evaluations[e])) </li> <li> children.append(self.population[e]) </li> <li> # tournament probabilities: </li> <li> # first p </li> <li> # second p*(1-p) </li> <li> # third p*((1-p)^2) </li> <li> # etc... </li> <li> p = 0.85 # winner probability </li> <li> tournament_size = 3 </li> <li> probs = [p*((1-p)**i) for i in range(tournament_size-1)] </li> <li> # a little trick to certify that probs is adding up to 1.0 </li> <li> probs.append(1-np.sum(probs)) </li> <li> </li> <li> while len(children) < self.population_sz: </li> <li> pop = range(len(self.population)) </li> <li> sel_k = random.sample(pop, k=tournament_size) </li> <li> fitness_k = list(np.array(self.evaluations)[sel_k]) </li> <li> selected = zip(sel_k, fitness_k) </li> <li> rank = sorted(selected, key=itemgetter(1), reverse=True) </li> <li> pick = np.random.choice(tournament_size, size=1, p=probs)[0] </li> <li> best = rank[pick][0] </li> <li> model = self.mutate_fn(self.population[best]) </li> <li> children.append(model) </li> <li> </li> <li> self.population = children </li> <li> </li> <li> # if we want to do a completely completely random search per epoch </li> <li> # self.population = [randomize_network(bounded=False) for i in range(self.population_sz) ] </li> <li> </li> <li> def evaluate(self): </li> <li> """evaluate the models.""" </li> <li> </li> <li> workerids = range(self.nb_workers) </li> <li> workerpool = Scheduler(workerids, self.use_cuda ) </li> <li> self.population, returns = workerpool.start(self.population) </li> <li> </li> <li> self.evaluations = returns </li> <li> self.stats.append(copy.deepcopy(returns)) </li> <li> self.history.append(copy.deepcopy(self.population)) </li> </ol>
“进化算法”看起来非常简单,对吗?没错!这个算法可以非常成功,尤其是当你为个体定义了好的变异或跨界功能时。
存储库中还包含了一些额外的使用类别,比如工作器类和调度器类,使GP优化器能够独立平行地完成模型训练和评估。
运行代码
按照上述步骤操作运行。
<ol> <li>"""Tournament play experiment.""" </li> <li>from __future__ import absolute_import </li> <li>import net_builder </li> <li>import gp </li> <li>import cPickle </li> <li># Use cuda ? </li> <li>CUDA_ = True </li> <li> </li> <li>if __name__=='__main__': </li> <li> # setup a tournament! </li> <li> nb_evolution_steps = 10 </li> <li> tournament = \ </li> <li> gp.TournamentOptimizer( </li> <li> population_sz=50, </li> <li> init_fn=net_builder.randomize_network, </li> <li> mutate_fn=net_builder.mutate_net, </li> <li> nb_workers=3, </li> <li> use_cuda=True) </li> <li> </li> <li> for i in range(nb_evolution_steps): </li> <li> print('\nEvolution step:{}'.format(i)) </li> <li> print('================') </li> <li> tournament.step() </li> <li> # keep track of the experiment results & corresponding architectures </li> <li> name = "tourney_{}".format(i) </li> <li> cPickle.dump(tournament.stats, open(name + '.stats','wb')) </li> <li> cPickle.dump(tournament.history, open(name +'.pop','wb')) </li> </ol>
接下来,让我们一起来看看运行的结果!
这是50个解决方案的得分结果,比赛规模为3。这些模型仅接受了10000个样本的训练,然后就被评估了。乍一看,进化算法似乎并没有起到太大的作用,因为解决方案在第一次进化中就已经接近最佳状态了;而在第七阶段,解决方案达到了它的最佳表现。在下图中,我们用了一个盒式图来依次描述这些解决方案的四分之一。我们发现,大多数方案都表现的很好,但在方案进化的同时,这个盒式图也随之紧缩了。
图中的这个盒子展示了方案的四分之一,而其盒须则延伸展示了剩余四分之三的方案分布。其中的黑点代表着方案的平均值,从图中我们会发现平均值的上升趋势。
为了进一步理解这一方法的性能和表现,我们最好将其与一个完全随机的种群搜做相比较。每个阶段之间都不需要进化,每个解决方案都要被重新设置为一个随机的状态。
在一个相对较小的(93.66% vs 93.22%)里进化算法的性能较好。而随机种群搜索似乎生成了一些好的解决方案,但模型的方差却大大增加了。这就意味着在搜索次优架构的时候出现了资源浪费。将这个与进化图相比较,我们会发现进化确实生成了更多有用的解决方案,它成功地使那些结构进化了,进而使之达到了更好的性能表现。
-
MNIST是一个相当简单的数据集,即使是单层网络也能达到很高的准确度。
-
像ADAM这样的优化器对学习率的敏感度比较低,只有在它们的网络具备足够的参数时,它们才能找到比较好的解决方案。
-
在训练过程中,模型只会查看10000个(训练总数据的1/5)样本示例。如果我们训练得时间再长一些,好的架构可能会达到更高的准确度。
-
限制样本数量对于我们学习的层的数量同样非常重要,越深层的模型需要越多样本。为了解决这个问题,我们还增加了一个移除突变层,使种群调节层的数量。
这个实验的规模还不足以突出这种方法的优势,这些文章中使用的实验规模更大,数据集也更复杂。
我们刚刚完成了一个简单的进化算法,这个算法很好地诠释了“物竞天择”的主题。我们的算法只会选择最终胜利的解决方案,然后将其变异来产生更多的后代。接下来,我们需要做的就是使用更先进的方法,生成和发展方案群。以下是一些改进的建议:
-
为通用层重新使用亲本的权重
-
将来自两个潜在亲本的层合并
-
架构不一定要连续的,你可以探索层与层之间更多不一样的联系(分散或合并等)
-
在顶部增加额外的层,然后进行微调整。
以上内容都是人工智能研究领域的一个课题。其中一个比较受欢迎的方法就是NEAT及其扩展。EAT变量使用进化算法在开发网络的同时,还对网络的权重进行了设置。在一个典型的强化学习场景下,代理权重的进化是非常有可能实现的。但是,当(x,y)输入对可用时,梯度下降的方法则表现得更好。
相关文章
Evolino: Hybrid Neuroevolution / Optimal Linear Search for Sequence Learning
Evolving Deep Neural Networks — This is a very interesting approach of co-evolving whole networks and blocks within the network, it’s very similar to the Evolino method but for CNNs.