Introduction to Deep Learning

Programmazione funzionale per il deep learning

Autore: Joyce Xu

Traduttrice: Sabrina Sala

 

Fino a poco tempo fa il concetto di “programmazione funzionale” e “machine learning” erano attribuiti a due mondi nettamente separati. Il primo è un paradigma di programmazione che ha guadagnato popolarità nel momento in cui si il mondo si è rivolto alla semplicità ed alla componibilità per produrre pplicazioni scalabili complesse; il secondo è uno strumento utilizzato per istruire un computer come un completamento automatico di scarabocchi e a comporre musica. Non vi era quindi dialogo tra questi due elementi.

Ma studiandoli ed approfondendoli con attenzione ci si rende conto che la loro sovrapposizione è sia pratica sia teorica. Innanzitutto, il machine learning non è un elemento a sé stante ma piuttosto, per essere sfruttato, deve essere incorporato in applicazioni scalabili complesse. In secondo luogo, il machine learning, e in particolare il deep learning, è funzionale nel suo design:

  • I modelli di deep learning sono componibili: la programmazione funzionale riguarda comporre catene di funzioni di ordine superiore per operare su semplici strutture di dati. Le reti neurali sono disegnate nella stessa maniera, concatenando funzioni di trasformazione da uno strato a quello successivo, per operare su una semplice matrice di dati di input. Infatti l’intero processo di deep learning può essere visto come l’ottimizzazione di un set di funzioni composte. Ciò comporta che i modelli in sé siano intrinsecamente funzionali.

 

  • Le componenti di deep learning sono immutabili: quando applichiamo le funzioni sui dati di input, i nostri dati non cambiano ma, piuttosto, viene prodotto un nuovo set di valori. In aggiunta, quando i pesi sono aggiornati, non hanno bisogno di essere “mutati” ma rimpiazzi da un nuovo valore. In teoria l’aggiornamento dei pesi può essere applicato in qualsiasi ordine (ovvero non sono dipendenti l’uno dall’altro) e non c’è quindi necessità di tenere traccia in maniera sequenziale del relativo cambiamento.

 

  • La programmazione funzionale offre facilità nella parallelizzazione. Ancor più importante, però, è che le funzioni che sono totalmente componibili sono facili da parallelizzare. Ciò comporta maggiori velocità e potenza computazionale. La programmazione funzionale fornisce concurrency e parallelismo a costi quasi pari a zero, rendendo così più semplice lavorare in apprendimento profondo con modelli larghi e distribuiti.

Ci sono differenti teorie e prospettiva riguardanti la combinazione tra programmazione funzionale e deep learning, sia dal punto di vista matematico sia da quello pratico, ma tuttavia alcune volte è più semplice vederla in maniera pratica. In questo articolo andremo a introdurre le idee alla base della programmazione funzionale e di come applicarle in un modello Cortex di deep learning per la classificazione dei valori anomali.

Le basi di Clojure

Prima di continuare con il tutorial su Cortex, introduciamo alcune nozioni di base in merito a Clojure. Clojure è un linguaggio di programmazione funzionale ottimo per concurrency e data processing. Per nostra fortuna, questi sono entrambi estremamente utili nel machine learning. Infatti la ragione primaria per cui utilizziamo Clojure per il machine learning è che il lavoro di preparazione dei dataset di allenamento (manipolazione dei dati, processing ecc.) può facilmente compensare il lavoro di implementazione degli algoritmi, specialmente quando si hanno librerie solide come Cortex.  Utilizzare Clojure e .edn (piuttosto che C++ e protobuf) ci da un vantaggio in termini di velocità su progetti di machine learning.

Potete trovare un’introduzione più approfondita sul linguaggio qui.

Partiamo con le basi: il codice di Clojure è formato da un insieme di espressioni. Queste sono racchiuse in parentesi e tipicamente trattate come funzioni di chiamata.

(+ 2 3)          ; => 5
(if false 1 0)   ; => 0

Ci sono 4 strutture di dati base: vettori, liste, hash-map e set. Le virgole sono considerate come spazi bianchi, quindi vengono solitamente omesse.

[1 2 3]            ; vector (ordered)
‘(1 2 3)           ; lists (ordered)
{:a 1 :b 2 :c 3}   ; hashmap or map (unrdered)
#{1 2 3}           ; set (unordered, unique values)

Il virgolettato singolo, che precede la lista, è unicamente uno strumento per far sì che essa non venga rilevata come espressione.

Clojure ha anche molte funzioni integrate per funzionare su queste strutture di dati. Parte del fascino di Clojure sta nel suo design ricco di funzioni per poche tipologie di dati molto ridotti, il che si contrappone alla comune prassi di avere poche funzioni specializzate per il maggior numero possibile di strutture dati. Clojure, essendo un linguaggio di functional programming, supporta funzioni di alto livello, il che significa che le funzioni possono essere importate come argomento su altre funzioni.

(count [a b c])              ; => 3

(range 5)                    ; => (0 1 2 3 4)

(take 2 (drop 5 (range 10))) ; => (5 6)

(:b {:a 1 :b 2 :c 3})        ; use keyword as function => 2

(map inc [1 2 3])            ; map and increment => (2 3 4)

(filter even? (range 5))     ; filter collection based off predicate => (0 2 4)

(reduce + [1 2 3 4])         ; apply + to first two elements, then apply + to that result and the 3rd element, and so forth => 10

Potremmo ovviamente scrivere le nostre funzioni in Clojure utilizzando defn. La definizione delle funzioni di Clojure seguono la forma (defn fn-name [params*] expressions) e inoltre restituiscono il valore dell’ultima espressione nel corpo.

[x]
(+ x 2))(add2 5)     ; => 7

Le espressioni “let” creano e associano le variabili all’interno dello scope lessicale, definito lexical scope, di “let”. Ciò viene fatto nell’espressione (let [a 4] (…)), in cui la variabile “a” presenta un valore di 4 solamente nelle parentesi interne. Queste variabili sono chiamate “locali”.

(defn square-and-add
[a b]
(let [a-squared (* a a)
b-squared (* b b)]
(+ a-squared b-squared)))

(square-and-add 3 4)       ; => 225

Abbiamo, infine, due modi per creare funzioni “anonime”, che possono essere assegnate a una funzionale locale o ad una di ordine superiore.

(fn [x] (* 5 x))          ; anonymous function#(* 5 %)                  ; equivalent anonymous function, where the % represents the function’s argument(map #(* 5 %) [1 2 3])    ; => (5 10 15)

Questo è tutto per le informazione di base. Ora che abbiamo imparato qualche nozione su Clojure, torniamo al machine learning.

Cortex

Cortex è scritta in Clojure, ed è attualmente una delle librerie di machine learning più vaste e in rapida crescita, che utilizza un linguaggio di programmazione funzionale. Il resto dell’articolo si focalizzerà su come costruire un modello di classificazione in Cortex, insieme ai Paradigmi di programmazione funzionale e le tecniche di arricchimento dei dati (data augmentation) richieste.

Data preprocessing

Prendiamo un dataset di frodi legate a carte di credito, fornito da questo sito. Queste dataset è molto sbilanciato, per il fatto che container solo 492 casi di fronde positivi su un totale di 248’807, in pratica lo 0,172%. Ciò ci creerà dei problemi ma, per ora, limitiamoci a guardare i dati e a vedere il funzionamento del modello.

Per assicurare l’anonimità dei dati personali, tutte le caratteristiche originarie, eccetto “time” e “amount”, sono già state trasformate in componenti principali, o PCA (dove ogni entry rappresenta una nuova variabile che contiene le informazioni più rilevanti dei dati grezzi). Una breve occhiata ai dati ci mostra che la prima variabile “time” ha un limitato contenuto informativo, quindi la lasciamo da parte. Di seguito vediamo come appare il nostro gruppo di codici:

 

(ns fraud-detection.core

(:require [clojure.java.io :as io]

[clojure.string :as string] [clojure.data.csv :as csv] [clojure.core.matrix :as mat] [clojure.core.matrix.stats :as matstats]

[cortex.nn.layers :as layers]

[cortex.nn.network :as network]

[cortex.nn.execute :as execute]

[cortex.optimize.adadelta :as adadelta]

[cortex.optimize.adam :as adam]

[cortex.metrics :as metrics]

[cortex.util :as util]

[cortex.experiment.util :as experiment-util]

[cortex.experiment.train :as experiment-train]))

(def orig-data-file “resources/creditcard.csv”)

(def log-file “training.log”)

(def network-file “trained-network.nippy”)

;; Read input csv and create a vector of maps {:data […] :label [..]},

;; where each map represents one training instance in the data

(defonce create-dataset

(memoize

(fn []

(let [credit-data (with-open [infile (io/reader orig-data-file)]

(rest (doall (csv/read-csv infile))))

data (mapv #(mapv read-string %) (map #(drop 1 %) (map drop-last credit-data))) ; drop label and time

labels (mapv #(util/idx->one-hot (read-string %) 2) (map last credit-data))

dataset (mapv (fn [d l] {:data d :label l}) data labels)]

dataset))))

 

Le reti neurali di Cortex utilizzano dati di input in forma di mappe, dove ogni mappa rappresenta un singolo dato con i relativi label associati (per esempio, se ho l’immagine di un cane, il label sarà “cane”). Una classificazione, per esempio, può apparire come [{:data [12 10 38] :label “cat”} {:data [20 39 3] :label “dog“} … ]

Nella nostra funzione per la creazione di dataset ad hoc, vediamo che nel data file di formato comma-separated value, tutte le colonne a parte l’ultima formano i nostri “data” (o caratteristiche), mentre l’ultima colonna rappresenta il label, ovvero l’etichetta. Nel frattempo, trasformiamo i label in one-hot vector (per esempio [0 1 0 0]) basati sulla classe di classificazione. Ciò perché l’ultimo strato soft max nella nostra rete neurale produce un vettore di probabilità di classe, e non il label stesso. Infine, creiamo la mappa da queste due variabili e la salviamo come dataset.

Descrizione del modello

Creare un modello in Cortex è un’operazione piuttosto semplice e diretta. Prima di tutto dobbiamo definire una mappa di parametri superiori che verrà usata successivamente durante l’allenamento. In seguito, per definire il modello, uniamo semplicemente gli strati:

 

(def params

{:test-ds-size      50000 ;; total = 284807, test-ds ~= 17.5%

:optimizer         (adam/adam)   ;; alternately, (adadelta/adadelta)

:batch-size        100

:epoch-count       50

:epoch-size        200000})

(def network-description

[(layers/input (count (:data (first (create-dataset)))) 1 1 :id :data) ;width, height, channels, args

(layers/linear->relu 20) ; num-output & args

(layers/dropout 0.9)

(layers/linear->relu 10)

(layers/linear 2)

(layers/softmax :id :label)])

Dove network-description è un vettore di strati di rete neurale. Il nostro modello consiste in:

  • Uno strato di input
  • Uno strato completamente connesso (e lineare) con la funzione di attivazione ReLu
  • Un layer dropout
  • Un altro strano completamente connesso con ReLu
  • Un strato di output di dimensione 2 , passato attraverso la funzione softmax

Nel primo e ultimo strato, dobbiamo specificare un :id. questo si riferisce alla chiave nella mappa dei dati a cui la nostra rete dovrebbe guardare. Ricordiamo che la mappa risulta come {:data […] :label […]}). Per il nostro strato di input, passiamo in :data id affinché il modello prenda i dati di allenamento nei passaggi successivi. Nello strato finale forniamo, invece, :label come :id, così che possiamo utilizzare il vero label per calcolare il nostro errore.

Allenamento e valutazione

Da qui in poi diventa più complesso. La funzione di allenamento non è in realtà così complicata: Cortex da infatti funzioni preistruite per l’allenamento di alto livello, quindi tutto ciò che dobbiamo fare è settare nostri parametri (la rete, dataset di allenamento e verifica ecc). L’unico avvertimento che vi diamo è che il sistema si aspetta un “infinito” dataset per l’allenamento. tuttavia, Cortex  presenta una funzione infinite-class-balanced-dataset che ci aiuta a trasformarlo.

(defn train

“Trains network for :epoch-count number of epochs”

[]

(let [network (network/linear-network network-description)

[train-orig test-ds] (get-train-test-dataset)

train-ds (experiment-util/infinite-class-balanced-dataset train-orig

:class-key :label

:epoch-size (:epoch-size params))]

(experiment-train/train-n network train-ds test-ds

:batch-size (:batch-size params)

:epoch-count (:epoch-count params)

:optimizer (:optimizer params)

:test-fn f1-test-fn)))

Quindi arriviamo alla parte complessa: f1-test-fn. Durante l’allenamento, infatti, la funzione train-n si aspetta che le venga fornito un :test-fn che ne verifichi la performance e che determini se debba essere salvata come “best network”. È presente una funzione test di default che individua la perdita cross-entropica (cross-entropy loss)ma il suo valore di perdita non è così semplice da interpretare e, inoltre, non si adatta perfettamente il nostro dataset poco bilanciato. Per risolvere questo problema, andremo a scrivere una nostra personale funzione test.

Ma sorge ora una domanda: come possiamo verificare le performance del nostro modello? La metrica standard nei task di classificazione è solitamente l’accuratezza ma, con il nostro dataset sbilanciato, questa misura è pressoché inutile. Poiché gli esempi positivi contano solo per lo 0,172% del dataset totale, anche un modello che si limiti a prevedere esempi negativi, potrebbe raggiungere il 99.828% di accuratezza. Una percentuale particolarmente elevata, ma nel caso in cui questo modello venisse utilizzato nella realtà, sorgerebbero diversi problemi.

Un migliore gruppo di metriche è quindi: precisione, recupero, e F1 score (o genericamente F-beta).

1 1

1 1

La precisione ci pone quindi di fronte a una domanda: “di tutti gli esempi che ho previsto essere positivi, quale proporzione è di fatto positiva?” Mentre per il recupero ci chiediamo: “di tutti gli esempi  positivi, quale proporzione ho previsto con esattezza essere positiva?”
Visualizzazione di precisione e recupero. Fonte https://en.wikipedia.org/wiki/Precision_and_recall

Il F-beta score (una generalizzazione del tradizionale F1 score) è una media ponderata di precisione e recupero, misurata in una scala da 0 a 1:

2.jpg 2

Quando beta = 1, otteniamo F1 come of 2 * (precisione * recupero) / (precisione + recupero). Solitamente beta rappresenta quante volte recupero debba essere più importante di precisione. Nel nostro modello utilizziamo il F1 score come nostro punteggio da ben approssimare ma registriamo anche i punteggi di precisione e recupero per rilevare il bilanciamento. Questo è il nostro f1-test-fn:

 

(defn f-beta

“F-beta score, default uses F1”

([precision recall] (f-beta precision recall 1))

([precision recall beta]

(let [beta-squared (* beta beta)]

(* (+ 1 beta-squared)

(try                         ;; catch divide by 0 errors

(/ (* precision recall)

(+ (* beta-squared precision) recall))

(catch ArithmeticException e

0))))))

(defn f1-test-fn

“Test function that takes in two map arguments, global info and local epoch info.

Compares F1 score of current network to that of the previous network,

and returns map:

{:best-network? boolean

:network (assoc new-network :evaluation-score-to-compare)}”

[;; global arguments

{:keys [batch-size context]}

;per-epoch arguments

{:keys [new-network old-network test-ds]} ]

(let [batch-size (long batch-size)

test-results (execute/run new-network test-ds

:batch-size batch-size

:loss-outputs? true

:context context)

;;; test metrics

test-actual (mapv #(vec->label [0.0 1.0] %) (map :label test-ds))

test-pred (mapv #(vec->label [0.0 1.0] % [1 0.9]) (map :label test-results))

precision (metrics/precision test-actual test-pred)

recall (metrics/recall test-actual test-pred)

f-beta (f-beta precision recall)

;; if current f-beta higher than the old network’s, current is best network

best-network? (or (nil? (get old-network :cv-score))

(> f-beta (get old-network :cv-score)))

updated-network (assoc new-network :cv-score f-beta)

epoch (get new-network :epoch-count)]

(experiment-train/save-network updated-network network-file)

(log (str “Epoch: ” epoch “\n”

“Precision: ” precision  “\n”

“Recall: ” recall “\n”

“F1: ” f-beta “\n\n”))

{:best-network? best-network?

:network updated-network}))

La funzione esegue la rete sul test set, calcola il F1 score e, infine, aggiorna e salva la rete di conseguenza. Inoltre, stampa ognuna delle nostre metriche di valutazione ad ogni punto. Se ora eseguissimo il REPL, avremmo uno score che sarebbe all’incirca così:

Epoch: 30
Precision: 0.2515923566878981
Recall: 0.9186046511627907
F1: 0.395

Un risultato piuttosto scarso.

Arricchimento dei dati (Data Augmentation)

Eccoci arrivati al problema di cui abbiamo parlato all’inizio di questo articolo, dovuto allo sbilanciamento del dataset. Il modello non presenta ora sufficienti esempi positivi da cui apprendere. Quando richiamiamo experiment-util/infinite-class-balanced-dataset nella nostra funzione di allenamento, stiamo di fatto creando centinaia di copie di ogni istanza di allenamento positiva per bilanciare il nostro dataset. Ne risulta che il modello memorizza quei valori di caratteristiche, piuttosto che apprendere la distinzione tra le classi.

Un modo per ovviare a questo problema è utilizzare l’arricchimento dei dati, con il quale generi amo dati aggiuntivi e artificiale basati sugli esempi che già abbiamo disponibili. Per creare degli esempi di allenamento che siano positivi, dobbiamo aggiungere una qualsiasi quantità di rumore ai delle features vectors (vettori contenenti le caratteristiche) per ogni esempio positivo esistente. La quantità di rumore da noi aggiunta dipenderà dalla varianza di ogni caratteristica nelle classi positive, in modo che le feature con un’ampia varianza siano arricchite con una grande quantità di rumore, e l’opposto per quelle con bassa varianza.

Di seguito il codice per l’arricchimento dei dati:

(defonce get-scaled-variances

(memoize

(fn []

(let [{positives true negatives false} (group-by #(= (:label %) [0.0 1.0]) (create-dataset))

pos-data (mat/matrix (map #(:data %) positives))

variances (mat/matrix (map #(matstats/variance %) (mat/columns pos-data)))

scaled-vars (mat/mul (/ 5000 (mat/length variances)) variances)]

scaled-vars))))

(defn add-rand-variance

“Given vector v, add random vector based off the variance of each feature”

[v scaled-vars]

(let [randv (map #(- (* 2 (rand %)) %) scaled-vars)]

(mapv + v randv)))

(defn augment-train-ds

“Takes train dataset and augments positive examples to reach 50/50 balance”

[orig-train]

(let [{train-pos true train-neg false} (group-by #(= (:label %) [0.0 1.0]) orig-train)

pos-data (map #(:data %) train-pos)

num-augments (- (count train-neg) (count train-pos))

augments-per-sample (int (/ num-augments (count train-pos)))

augmented-data (apply concat (repeatedly augments-per-sample

#(mapv (fn [p] (add-rand-variance p (get-scaled-variances))) pos-data)))

augmented-ds (mapv (fn [d] {:data d :label [0 1]}) augmented-data)]

(shuffle (concat orig-train augmented-ds))))

augment-train-ds prende il nostro dataset di allenamento originario, calcola il numero di arricchimenti necessario per raggiungere un bilanciamento di classe 50/50, e applica poi questi arricchimenti ai nostri esempi esistenti attraverso un qualsiasi vettore di rumore (add-rand-variance) basato sulla varianza consentita (get-scaled-variances). Infine, concateniamo gli esempi arricchiti con il dataset originario ed otteniamo un dataset bilanciato.

Durante l’allenamento, il modello rileva una quantità irrealisticamente ampia di esempi positivi, mentre il set di verifica rimarrà positivo al 0.172%. come risultato, nonostante il modello potrà essere in grado di apprendere in modo migliore la differenza tra le due classi, comunque overfitteremo la nostra classe con di esempi positivi. Per risolvere tale problema, possiamo cambiare le relative soglie in modo da forzare la predizione positiva durante il testing. In altre parole, piuttosto che richiedere al modello almeno il 50% di certezza della positività degli esempi per classificarli come tali, possiamo aumentare la richiesta ad almeno il 70%. Dopo alcuni test, abbiamo notato che la soglia minima dovrebbe essere impostata al 90%. Il codice può essere trovato in  vec->labelfunction nel codice sorgente, ed è richiamato alla linea 31 del f1-test-fn.

Usando il nuovo e arricchito dataset per l’allenamento, i nostri scores si presentano all’incirca così:

Epoch: 25
Precision: 0.8658536585365854
Recall: 0.8255813953488372
F1: 0.8452380952380953

Risultati nettamente migliori rispetto a quelli precedenti.

Conclusioni

Questo modello può ovviamente essere ancora migliorato. Di seguito, alcuni consigli:

  • Tutte le PCA feature sono informative? Osservate la distribuzione dei valori per gli esempi positivi e negativi attraverso le feature e scartate ogni feature che non aiuta a distinguere tra le due classi
  • Vi sono altre architetture di reti neurali, funzioni di attivazione ecc che performano meglio?
  • Vi sono diverse tecniche di arricchimento dei dati che potrebbero performare meglio?
  • Come si rapporta la performance del modello in Cortex rispetto a Keras/Tensorflow/Theano/Caffe?

L’intero codice sorgente per questo progetto può essere trovato qui. Vi incoraggiamo a sperimentare gli step successivi, ad esplorare differenti architetture di reti (qui c’è un ottimo esempio di classificazione di immagini su CNN che potete prendere come riferimento).

 

0 commenti

Lascia un Commento

Vuoi partecipare alla discussione?
Fornisci il tuo contributo!

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *