Tipi Enumerati (enum class) e Upcasting
enum classdynamic_cast e RTTIQuando costruiamo il nostro framework per la Rete Neurale, avremo bisogno di specificare molte configurazioni a parole.
Ad esempio, quale Funzione di Attivazione vogliamo per un certo DenseLayer? Quale algoritmo di Ottimizzazione vogliamo per l’addestramento (SGD, Adam, RMSprop)?
Il computer capisce solo numeri. Come traduciamo questi concetti “umani” in codice C++ efficiente senza appesantire la CPU con lenti controlli su stringhe di testo (es. if (act == "ReLU"))?
L’approccio da principianti (molto usato in C e Fortran) è mappare ogni parola a un numero intero.
// Legenda mentale del programmatore:
// 0 = Nessuna, 1 = Sigmoide, 2 = ReLU, 3 = Tanh
DenseLayer layer("Hidden1", 128);
1layer.setActivation(2);2, che nella nostra testa significa ReLU.
Questo numero 2 buttato in mezzo al codice prende il nome di Numero Magico (Magic Number).
I Numeri Magici sono la rovina dei grandi progetti software:
layer.setActivation(2) tra sei mesi, vi ricorderete cosa diamine significa il 2? Assolutamente no. Dovrete andare a cercare nei commenti.int. Cosa impedisce a un altro utente della vostra libreria di chiamare layer.setActivation(999)? Il compilatore lo accetterà silenziosamente, per poi far esplodere la rete neurale durante il calcolo.Per risolvere questo problema, nascono i Tipi Enumerati (enum). Un Enumeratore è un modo per inventare un Nuovo Tipo di Dato (come int o double), che però può assumere solo e soltanto un set ristretto di valori stabiliti da noi.
Questi valori avranno internamente un numero intero per il computer (massima velocità), ma nel codice verranno scritti con parole umane (massima leggibilità).
Tuttavia, bisogna fare attenzione: in C++ ci sono due modi di scriverli. Il modo vecchio (C-Style) e il modo nuovo (C++11).
enum (L’approccio obsoleto)Il vecchio modo di fare gli enumeratori era semplicemente:
enum Activation {
NONE,
SIGMOID,
RELU,
TANH
1};
DenseLayer layer("Hidden1");
layer.setActivation(RELU); // Leggibile!NONE=0, SIGMOID=1, RELU=2, ecc.
Sembra perfetto, vero? Eppure, questo approccio porta con sé un grave problema che infesta grandi librerie.
Negli enum classici, i nomi dei valori (“RELU”, “NONE”) “fuoriescono” e inquinano lo scope globale!
Immaginate di avere due enum diversi nel vostro framework:
enum Activation { NONE, SIGMOID, RELU };
1enum ErrorType { NONE, WARNING, FATAL };NONE.
Entrambi gli enum stanno cercando di creare una variabile globale chiamata NONE. Il compilatore andrà in panico.
enum classPer risolvere l’inquinamento dei nomi, il C++11 ha introdotto gli enum class (Strongly Typed Enums).
enum class Activation {
NONE,
SIGMOID,
RELU
};
enum class ErrorType {
NONE,
WARNING,
FATAL
1};Perché le parole non fuoriescono più! Per usarle, dovrete esplicitare il nome della classe-enum usando l’operatore di scope ::.
L’enum class non protegge solo i nomi, ma applica una sicurezza sui Tipi draconiana.
Activation non è un int.
enum class e lo statement switchL’enum class si sposa in maniera perfetta con lo statement switch, ed è questo il modo in cui scriveremo la matematica del feedforward.
#include <cmath>
double applyActivation(double x, Activation act) {
1 switch (act) {
case Activation::RELU:
return (x > 0) ? x : 0.0;
case Activation::SIGMOID:
return 1.0 / (1.0 + std::exp(-x));
case Activation::NONE:
return x;
}
return x;
}switchC’è un ulteriore immenso vantaggio nell’usare enum class dentro gli switch.
Tip
Se il vostro switch sta valutando un enum class, i compilatori moderni vi daranno un Warning (Avvertimento) se vi dimenticate di gestire uno dei possibili casi!
Se domani aggiungete TANH all’enum Activation, ma vi dimenticate di aggiornare la funzione matematica, il compilatore vi dirà: warning: enumeration value 'TANH' not handled in switch. Vi salverà da ore di debugging!
EnumTest.cpp.enum class chiamato Optimizer con i valori SGD, ADAM, e RMSPROP.void compileNetwork(Optimizer opt) che accetta il nuovo tipo.main, prova a richiamare compileNetwork(1); (dovrebbe darti errore).compileNetwork(Optimizer::ADAM); (dovrebbe compilare perfettamente).Archiviamo gli enum e torniamo al cuore dell’architettura della nostra Rete Neurale: L’Ereditarietà.
Abbiamo costruito una Classe Base astratta Layer, da cui ereditano classi concrete come DenseLayer e ConvLayer. Sappiamo che ogni oggetto ha una precisa identità in memoria RAM creata dal new.
Nella Lezione 12 abbiamo visto la promozione magica: L’Upcasting (Cast verso l’alto). Ovvero la possibilità di far puntare un puntatore della Classe Padre (generico) all’oggetto Figlio (specifico).
Layer, anche se in RAM c’è un DenseLayer.
Perché il compilatore C++ ci lascia fare Layer* p = new DenseLayer() senza battere ciglio?
Perché per la relazione “IS-A”, il compilatore ha la certezza matematica che la promozione sia sicura. Un DenseLayer “È-UN” Layer. Se il Padre ha la funzione getName(), il Figlio ce l’avrà al 100%, ereditata o sovrascritta! Non c’è alcun rischio che il puntatore base invochi una funzione inesistente nell’oggetto in RAM.
L’Upcasting è il trucco che ci permette di costruire il Vettore Polimorfico.
Grazie a questo, la nostra Rete Neurale vive felice, e con il Late Binding (virtual) sa eseguire le funzioni giuste.
Ma cosa succede quando dobbiamo fare l’operazione inversa?