Grafi dinamici in TensorFlow con TF Eager (Tutorial)


Per chi è appassionato di deep learning, uno dei problemi principali (paradossalmente) è diventato districarsi nell'enormità di software disponibili. Fra le alternative più comuni troviamo sicuramente TensorFlow, rilasciato in open-source da Google nel 2015, e PyTorch, rilasciato poco più di un anno dopo da Facebook.

A prima vista, entrambi i framework sono molto simili, dando la possibilità di sviluppare modelli enormemente complessi automatizzandone la fase di training, oltre a semplificare il supporto verso la GPU, il caricamento dei dati, ed altre funzioni comuni. A livello commerciale, TensorFlow ha subito una crescita enorme nel periodo successivo al suo rilascio, diventando il software più usato in assoluto, ma già ad Aprile 2017 i suoi concorrenti come PyTorch erano in forte ascesa, mettendone in discussione la sua posizione da leader (da un tweet di François Chollet):

Deep learning software adoption

Se i due framework sono così simili, come spiegare questi rapidi cambi? La differenza principale fra i due framework si riassume forse nella maggiore semplicità di utilizzo (e di debug) di PyTorch, che risulta molto più intuitivo per chi si avvicina al mondo del deep learning per la prima volta, o per chi ha alle spalle esperienza di NumPy (o R).

In questo tutorial scopriamo insieme TensorFlow Eager Execution, una nuova modalità di TensorFlow per ora ancora in alpha (annunciata ufficialmente da Google solo poche settimane fa), che promette di rimuovere questa differenza semplificando di molto l'utilizzo di TensorFlow, rendondolo potenzialmente più appetibile anche a chi per ora gli preferisce framework alternativi.

TensorFlow vs. PyTorch vs. TF Eager

Tra i tanti motivi che influiscono sulla scelta di un software di deep learning, il più importante in questo caso (come già detto) è probabilmente la semplicità di sviluppo: creare e testare modelli in PyTorch è, oggettivamente, molto più semplice che in TensorFlow, e si discosta poco dallo sviluppare nativamente in NumPy o librerie simili. Questo per precise scelte di progettazione, che prendono origine storicamente dai primi software sviluppati nel campo, tra cui in particolare Theano.

Per capirlo, prendiamo uno degli esempi più semplici possibili in TensorFlow, la somma di due numeri:

import tensorflow as tf
c = tf.constant(3.0) + tf.constant(2.0)
print(c)
# Returns: Tensor("add:0", shape=(), dtype=float32)

Poiché TF separa la fase di definizione dalla fase di esecuzione, le uniche cose che possiamo conoscere della variabile c a questo punto sono il suo tipo (float32) e la sua dimensione (scalare). Per ottenerne il valore, dobbiamo eseguire l'operazione all'interno di una sessione:

with tf.Session() as sess:
    print(sess.run(c))

Anche se questo sembra innocuo, può diventare un problema appena inseriamo calcoli intermedi nel nostro grafo:

a = tf.constant(3.0)
b = a + 2.0
with tf.Session() as sess:
    c = sess.run(1.5*b)

Anche se il valore intermedio (b) viene calcolato da TF, non abbiamo modo di visualizzarlo a meno di non chiederlo esplicitamente all'interno di una sessione (es., con un'operazione aggiuntiva per stampare valori). Confrontiamo questo con il codice equivalente in PyTorch:

import torch
a = torch.ones(3.0)
b = a + 2.0
print(1.5*b)
# Returns: 7.5000 [torch.FloatTensor of size 1]
print(b)
# Returns: 5.0 [torch.FloatTensor of size 1]

A differenza di TF, tutti i valori sono calcolati una volta definiti e possono essere visualizzati immediatamente, come succede in NumPy. Questa differenza, da sola, fa sì che sviluppare e testare codice in PyTorch è enormemente più semplice (soprattutto per chi ha minore esperienza con altri framework) appena il grafo computazionale diventa sufficientemente complesso. Nel gergo di un altro competitor di TF, Chainer, i framework come TF seguono un modello define-and-run, mentre quelli come PyTorch un modello define-by-run, ovvero molto più dinamico:

Esempio di grafo computazionale dinamico in PyTorch

Adesso vediamo come cambia il codice di TF se abilitiamo la modalità eager:

a = tf.constant(3.0)
b = a + 2.0
print(1.5*b)
# Returns: tf.Tensor(7.5, shape=(), dtype=float32)
print(b)
# Returns: tf.Tensor(5.0, shape=(), dtype=float32)

Equivalente a PyTorch! La possibilità di definire grafi dinamicamente è stata da subito una delle feature più richieste al team di sviluppo di Google, e la Eager Execution (seppure ancora in alpha) sembra promettere bene. Vediamo rapidamente come installarla ed abilitarla prima di passare ad un esempio più concreto.

Installare ed abilitare TF Eager Execution

Poiché Eager è ancora in alpha, per utilizzarlo è necessario compilare TF da sorgente, o installare la nightly build. Per chi non ha esperienza con la compilazione di TF, la seconda opzione è più comoda; ad esempio, per installarla con pip è sufficiente digitare a riga di comando (in un ambiente dove TF non è ancora installato):

pip install tf-nightly

Sostituite tf-nightly con tf-nightly-gpu se volete la versione con supporto GPU. È anche disponibile una immagine docker.

Una volta installato, importate il modulo eager ed abilitate la modalità dinamica come segue:

import tensorflow as tf
import tensorflow.contrib.eager as tfe
tfe.enable_eager_execution()

Due commenti vanno fatti subito:

  • Essendo in alpha, è possibile che la sintassi cambierà (anche in maniera significativa) da qui al rilascio ufficiale. Dai commenti su un thread Reddit si lascia intendere però che non ci dovrebbero essere grandi cambiamenti (se non in velocità), e che l'interfaccia non si stabilizzerà prima della versione 1.6 di TF.
  • La modalità eager va abilitata all'inizio del programma; se avete già costrutti TF nel kernel, il metodo 'enable_eager_execution' ritornerà un errore.

Vediamo ora un esempio più concreto di utilizzo di TF Eager.

Regressione logistica con Eager

Come demo, nulla di più semplice che una regressione logistica su Iris! In questa parte assumiamo che abbiate già seguito il tutorial di base di TF, in particolare per la definizione del modello di regressione logistica, e vediamo come allenare un modello simile ma utilizzando la eager execution, commentando le differenze principali.

Cominciamo caricando il dataset (nella versione presente su scikit-learn), applicando qualche step di preprocessing agli input ed agli output:

from sklearn import datasets, preprocessing, model_selection
data = datasets.load_iris()
X = preprocessing.MinMaxScaler(feature_range=(-1,+1))\
        .fit_transform(data['data'])
y = preprocessing.OneHotEncoder(sparse=False)\
        .fit_transform(data['target'].reshape(-1, 1))

Per questo post ci focalizziamo su Eager, quindi non dividiamo i dati di test e ci concentriamo solo sulla fase di ottimizzazione. Per definire il modello, utilizziamo i layer di TF, che internamente richiamano i moduli di Keras:

lin = tf.layers.Dense(units=3, use_bias=True, activation=None)

Possiamo subito definire la nostra funzione costo, prendendo direttamente le funzioni dai moduli di TF:

def loss_fn(xb, yb, lin):
  predictions = lin(xb)
  return tf.reduce_mean(
      tf.nn.softmax_cross_entropy_with_logits_v2(
          logits=predictions, labels=yb))

Poiché stiamo lavorando in maniera dinamica, però, possiamo valutare subito la funzione costo senza bisogno di inizializzare una sessione o le variabili:

print(loss_fn(tf.constant(X), tf.constant(y), lin))
# Returns: <tf.Tensor: id=213049, shape=(), dtype=float64, numpy=1.9096685348363727>

Si noti come possiamo convertire da NumPy a tensori di TF in modo immediato. Una volta valutata la funzione costo per la prima volta, TF permette di accedere a tutte le variabili del modello per stamparne il valore:

w, b = lin.variables
print("w: " + str(w.read_value()))
print("b: " + str(b.read_value()))

Per ottimizzare il modello, abbiamo bisogno di una funzione che calcoli il gradiente del modello ad ogni passo, ed un'altra funzione che applichi gli aggiornamenti alle variabili. Il primo step in TF Eager è automatizzato da una singola funzione:

value_and_gradients_fn = tfe.implicit_value_and_gradients(loss_fn)

La funzione così ottenuta permette di calcolare automaticamente il valore (ed i gradienti) di tutte le variabili nel modello. Per altri casi d'uso (es., il gradiente di una singola variabile o gradienti custom), rimandiamo alla documentazione ufficiale. Come algoritmo di ottimizzazione, possiamo usare qualsiasi routine già definita in TF:

optimizer = tf.train.GradientDescentOptimizer(learning_rate=0.1)

Siamo pronti ad allenare il modello!

import numpy as np
iters = 1000
loss = np.zeros(iters)
for i in range(iters):
    # Calcola la funzione costo ed i gradienti
    current_loss, gradients_and_variables = value_and_gradients_fn(X, y, lin)
    # Applica l'update
    optimizer.apply_gradients(gradients_and_variables)
    # Salva il valore della funzione costo
    loss[i] = current_loss.numpy()

Per verificare che tutto stia funzionando come desiderato, stampiamo l'evoluzione della funzione costo per visualizzarne la convergenza:

Funzione costo

Limitazioni di Eager

In questo tutorial abbiamo visto solo qualche elemento base di Eager. Se siete interessati, vi invitiamo a leggere la guida ufficiale per scoprire come strutturare modelli più sofisticati ed abilitare il supporto su GPU (che, a differenza di TF classico, è disabilitato di default).

Chiaramente, come tutti i moduli in alpha la eager execution ha numerosi bug ancora irrisolti, oltre ad un'interfaccia per ora ancora volatile e soggetta a possibili cambiamenti repentini. Nonostante questo, dai primi test semplifica enormemente lo sviluppo in TF, senza andare ad intaccare (se non sommariamente) la velocità di esecuzione. Crediamo possa quindi diventare un componente essenziale nell'immediato futuro, e vale la pena farci pratica da subito.


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 Next Post