Ottimizzare iperparametri in Keras con Hyperopt


Configurare i parametri di un modello di deep learning è sempre un'operazione a metà strada fra l'arte, l'esperienza, e la pura forza bruta di calcolo. In questo tutorial vediamo come utilizzare Hyperopt, una libreria di black-box optimization perfetta per ottimizzare iperparametri di ogni tipo affidandosi alle sue capacità di ricerca. Tra le sue caratteristiche principali, oltre ad essere altamente configurabile ha la possibilità di lanciare più simulazioni in parallelo appoggiandosi a MongoDB. Come caso d'uso useremo una semplice rete neurale in Keras, concentrandoci sopratutto sulla configurazione di Hyperopt.

Introduzione ad Hyperopt

Hyperopt è una libreria di black-box optimization, ovvero permette di ottimizzare qualsiasi funzione senza necessità di calcolarne i gradienti (come avviene per la maggior parte degli ottimizzatori in TensorFlow o altre librerie di deep learning). Nel machine learning, queste librerie sono molto utili per selezionare gli iperparametri dei nostri modelli, ottimizzando una qualche metrica di accuratezza calcolata su un dataset di validazione.

Abbiamo già introdotto l'ottimizzazione degli iperparametri parlando delle pipeline di scikit-learn. Oltre ad essere più configurabile delle funzioni contenute in scikit-learn, Hyperopt include un metodo di ricerca più sofisticato delle semplici grid search e random search, chiamato Tree-structured Parzen Estimator (TPE), che cerca dinamicamente di ridurre lo spazio dove ricercare le soluzioni ottimali per massimizzare il numero di chiamate alla funzione. Se siete interessati, qui trovate la descrizione originale dell'approccio.

Confronto tra random search e tree-structured Parzen estimator per ottimizzare gli iperparametri di un modello su CIFAR-10 ([Bergstra et al., 2013]).

Configurazione per il tutorial

Potete scaricare tutto il codice per questo tutorial in formato notebook su Google Colaboratory da questo link.

Per questo tutorial useremo Keras per allenare la rete neurale, Hyperopt per ottimizzarne gli iperparametri, e scikit-learn per caricare un dataset di prova. Per configurare il vostro ambiente virtuale con le ultimi versioni al momento in cui scriviamo lanciate da terminale:

pip install keras==2.1.6 hyperopt==0.1 scikit-learn==0.19.2

Se utilizzate Google Colaboratory, questa versione di Hyperopt va in conflitto con la versione installata di NetworkX (issue #357: TypeError: 'generator' object is not subscriptable). Se ottenete questo errore, effettuate un downgrade della seconda libreria e riavviate in seguito il runtime di Colab:

pip install networkx==1.11

Come dati useremo il wine dataset, un dataset molto semplice di classificazione di vini. Per completezza, di seguito il codice per caricare i dati, preprocessarli (normalizzazione dell'input e one-hot encoding dell'output) e dividere la parte di test:

from sklearn import datasets, preprocessing, model_selection
wine_dataset = datasets.load_wine()
X = preprocessing.MinMaxScaler().fit_transform(wine_dataset['data'])
y = preprocessing.OneHotEncoder(sparse=False).fit_transform(wine_dataset['target'].reshape(-1, 1))
Xtrain, Xtest, ytrain, ytest = model_selection.train_test_split(X, y, stratify=y)

Un esempio introduttivo

Per fare pratica con Hyperopt, cominciamo con qualcosa di semplice (un esempio simile lo trovate nella documentazione ufficiale):

import numpy as np
def simple_fcn(x):
  return - np.sin(x ** 2.0)/x + 0.01 * x ** 2.0

Funzione artificiale di prova

Il grande numero di minimi locali rende questa funzione piuttosto complessa per un ottimizzatore "classico", a meno di non inizializzarlo molto vicino al minimo globale. Per provare ad ottimizzarla con Hyperopt, iniziamo importando un po' di metodi che ci serviranno anche in seguito (non li useremo tutti subito):

from hyperopt import fmin, tpe, hp, Trials
from hyperopt import STATUS_OK

Con Hyperopt possiamo minimizzarla in una sola riga di codice:

best = fmin(fn=simple_fcn, space=hp.uniform('x', -10, 10), algo=tpe.suggest, max_evals=100)
print(best) # {'x': 1.0774531825730786}

Minimo della funzione artificiale di prova

Commentiamo brevemente i quattro parametri di fmin (ci ritorneremo in seguito):

  • fn è la funzione da ottimizzare. Non avendo bisogno di gradienti, può rappresentare praticamente qualsiasi cosa. Ad esempio, potremmo ottimizzare una funzione che prende in ingresso delle coordinate spaziali, ed interroga una API esterna per restituire la temperatura in quel punto preciso.

  • space è un parametro essenziale, che rappresenta la nostra intuizione su dove cercare l'ottimo. In questo caso, ad esempio, supponiamo che il minimo si trovi da qualche parte nell'intervallo $[-10, 10]$, con probabilità uniforme (hp.uniform).

  • algo è l'algoritmo di ricerca. Per ora Hyperopt supporta una random search ed il TPE, ma è teoricamente possibile estenderla anche ad altri algoritmi. Nel caso di TPE, lo spazio di ricerca originale verrà progressivamente raffinato per concentrarsi sulle regioni più promettenti.

  • max_evals, infine, è il numero di valutazioni di simple_fcn ammesse prima di ritornare il miglior risultato ottenuto fino a quel momento.

C'è veramente poco altro da sapere per poter cominciare ad utilizzare Hyperopt, quindi passiamo subito a qualcosa di più interessante.

Una rete neurale in Keras (che non funziona...)

Per cominciare importiamo un po' di oggetti da Keras:

from keras.models import Sequential
from keras.layers import Dense, Dropout
from keras.optimizers import Adam, SGD

La nostra rete neurale sarà per ora quanto di più basico si possa immaginare. Per comodità definiamo due funzioni: la prima, train_fcn per allenare il modello (una rete neurale con uno strato nascosto), e la seconda, test_fcn, per valutarne le prestazioni. Cominciamo dalla prima:

def train_fcn(features, labels, train_params):

  # Definizione del modello
  model = Sequential()
  model.add(Dense(units=int(train_params['layer_size']), activation='relu', input_shape=[13,]))
  model.add(Dense(units=3, activation='softmax'))

  # Ottimizzazione
  adam = Adam(lr=train_params['learning_rate'])
  model.compile(loss='categorical_crossentropy', optimizer=adam, metrics=['accuracy'])
  model.fit(features, labels, epochs=50, batch_size=32, verbose=0)

  return model

La funzione ha due iperparametri che passiamo tramite un dizionario, ovvero la dimensione dello strato nascosto della rete neurale, ed il learning rate di Adam. La funzione di test è altrettanto semplice:

def test_fcn(model, features, labels):
  test_accuracy = model.evaluate(features, labels)
  return test_accuracy

A questo punto è necessario scegliere i due iperparametri! Supponendo di saperne pochissimo di Adam, possiamo fare una prima prova:

default_params = {
    'layer_size': 5,
    'learning_rate': 1.0
}
model = train_fcn(Xtrain, ytrain, default_params)
print('Accuracy is: ', test_fcn(model, Xtest, ytest))

Accuracy is: [1.1551294565200805, 0.40000000331136915]

Il learning rate è chiaramente troppo alto e l'ottimizzazione diverge. Al posto di continuare a provare manualmente per cercare una soluzione ottimale, è tempo di passare ad Hyperopt!

Ottimizzazione con Hyperopt

Come prima cosa, definiamo uno spazio di ricerca per gli iperparametri. Oltre a hp.uniform, abbiamo una vasta scelta di configurazioni possibili, a seconda dell'iperparametro. Per ora ne introduciamo altri due:

  • hp.choice per selezionare layer_size in un intervallo discreto di scelte, ovvero nell'intervallo $[5, 10, \cdots, 25]$.

  • hp.loguniform per cercare il learning rate in un intervallo esponenziale $\exp(j)$, con $j \in [-10, 0]$. Questo perché iperparametri come il learning rate sono spesso poco sensibili a piccole variazioni e richiedono di valutare numerosi ordini di grandezza simultaneamente.

Unendo tutto quanto all'interno di un dizionario:

search_space = {
  'layer_size': hp.choice('layer_size', np.arange(5, 26, 5)),
  'learning_rate': hp.loguniform('learning_rate', -10, 0),
}

Per valutare l'accuratezza di ciascun scelta, andiamo a suddividere ulteriormente il nostro dataset (in questo caso una k-fold cross-validation sarebbe probabilmente più opportuna):

Xtrain, Xval, ytrain, yval = model_selection.train_test_split(Xtrain, ytrain, stratify=ytrain)

La funzione da ottimizzare è molto semplice: per ogni configurazione di iperparametri da provare allena un modello sulla parte rimanente di training, e ne valuta le prestazioni su quella di validazione:

from keras import backend as K
def hyperopt_fcn(params):
  model = train_fcn(Xtrain, ytrain, params)
  test_acc = test_fcn(model, Xval, yval)
  K.clear_session()
  return {'loss': -test_acc[1], 'status': STATUS_OK}

Qualche commento aggiuntivo:

  1. I parametri da testare vengono passati da Hyperopt in forma di dizionario (la stessa convenzione che abbiamo rispettato per la funzione di allenamento).
  2. K.clear_session() evita che i modelli di Keras si accumulino in memoria.
  3. Rispetto a prima, passiamo ad Hyperopt due informazioni distinte: il valore da minimizzare (l'opposto dell'accuratezza), ed uno "status" con valore OK. Questo valore è molto utile perché permette di distinguere configurazioni che danno vita ad errori e/o problemi numerici (come un learning rate molto alto).

L'ottimizzazione a questo punto è uguale a prima:

best = fmin(hyperopt_fcn, search_space, algo=tpe.suggest, max_evals=50)

Possiamo valutare la configurazione ottimale ritornata dall'algoritmo:

from hyperopt import space_eval
space_eval(search_space, best)

{'layer_size': 15, 'learning_rate': 0.0025886368288856416}

Possiamo quindi procedere ad una fase finale di training, usando gli iperparametri ottimali ma riunendo la componente di validazione a quella di training:

model = train_fcn(np.vstack((Xtrain, Xval)), np.vstack((ytrain, yval)), space_eval(search_space, best))
print('Accuracy is: ', test_fcn(model, Xtest, ytest))
# Accuracy is:  [0.15580001407199437, 1.0]

100% di accuratezza! Ovviamente i problemi reali non sono mai così semplici...

Nota a margine 1: distribuzioni di probabilità quantizzate

Nel caso di parametri discreti (come layer_size), hp.choice non è l'unica possibilità. In particolare, potremmo supporre che valori simili di layer_size portino a valori simili di accuratezza. Per includere questa assunzione nel processo di ricerca, possiamo usare al posto di hp.choice una distribuzione di probabilità "quantizzata", come ad esempio:

'layer_size': hp.quniform('layer_size', low=5, high=50, q=1)

Questa distribuzione contiene in maniera uniforme tutti i valori compresi fra low e high con passo q ma, a differenza di hp.choice, assume che ci sia una certa continuità in termini di funzione costo fra valori vicini (ed è quindi preferibile per iperparametri come layer_size).

Esistono versioni quantizzate anche per intervalli esponenziali (es., per selezionare un batch size) o normalmente distribuiti (es., per una variabile discreta che con molta probabilità si aggira attorno ad un certo valore) con cui sperimentare.

Nota a margine 2: MongoDB

Hyperopt permette di parallelizzare il processo di ricerca sfruttando MongoDB per salvare i risultati intermedi. Estendere questi esempi al caso di MongoDB è molto semplice, e a tal fine rimandiamo alla guida ufficiale se siete interessati.

Non siamo nulla senza debug

Non sapere nulla di cosa stia succedendo internamente ad fmin è chiaramente deludente. Per salvare tutti i risultati intermedi, in Hyperopt è sufficiente inizializzare un oggetto Trials da passare ad fmin:

trials = Trials()
best = fmin(hyperopt_fcn, search_space, algo=tpe.suggest, max_evals=50, trials=trials)

All'interno di trials abbiamo due proprietà utili. results mantiene una lista di tutti gli esperimenti insieme al loro risultato:

trials.results[0:2]

[{'loss': -0.4005882352941176, 'status': 'ok'}, {'loss': -0.9411764705882353, 'status': 'ok'}]

Se vogliamo informazioni più comprensive, trials permette di conoscere la configurazione testata, un timestamp dell'esperimento, ed eventualmente il worker nel caso di simulazioni parallele:

print(trials.trials[0])

{'book_time': datetime.datetime(2018, 7, 24, 11, 8, 13, 611000), 'exp_key': None, 'misc': {'cmd': ('domain_attachment', 'FMinIter_Domain'), 'idxs': {'layer_size': [0], 'learning_rate': [0]}, 'tid': 0, 'vals': {'layer_size': [27.0], 'learning_rate': [0.1390542781208916]}, 'workdir': None}, 'owner': None, 'refresh_time': datetime.datetime(2018, 7, 24, 11, 8, 14, 384000), 'result': {'loss': -0.9705882352941176, 'status': 'ok'}, 'spec': None, 'state': 2, 'tid': 0, 'version': 0}

Da questo oggetto possiamo ottenere moltissime informazioni utili. Prima di tutto, possiamo stampare l'accuratezza ottenuta da ciascun esperimento:

import matplotlib.pyplot as plt
plt.figure()
xs = [t['tid'] for t in trials.trials]
ys = [-t['result']['loss'] for t in trials.trials]
plt.xlim(xs[0]-1, xs[-1]+1)
plt.scatter(xs, ys, s=20, linewidth=0.01, alpha=0.75)
plt.xlabel('Iteration', fontsize=16)
plt.ylabel('Accuracy', fontsize=16)
plt.show()

Essendo il problema molto semplice, da subito alcuni tentativi raggiungono un'accuratezza molto elevata. Progressivamente poi tutte le simulazioni si concentrano in quella fascia, man mano che Hyperopt raffina le ipotesi su ciascun iperparametro. In maniera ancora più dettagliata, possiamo analizzare che accuratezza otteniamo a seconda di come scegliamo gli iperparametri individualmente:

Come si vede, in generale è necessario scegliere un learning rate sotto una certa soglia, mentre il modello è relativamente insensibile alla scelta della dimensione dello strato nascosto, in quanto anche pochissimi neuroni sono sufficienti a risolvere questo problema con grande accuratezza.

Nella nostra funzione possiamo ritornare qualsiasi oggetto desideriamo venga salvato da Hyperopt. Ad esempio, ecco una semplice modifica che permette di salvare il tempo di allenamento per ciascun modello:

from timeit import default_timer as timer
def hyperopt_fcn(params):

  # Calcola il tempo di training
  start = timer()
  model = train_fcn(Xtrain, ytrain, params)
  end = timer()

  test_acc = test_fcn(model, Xval, yval)
  K.clear_session()
  return {'loss': -test_acc[1], 'status': STATUS_OK, 'train_time': (end-start)}

Ancora più flessibilità: spazi di ricerca condizionali

Un aspetto interessante di Hyperopt è che, grazie ad hp.choice, possiamo creare spazi di ricerca condizionali o addirittura ricorsivi. Ad esempio, il nostro povero esperimento con il learning rate di prima potrebbe spingerci a voler provare anche altri algoritmi di ottimizzazione. Per cominciare, vediamo come definire uno spazio di ricerca per selezionare un algoritmo di ottimizzazione tra Adam ed una più semplice discesa al gradiente con momentum:

opt_search_space = hp.choice('name',
        [
        {'name': 'adam', 
            'learning_rate': hp.loguniform('learning_rate_adam', -10, 0), # Note the name of the label to avoid duplicates
        },
        {'name': 'sgd',
            'learning_rate': hp.loguniform('learning_rate_sgd', -15, 1), # Note the name of the label to avoid duplicates
            'momentum': hp.uniform('momentum', 0, 1.0),
        }
        ])}

Concentriamoci sulla chiamata di hp.choice: come prima, questa permette di selezionare fra due diverse alternative (contenute in una lista); diversamente da prima, però, ognuna di queste alternative è a sua volta un dizionario che definisce un diverso spazio di ricerca. In particolare, nel primo caso (adam), andiamo a scegliere solo un learning rate, mentre nel secondo caso (sgd), scegliamo sia un learning rate che un parametro di momentum.

Grazie alla possibilità di avere spazi di ricerca innestati fra di loro, possiamo includere questo oggetto nel nostro spazio di ricerca originario:

search_space = {
  'optimizer': opt_search_space,
  'layer_size': hp.choice('layer_size', np.arange(5, 26, 5))
}

Per avere un'idea più precisa di cosa faccia questo spazio di ricerca, andiamo a campionarlo qualche volta:

from hyperopt.pyll.stochastic import sample
for _ in range(3):
  print(sample(search_space))
# {'layer_size': 10, 'optimizer': {'learning_rate': 0.0004927227823704443, 'name': 'adam'}}
# {'layer_size': 5, 'optimizer': {'learning_rate': 0.00039102348250862214, 'momentum': 0.045526270930157486, 'name': 'sgd'}}
# {'layer_size': 15, 'optimizer': {'learning_rate': 0.00012154874605560738, 'name': 'adam'}}

Come si vede, 'optimizer' è ora a sua volta un dizionario che contiene tutti i parametri necessari ad istanziare un ottimizzatore. A questo punto possiamo divertirci a rendere il tutto quanto più parametrico possibile. Ad esempio, perché non provare ad aggiungere un secondo strato nascosto?

second_layer_search_space = \
  hp.choice('second_layer', 
    [
      {
        'include': False,
      },
      {
        'include': True,
        'layer_size': hp.choice('layer_size', np.arange(5, 26, 5)),
      }

  ])

Un altro iperparametro che potremmo configurare è il batch size. In questo caso, una convenzione tipica è di valutarlo su potenze di due crescenti, es., 2, 4, 8... Hyperopt non ha nessuna classe di default per fare questo, ma includere questo comportamento è facilissimo. In particolare, possiamo campionare in maniera uniforme l'esponente della potenza di due, e definire una funzione personalizzata per ottenere l'effettivo batch size (si noti come è necessario decorare la funzione con scope.define):

from hyperopt.pyll import scope
@scope.define
def power_of_two(a):
     return 2.0 ** a

Includiamo tutto nel nostro spazio di ricerca:

search_space = {
    'optimizer': opt_search_space,
    'second_layer': second_layer_search_space,
    'layer_1_size': hp.choice('layer_1_size', np.arange(5, 26, 5)),
    'batch_size': scope.power_of_two(hp.quniform('batch_size', 0, 8, q=1))
}

Modifichiamo la nostra funzione di training per prendere in considerazione tutti i nostri nuovi iperparametri:

def train_fcn(features, labels, params):

  model = Sequential()
  model.add(Dense(units=params['layer_1_size'], activation='relu', input_dim=13))

  # Aggiunge condizionalmente un secondo strato nascosto
  if params['second_layer']['include']:
    model.add(Dense(units=params['second_layer']['layer_size'], activation='relu'))

  model.add(Dense(units=3, activation='softmax'))

  # Seleziona l'ottimizzatore corretto
  if params['optimizer']['name'] == 'adam':
    opt = Adam(lr=params['optimizer']['learning_rate'])
  else:
    opt = SGD(lr=params['optimizer']['learning_rate'], momentum=params['optimizer']['momentum'])

  model.compile(loss='categorical_crossentropy', optimizer=opt, metrics=['accuracy'])

  # Anche il batch size ora è un iperparametro
  model.fit(features, labels, epochs=50, batch_size=int(params['batch_size']), verbose=0)

  return model 

L'allenamento è uguale ai casi precedenti!

Qualche parola conclusiva

Ovviamente Hyperopt non è l'unica scelta disponibile per l'ottimizzazione degli iperparametri, ed esistono numerose altre librerie che potreste voler esplorare a partire da qui. Fra queste vale la pena ricordare Spearmint (tra le migliori basate sull'ottimizzazione Bayesiana), e skopt, una libreria ancora in forte sviluppo con un larghissimo numero di algoritmi disponibili, oltre ovviamente a librerie di hyperparameter tuning disponibili sulle varie piattaforme cloud come Amazon SageMaker. Non mancheremo di esplorare anche queste librerie prossimamente.


Se questo articolo ti è piaciuto e vuoi tenerti aggiornato sulle nostre attività, ricordati che l'iscrizione all'Italian Association for Machine Learning è gratuita! Puoi seguirci anche su Facebook e su LinkedIn.

Previous Post