Sei sulla pagina 1di 435

40 ALGORITMI CHE OGNI PROGRAMMATORE DEVE

CONOSCERE
PER MIGLIORARE NEL PROBLEM SOLVING E SCRIVERE CODICE PIÙ EFFICACE

Imran Ahmad
© Apogeo - IF - Idee editoriali Feltrinelli s.r.l.
Socio Unico Giangiacomo Feltrinelli Editore s.r.l.

ISBN ebook: 9788850319398

Copyright (c) Packt Publishing 2020. First published in the English


language under the title 40 Algorithms Every Programmer Should Know
(9781789801217).

Il presente file può essere usato esclusivamente per finalità di carattere


personale. Tutti i contenuti sono protetti dalla Legge sul diritto d’autore.

Nomi e marchi citati nel testo sono generalmente depositati o registrati dalle
rispettive case produttrici.

L’edizione cartacea è in vendita nelle migliori librerie.

Sito web: www.apogeonline.com

Scopri le novità di Apogeo su Facebook

Seguici su Twitter

Collegati con noi su LinkedIn

Guarda cosa stiamo facendo su Instagram

Rimani aggiornato iscrivendoti alla nostra newsletter

~
Sai che ora facciamo anche CORSI? Dai un’occhiata al calendario.
A mio padre, Inayatullah Khan, che seguita a motivarmi nel
continuare a imparare ed esplorare nuovi orizzonti.
Prefazione

Gli algoritmi hanno sempre avuto un ruolo importante nella scienza


e nella pratica dell’informatica. Questo libro si concentra sull’utilizzo
degli algoritmi per risolvere i problemi tipici del mondo reale. Per
trarre il massimo da questi algoritmi, è indispensabile comprendere più
profondamente la loro logica e le loro basi matematiche. Inizieremo
con un’introduzione agli algoritmi ed esploreremo varie tecniche di
progettazione. Procedendo con la lettura, affronteremo la
programmazione lineare, la valutazione (ranking) delle pagine e i
grafi, e affronteremo gli algoritmi di machine learning, sempre sulla
scorta delle loro basi matematiche e logiche. Questo libro contiene
anche vari casi di studio per argomenti pratici come le previsioni del
tempo, il clustering di tweet e i motori di raccomandazione per film,
problemi mediante i quali impareremo ad applicare questi algoritmi in
modo ottimale. Al termine di questo libro, avrete acquisito tutta la
sicurezza necessaria per applicare gli algoritmi alla risoluzione di
problemi computazionali tipici del mondo reale.

A chi è rivolto questo libro


Questo è un libro rivolto ai programmatori. Che voi siate
programmatori esperti e desideriate acquisire una comprensione più
profonda delle basi matematiche degli algoritmi o che abbiate solo una
conoscenza limitata della programmazione o della data science e
desideriate saperne di più su come sfruttare questi algoritmi, ben
collaudati, per migliorare il modo in cui progettate e scrivete il codice,
sicuramente lo studio di questo libro vi sarà di grande utilità pratica.
L’esperienza di programmazione con Python è essenziale, mentre la
conoscenza della data science è utile ma non indispensabile.

Argomenti trattati
Il Capitolo 1 riassume i fondamenti degli algoritmi. Si apre con una
sezione sui concetti di base necessari per comprendere il
funzionamento dei diversi algoritmi. Riepiloga come abbiamo iniziato
a utilizzare algoritmi per formulare matematicamente determinate
classi di problemi. Menziona anche i limiti dei diversi algoritmi.
Quindi spiega i vari modi per specificare la logica di un algoritmo.
Poiché per scrivere gli algoritmi in questo libro viene utilizzato
Python, il capitolo spiega come impostare l’ambiente per svolgere gli
esempi. Quindi, discute i vari modi in cui le prestazioni di un
algoritmo possono essere quantificate e confrontate con altri. Infine, il
capitolo tratta i vari metodi di convalida di una determinata
implementazione di un algoritmo.
Il Capitolo 2 si concentra sulle strutture di dati necessarie per
contenere i dati temporanei di un algoritmo. Gli algoritmi possono
essere a elevata intensità di dati, a elevata intensità di calcolo o
entrambe le cose. Ma per tutti i diversi tipi, la scelta delle giuste
strutture di dati è essenziale per la loro ottimale implementazione.
Molti algoritmi hanno una logica ricorsiva o iterativa e richiedono
strutture di dati specializzate di natura fondamentalmente iterativa.
Poiché in questo libro utilizziamo il linguaggio Python, il capitolo si
concentra sulle strutture di dati di Python.
Il Capitolo 3 presenta gli algoritmi di base impiegati per operazioni
di ordinamento e ricerca. Questi algoritmi possono in seguito diventare
la base per altri più complessi. Il capitolo inizia presentando diversi
tipi di algoritmi di ordinamento, confrontando le prestazioni di vari
approcci. Quindi, ne esamina vari per operazioni di ricerca e li mette a
confronto, quantificandone le prestazioni e la complessità. Infine, il
capitolo presenta le applicazioni pratiche di questi algoritmi.
Il Capitolo 4 presenta i concetti fondamentali di progettazione di
vari algoritmi. Spiega anche diversi tipi di algoritmi e discute i loro
punti di forza e punti deboli. Comprendere questi concetti è importante
quando si tratta di progettare e ottimizzare algoritmi complessi. Il
capitolo inizia discutendo diversi tipi di progetti algoritmici. Quindi,
presenta la soluzione per il famoso problema del commesso
viaggiatore. Poi introduce la programmazione lineare e i suoi limiti.
Infine, presenta un esempio pratico che mostra come la
programmazione lineare possa essere utilizzata per la pianificazione
della capacità.
Il Capitolo 5 si concentra sugli algoritmi per problemi con i grafi.
Sono molti i problemi computazionali che possono essere rappresentati
al meglio in termini di grafi. Questo capitolo presenta i metodi per
rappresentare un grafo e per eseguire ricerche su di esso. Eseguire
ricerche in un grafo significa seguire sistematicamente gli archi del
grafo in modo da visitarne i nodi. Un algoritmo di ricerca su grafi può
scoprire molto sulla struttura di un grafo. Molti algoritmi iniziano
cercando nel grafo fornito in input per ottenere alcune informazioni
strutturali. Sono vari gli algoritmi che si occupano di ricerche sui grafi.
Le tecniche di ricerca sono al centro del campo degli algoritmi dei
grafi. Il capitolo tratta le due rappresentazioni computazionali più
comuni dei grafi: come liste di adiacenza e come matrici di adiacenza.
Successivamente, presenta un semplice algoritmo di ricerca in
ampiezza (breadth-first). Poi presenta la ricerca in profondità (depth-
first)e fornisce alcuni risultati standard sull’ordine in cui una ricerca in
profondità visita i nodi.
Il Capitolo 6 introduce gli algoritmi di machine learning senza
supervisione. Sono detti “senza supervisione” perché il modello o
l’algoritmo cerca di apprendere dai dati le sue strutture, i suoi schemi e
le sue relazioni intrinseche senza alcuna supervisione. In primo luogo,
vengono discussi i metodi di clustering, metodi di machine learning
che cercano di trovare modelli di somiglianza e relazioni fra i
campioni di dati nel nostro dataset e quindi raggruppano questi
campioni in gruppi, in modo tale che ogni gruppo o cluster di campioni
di dati manifesti una qualche somiglianza, in base agli attributi o alle
sue caratteristiche intrinseche. Poi il capitolo discute gli algoritmi di
riduzione della dimensionalità, utilizzati quando si ha un numero
eccessivo di feature. Successivamente, esamina alcuni algoritmi che si
occupano del rilevamento delle anomalie. Infine, il capitolo presenta
l’estrazione delle regole associative, un metodo di data mining
utilizzato per esaminare e analizzare grandi dataset di transazioni per
identificare modelli e regole di interesse. Questi modelli rappresentano
relazioni e associazioni interessanti fra i vari elementi nelle
transazioni.
Il Capitolo 7 descrive i tradizionali algoritmi di machine learning
con supervisione in relazione a un insieme di problemi di machine
learning in cui è presente un dataset etichettato, ovvero dove vi sono
attributi di input ed etichette o classi di output. Questi input e output
vengono utilizzati per definire un sistema generalizzato, che può
successivamente essere utilizzato per prevedere i risultati per punti dei
dati non considerati in precedenza. Innanzitutto, viene introdotto il
concetto di classificazione nel contesto del machine learning. Quindi,
viene presentato il più semplice degli algoritmi di machine learning, la
regressione lineare. Segue uno degli algoritmi più importanti, l’albero
decisionale. Vengono discussi i limiti e i punti di forza degli algoritmi
ad albero decisionale, seguiti da due importanti algoritmi, SVM e
XGBoost.
Il Capitolo 8 introduce innanzitutto i concetti e i componenti
principali di una tipica rete neurale, che sta diventando la tecnica più
importante di machine learning. Quindi, presenta i vari tipi di reti
neurali e spiega i tipi di funzioni di attivazione utilizzati per
realizzarle. Poi tratta l’algoritmo di retropropagazione, l’algoritmo più
utilizzato per far convergere il problema in una rete neurale.
Successivamente, spiega la tecnica di trasferimento
dell’apprendimento, che può essere utilizzata per semplificare
notevolmente e automatizzare parzialmente l’addestramento dei
modelli. Infine, presenta un esempio reale: come utilizzare il deep
learning per rilevare oggetti nei dati multimediali.
Il Capitolo 9 presenta gli algoritmi per l’elaborazione del linguaggio
naturale (NLP). Questo capitolo procede dagli aspetti teorici a quelli
più pratici in modo progressivo. In primo luogo, presenta i fondamenti,
seguiti dalle basi matematiche. Quindi, discute una delle reti neurali
più utilizzate per progettare e implementare un paio di importanti casi
d’uso per i dati testuali. Tratta anche i limiti dell’elaborazione del
linguaggio naturale. Infine, presenta un caso di studio in cui viene
addestrato un modello per rilevare l’autore di un articolo in base allo
stile di scrittura.
Il Capitolo 10 si concentra sui motori di raccomandazione, che sono
un modo per modellare le informazioni disponibili in relazione alle
preferenze dell’utente e quindi utilizzarle per fornire raccomandazioni
informate. La base del motore di raccomandazione è sempre costituita
dalle interazioni fra utenti e prodotti. Questo capitolo inizia
presentando l’idea alla base dei motori di raccomandazione, quindi,
discute dei vari tipi di motori di raccomandazione e infine, illustra
come vengono utilizzati per suggerire articoli e prodotti agli utenti.
Il Capitolo 11 si concentra sui problemi relativi agli algoritmi basati
sui dati. Il capitolo inizia con una breve panoramica delle questioni
relative ai dati e poi presenta i criteri per la loro classificazione.
Successivamente, spiega come applicare determinati algoritmi alle
applicazioni che operano su dati in streaming e quindi presenta
l’argomento della crittografia. Infine, mostra un esempio pratico di
estrazione di modelli dai dati di Twitter.
Il Capitolo 12 introduce gli algoritmi relativi alla crittografia. Il
capitolo inizia presentando le basi storiche. Quindi, tratta gli algoritmi
di crittografia simmetrica. Partiremo dagli algoritmi di hashing MD5 e
SHA ed esamineremo i limiti e i punti deboli associati
all’implementazione di algoritmi simmetrici. Successivamente,
esamineremo gli algoritmi di crittografia asimmetrica e come vengono
utilizzati per creare certificati digitali. Infine, vedremo un esempio
pratico che riassume tutte queste tecniche.
Il Capitolo 13 spiega come gli algoritmi per dati su larga scala
gestiscono i dati che non possono essere contenuti nella memoria di un
singolo nodo e coinvolgono l’elaborazione da parte di più CPU.
Questo capitolo inizia discutendo quali tipi di algoritmi sono più adatti
per essere eseguiti in parallelo. Quindi, descrive le questioni relative
alla parallelizzazione degli algoritmi. Presenta inoltre l’architettura
CUDA e mostra come utilizzare una o più GPU per accelerare gli
algoritmi e quali modifiche devono essere apportate all’algoritmo per
utilizzare efficacemente la potenza della GPU. Infine, il capitolo
introduce il cluster computing e spiega come Apache Spark è in grado
di creare dataset resilienti distribuiti (RDD) per costruire
un’implementazione parallela estremamente veloce degli algoritmi
standard.
Il Capitolo 14 inizia con l’importante argomento della spiegabilità,
che sta diventando sempre più importante a mano a mano che si va
verso un’automatizzazione dei processi decisionali. Quindi, il capitolo
presenta gli aspetti etici dell’utilizzo di un algoritmo e le possibilità di
operare sulla base di pregiudizi, bias. Successivamente, tratta in
dettaglio le tecniche per la gestione dei problemi NP-difficili. Infine,
riassume i modi per implementare gli algoritmi e le sfide da affrontare.

Requisiti
Per sfruttare al meglio questo libro, impiegate Python versione 3.7.2
o successiva su un computer con almeno 4 GB di memoria RAM
(consigliati 8 GB o più) e con sistema operativo Windows, Linux o
Mac.
Anche se state utilizzando la versione digitale di questo libro, vi
consigliamo di digitare il codice o di scaricarlo dal repository GitHub
(URL disponibile nel prossimo paragrafo). In questo modo eviterete
eventuali errori presenti nel codice o legati a operazioni di copia-
incolla.

File degli esempi


Potete scaricare i file degli esempi su GitHub all’indirizzo
.
https://github.com/PacktPublishing/40-Algorithms-Every-Programmer-Should-Know

Una volta scaricato il file, assicuratevi di espandere la cartella


utilizzando l’ultima versione di:
WinRAR/7-Zip per Windows;
Zipeg/iZip/UnRarX per Mac;
7-Zip/PeaZip per Linux.
Convenzioni utilizzate
In questo libro vengono impiegate alcune convenzioni per il testo.
CodiceNelTesto: indica le parole in codice nel testo, i nomi delle tabelle

del database, i nomi delle cartelle, i nomi dei file, le estensioni dei file,
i nomi dei percorsi, gli URL, gli input dell’utente e gli handle di
Twitter. Ecco un esempio: “Vediamo come aggiungere un nuovo
elemento a uno stack utilizzando push o come rimuovere un elemento
da uno stack utilizzando pop”.
Un blocco di codice è formattato come segue:
define swap(x, y)

buffer = x

x = y

y = buffer

Quando desideriamo attirare l’attenzione su un particolare di un


blocco di codice, le righe o gli elementi pertinenti sono in grassetto:
define swap(x, y)

buffer = x

x = y

y = buffer

Il corsivo indica i comandi nei menu, nuovi termini, parole


importanti, titoli di libri o parole in altre lingue. Ecco un esempio: “Un
modo per ridurre la complessità di un algoritmo è scendere a
compromessi sulla sua accuratezza, producendo un tipo di algoritmo
chiamato algoritmo approssimato”.
NOTA
Gli avvertimenti, i suggerimenti di programmazione o le note importanti
vengono segnalati in questo modo.

L’autore
Imran Ahmad è istruttore certificato Google e da diversi anni
insegna per Google e Learning Tree. Fra gli argomenti da lui affrontati
vi sono Python, il machine learning, gli algoritmi, i big data e il deep
learning. Nel suo dottorato di ricerca, ha proposto un nuovo algoritmo
basato sulla programmazione lineare chiamato ATSRA, che può essere
utilizzato per assegnare in modo ottimale le risorse in un ambiente di
cloud computing. Negli ultimi quattro anni ha collaborato a un
progetto di alto profilo di machine learning presso il laboratorio di
analisi avanzate del governo federale canadese. Il progetto consiste
nello sviluppare algoritmi di machine learning in grado di
automatizzare le procedure di esame delle domande di immigrazione.
Imran sta attualmente lavorando allo sviluppo di algoritmi per
utilizzare in modo ottimale le GPU per addestrare modelli complessi di
machine learning.

Il revisore tecnico
Benjamin Baka è uno sviluppatore di software ed è appassionato di
nuove tecnologie e di tecniche di programmazione. Ha dieci anni di
esperienza in diverse tecnologie: C++, Java, Ruby, Python e Qt. Alcuni
dei progetti cui sta lavorando si trovano sulla sua pagina GitHub.
Attualmente sta operando su tecnologie entusiasmanti per mPedigree.
Parte I

Fondamenti e algoritmi di base

Questa prima parte introduce gli aspetti fondamentali degli


algoritmi. Scopriremo che cos’è un algoritmo e come si progetta, e
conosceremo le strutture di dati utilizzate negli algoritmi. I capitoli di
questa parte del libro forniscono anche nozioni approfondite sugli
algoritmi di ordinamento e ricerca e sugli algoritmi per risolvere
problemi grafici.
Capitolo 1

Panoramica sugli algoritmi

Questo libro tratta tutte le informazioni necessarie per comprendere,


classificare, selezionare e implementare alcuni fra gli algoritmi più
importanti. Oltre a spiegare la logica su cui essi si basano, il libro tratta
anche di strutture di dati, ambienti di sviluppo e ambienti di
produzione, spiegando quali sono più adatti alle diverse classi di
algoritmi. Ci concentreremo sui moderni algoritmi di machine
learning, che stanno diventando sempre più importanti, al giorno
d’oggi. Insieme alla logica, esamineremo anche alcuni esempi pratici
dell’uso di algoritmi per risolvere problemi di carattere quotidiano.
Questo capitolo offre una panoramica sui fondamenti degli
algoritmi. Si apre con un paragrafo dedicato ai concetti di base
necessari per comprendere il funzionamento dei diversi algoritmi.
Questo paragrafo riassume il modo in cui si è iniziato a utilizzare gli
algoritmi per formulare matematicamente una certa classe di problemi
e menziona anche i limiti dei diversi algoritmi. Il paragrafo successivo
spiega i vari modi per specificare la logica di un algoritmo. Poiché in
questo libro, per scrivere gli algoritmi viene utilizzato il linguaggio di
programmazione Python, vedremo anche come impostare l’ambiente
per poter svolgere gli esempi. Quindi, scopriremo i vari modi in cui
quantificare le prestazioni di un algoritmo, per poter confrontare fra
loro più algoritmi. Infine, il capitolo discute vari metodi di convalida
di una determinata implementazione di un algoritmo.
Che cos’è un algoritmo?
In breve, un algoritmo è un insieme di regole per eseguire i calcoli
necessari per risolvere un problema. Un algoritmo è progettato per
fornire risultati per qualsiasi input valido, secondo istruzioni definite
con precisione. Cercando la definizione di algoritmo in un dizionario
come l’American Heritage, si trova che:
Un algoritmo è un insieme finito di istruzioni non ambigue che, dato un insieme di
condizioni iniziali, può essere eseguito secondo una sequenza prescritta per raggiungere
un determinato obiettivo e che ha un insieme riconoscibile di condizioni finali.

La progettazione di un algoritmo tenta di creare una ricetta


matematica del modo più efficiente per risolvere un determinato
problema concreto. Questa ricetta può essere utilizzata come base per
sviluppare una soluzione matematica riutilizzabile e generica, che
possa essere applicata a un insieme più ampio di problemi simili.

Le fasi di un algoritmo
La Figura 1.1 mostra le diverse fasi di sviluppo, implementazione e
utilizzo di un algoritmo.
Figura 1.1

Come possiamo vedere, il processo inizia con la comprensione dei


requisiti, dall’affermazione del problema, che specificano quale
risultato occorre ottenere. Una volta che il problema è esposto
chiaramente, si passa alla fase di sviluppo.
La fase di sviluppo di un algoritmo si compone a sua volta di due
fasi.
La fase di progettazione duranre la quale vengono immaginati e
documentati l’architettura, la logica e i dettagli di
implementazione dell’algoritmo. In questa fase teniamo presente
sia l’accuratezza sia le prestazioni. Durante la ricerca della
soluzione per un dato problema, in molti casi finiremo per avere
più algoritmi alternativi. La fase di progettazione di un algoritmo
è un processo iterativo, che prevede il confronto di diversi
algoritmi candidati. Alcuni algoritmi possono fornire soluzioni
semplici e veloci, ma a scapito della precisione. Altri algoritmi
possono essere molto accurati, ma la loro esecuzione può
richiedere molto tempo, a causa della loro complessità. Alcuni di
questi algoritmi complessi possono essere più efficienti di altri.
Prima di effettuare una scelta, occorre studiare attentamente tutti i
compromessi intrinseci degli algoritmi candidati. Soprattutto se il
problema è complesso, è molto importante progettare un
algoritmo efficiente. Un algoritmo progettato correttamente si
tradurrà in una soluzione efficiente che sarà in grado di fornire,
allo stesso tempo, prestazioni soddisfacenti e un’accuratezza
ragionevole.
La fase di programmazione duranre la quale l’algoritmo
progettato viene convertito in un programma. È importante che
tale programma implementi tutte le logiche e le architetture
suggerite in fase di progettazione.
Le fasi di progettazione e programmazione di un algoritmo hanno
una natura iterativa. L’ideazione di un design algoritmico che soddisfi i
requisiti sia funzionali sia quelli non funzionali può richiedere molto
tempo e fatica. I requisiti funzionali sono quelli che determinano
l’output corretto per un determinato insieme di dati di input. I requisiti
non funzionali di un algoritmo riguardano principalmente le
prestazioni per una data dimensione dei dati. La convalida e l’analisi
delle prestazioni di un algoritmo verranno discusse più avanti in questo
stesso capitolo. La convalida di un algoritmo consiste nel verificare
che l’algoritmo soddisfi i suoi requisiti funzionali. L’analisi delle
prestazioni di un algoritmo consiste nel verificare che soddisfi il suo
principale requisito non funzionale: le prestazioni.
Una volta progettato e implementato in un linguaggio di
programmazione a scelta, il codice dell’algoritmo è pronto per essere
distribuito. La distribuzione (deployment) di un algoritmo coinvolge la
progettazione dell’ambiente di produzione in cui verrà effettivamente
eseguito il codice. L’ambiente di produzione deve essere progettato in
base ai dati e alle esigenze elaborative dell’algoritmo. Per esempio, per
algoritmi parallelizzabili, per l’esecuzione efficiente dell’algoritmo
sarà necessario prevedere un cluster con un numero appropriato di
nodi di calcolo. Per gli algoritmi a elevata intensità di dati, potrebbe
essere necessario progettare una pipeline di ingresso dei dati e una
strategia per memorizzare in cache e archiviare i dati. La progettazione
di un ambiente di produzione è trattata in maggior dettaglio nel
Capitolo 13 e nel Capitolo 14. Una volta che l’ambiente di produzione
è stato progettato e implementato, l’algoritmo viene distribuito e inizia
ad accettare i dati di input, a elaborarli e a generare l’output, secondo i
requisiti.

La logica di un algoritmo
Quando si progetta un algoritmo, è importante trovare modi diversi
per specificarne i dettagli, curando la capacità di catturarne sia la
logica sia l’architettura. In generale, proprio come quando si costruisce
una casa, è importante specificare la struttura di un algoritmo, prima di
implementarlo effettivamente. Per algoritmi più complessi e distribuiti,
la pianificazione preliminare del modo in cui la loro logica sarà
distribuita nel cluster per l’esecuzione è importante per guidare in
modo efficiente il processo iterativo di progettazione. Attraverso lo
pseudocodice e i piani di esecuzione, entrambe queste esigenze
vengono soddisfatte, come vedremo nel prossimo paragrafo.
Che cos’è lo pseudocodice
Il modo più semplice per specificare la logica di un algoritmo
consiste nello scriverne una descrizione di alto livello in un modo semi
strutturato, chiamato pseudocodice. Prima di scrivere in pseudocodice
la logica di un algoritmo, è utile descriverne il flusso principale
scrivendo i passaggi principali in linguaggio naturale. Poi, questa
descrizione testuale deve essere convertita in pseudocodice, che
rappresenta un modo strutturato per scrivere questa descrizione in
modo che essa rappresenti più rigorosamente la logica e il flusso
dell’algoritmo. Lo pseudocodice dell’algoritmo, se ben scritto, deve
descrivere ad alto livello i passaggi dell’algoritmo, con un livello di
dettaglio ragionevole, perché un eccessivo dettaglio non è
particolarmente rilevante per descrivere il flusso principale e la
struttura dell’algoritmo. La Figura 1.2 mostra il flusso dei passaggi.
Figura 1.2

Notate che, una volta scritto lo pseudocodice (come vedremo nel


prossimo paragrafo), siamo pronti per programmare l’algoritmo
utilizzando un linguaggio a scelta.

Un esempio pratico di pseudocodice


Di seguito potete vedere lo pseudocodice di un algoritmo di
allocazione delle risorse, chiamato SRPMP. Nel cluster computing, ci
sono molte situazioni in cui più attività parallele devono essere
eseguite su un dato insieme di risorse disponibili, chiamate
collettivamente pool di risorse. Questo algoritmo assegna le attività a
una risorsa e crea una mappatura, chiamata Ω. Notate che questo
pseudocodice cattura la logica e il flusso dell’algoritmo, elementi che
verranno meglio spiegati di seguito.
1: BEGIN Mapping_Phase

2: Ω = { }

3: k = 1

4: FOREACH Ti∈T

5: ωi = RA(∆k, Ti)

6: add {Ωi, Ti} to Ω

7: state_changeTi [STATE 0: Idle/Unmapped] → [STATE 1: Idle/Mapped]

8: k=k+1

9: IF (k>q)

10: k= 1

11: ENDIF

12: END FOREACH

13: END Mapping_Phase

Analizziamo questo algoritmo riga per riga.


1. Avviamo la mappatura eseguendo l’algoritmo. Il set di mappatura
Ω è vuoto.
2. Viene selezionata la prima partizione come pool di risorse per
l’attività T1 (vedi la riga 3 del codice precedente). Television
Rating Point (TRPS) richiama in modo iterativo l’algoritmo per
l’artrite reumatoide (Reumatoid Arthritis, RA) per ogni attività Ti
con una delle partizioni scelte come pool di risorse.
3. L’algoritmo RA restituisce l’insieme delle risorse scelte per
l’attività Ti, rappresentato da Ωi (vedi riga 5 del codice
precedente).
4. Ti e Ωi vengono aggiunti al set di mappatura (vedi riga 6 del
codice precedente).
5. Lo stato di Ti viene modificato da STATE 0: Idle/Unmapped a STATE 1:

Idle/Mapped (vedi riga 7 del codice precedente).


6. Notate che per la prima iterazione k = 1 e viene selezionata la
prima partizione. Per ogni successiva iterazione, il valore di k
aumenta fino a che k .
> q

7. Se k diventa maggiore di q, viene riportato a 1 (vedi righe 9 e 10


del codice precedente).
8. Questo processo si ripete finché non viene determinata una
mappatura fra tutte le attività e l’insieme di risorse che
utilizzeranno e tale mappatura memorizzata in un insieme di
mappatura chiamato Ω.
9. Una volta che ciascuna delle attività è mappata su un insieme di
risorse nella fase di mappatura, l’attività viene eseguita.

Utilizzo degli snippet


Con la crescita di popolarità di un linguaggio di programmazione
semplice ma potente come Python, si sta diffondendo sempre più un
approccio alternativo, che consiste nel rappresentare la logica
dell’algoritmo direttamente nel linguaggio di programmazione,
seppure in una versione un po’ semplificata. Come lo pseudocodice,
questo codice cattura la logica di base e la struttura dell’algoritmo,
evitando di entrare troppo nei dettagli. Questo tipo di codice è
chiamato anche snippet. In questo libro, ove possibile, vengono
utilizzati snippet di codice anziché pseudocodice, in quanto essi
consentono di risparmiare un passaggio. Per esempio, esaminiamo un
semplice snippet relativo a una funzione Python che può essere
utilizzata per scambiare due variabili:
define swap(x, y)

buffer = x
x = y

y = buffer

NOTA
Non sempre gli snippet possono sostituire lo pseudocodice. Nello
pseudocodice, a volte astraiamo più righe di codice in un’unica riga di
pseudocodice, esprimendo la logica dell’algoritmo senza farci distrarre da
dettagli inutili.

Creazione di un piano di esecuzione


Non sempre una descrizione in pseudocodice e sotto forma di
snippet è sufficiente per chiarire tutta la logica relativa agli algoritmi
distribuiti più complessi. Per esempio, in genere gli algoritmi
distribuiti devono essere suddivisi runtime in più fasi, in base a un
ordine di precedenza. La giusta strategia per suddividere il problema,
in tutta la sua ampiezza, in un numero ottimale di fasi con i giusti
vincoli di precedenza è cruciale per l’esecuzione efficiente di un
algoritmo.
Dobbiamo trovare un modo per rappresentare anche questa strategia
per riuscire a rappresentare appieno la logica e la struttura di un
algoritmo. Un piano di esecuzione è uno dei metodi usati per
dettagliare il modo in cui l’algoritmo verrà suddiviso in un gruppo di
attività. Un’attività può essere costituita da mappatori o riduttori, i
quali possono essere raggruppati in blocchi, chiamati fasi, stage. La
Figura 1.3 mostra un piano di esecuzione generato da un runtime
Apache Spark prima dell’esecuzione di un algoritmo. Descrive in
dettaglio le attività runtime in cui sarà suddiviso il job creato per
l’esecuzione del nostro algoritmo.
Notate che la Figura 1.3 prevede cinque attività, divise in due
diverse fasi, denominate Stage 11 e Stage 12.
Figura 1.3

Introduzione ai pacchetti Python


Una volta progettati, gli algoritmi devono poi essere implementati in
un linguaggio di programmazione in base al progetto. Per questo libro
ho scelto di impiegare il linguaggio di programmazione Python. L’ho
scelto perché Python è un linguaggio di programmazione flessibile e
open source. Python è anche il linguaggio preferito per infrastrutture di
cloud computing sempre più importanti, come Amazon Web Services
(AWS), Microsoft Azure e Google Cloud Platform (GCP).
La home page ufficiale di Python è disponibile su
https://www.python.org, che offre anche le istruzioni per l’installazione e

un’utile guida rivolta ai principianti.


Se non avete mai usato Python prima, può essere una buona idea
sfogliare questa guida per principianti per studiare autonomamente il
linguaggio. Una conoscenza di base di Python vi aiuterà a
comprendere meglio i concetti presentati nel libro.
In questo libro, mi aspetto che utilizziate la versione più recente di
Python 3. Al momento della stesura di queste pagine, la versione più
recente è la 3.7.3, che è quella che useremo per gli esercizi.

Pacchetti Python
Python è un linguaggio general-purpose. Questo significa anche che
è progettato in modo da offrire funzionalità minime. In base al caso
d’uso per il quale intendete utilizzare Python, dovrete quindi installare
altri pacchetti aggiuntivi. Il modo più semplice per installare altri
pacchetti consiste nell’impiegare il programma di installazione pip.
Ecco il comando pip da usare per installare nuovi pacchetti:
pip install un_pacchetto

I pacchetti precedentemente installati devono essere aggiornati


periodicamente per ottenere le ultime funzionalità. Per fare ciò si
utilizza il flag upgrade:
pip install un_pacchetto --upgrade

Una distribuzione Python rivolta al calcolo scientifico è Anaconda,


che può essere scaricata da http://continuum.io/downloads.
Oltre al comando pip, per installare nuovi pacchetti la distribuzione
Anaconda offre anche il seguente comando:
conda install un_pacchetto

Per aggiornare i pacchetti esistenti, la distribuzione Anaconda offre


la possibilità di utilizzare il seguente comando:
conda update un_pacchetto
Sono disponibili pacchetti Python di ogni tipo. Alcuni dei pacchetti
più importanti e più rilevanti per gli algoritmi sono elencati nel
prossimo paragrafo.

L’ecosistema SciPy
Scientific Python (SciPy) è un gruppo di pacchetti Python creati
appositamente per la comunità scientifica. Contiene molte funzioni, fra
cui un’ampia gamma di generatori di numeri casuali, numerose routine
di algebra lineare e svariati ottimizzatori. SciPy è un pacchetto
completo e, nel tempo, molti hanno sviluppato varie estensioni per
personalizzare ed estendere il pacchetto in base alle proprie esigenze.
Ecco i principali pacchetti che compongono questo ecosistema.
NumPy: per gli algoritmi, la capacità di creare strutture di dati
multidimensionali, come array e matrici, è davvero importante.
NumPy offre una serie di tipi di dati per array e matrici utili per i
calcoli statistici e l’analisi dei dati. Per informazioni su NumPy:
http://www.numpy.org.

scikit-learn: questa estensione, dedicata al machine learning, è


una delle estensioni più utilizzate di SciPy. scikit-learn offre
un’ampia gamma di utili algoritmi di machine learning, fra cui
quelli di classificazione, regressione, clustering e convalida dei
modelli. Per informazioni su scikit-learn: http://scikit-learn.org.
pandas: è una libreria software open source. Contiene
un’articolata struttura di dati tabulare ampiamente utilizzata per
operazioni di input, output ed elaborazione di dati tabulari in vari
algoritmi. La libreria pandas contiene molte funzioni utili e offre
prestazioni altamente ottimizzate. Per informazioni su pandas:
http://pandas.pydata.org.
Matplotlib: fornisce numerosi strumenti per creare visualizzazioni
di grande impatto. I dati possono essere presentati come grafici a
linee, grafici a dispersione, grafici a barre, istogrammi, grafici a
torta e così via. Per informazioni su Matplotlib:
https://matplotlib.org.

Seaborn: può essere considerata simile alla nota libreria ggplot2


per R. Si basa su Matplotlib e offre un’interfaccia avanzata per
disegnare ottimi grafici statistici. Per informazioni su Seaborn:
https://seaborn.pydata.org.

iPython: è una console interattiva avanzata, progettata per


facilitare la scrittura, il test e il debug del codice Python.

Implementazione di Python tramite


Jupyter Notebook
La modalità di programmazione interattiva è utile per
l’apprendimento e la sperimentazione del codice. I programmi Python
possono essere salvati in un file di testo con estensione .py, e quel file
può essere eseguito dalla console.
Un altro modo per eseguire programmi Python prevede l’uso di
Jupyter Notebook. Jupyter Notebook offre un’interfaccia utente basata
su browser per sviluppare il codice. Per presentare gli esempi di codice
di questo libro utilizzerò sempre Jupyter Notebook. La capacità di
commentare e descrivere il codice con testi e grafica lo rende lo
strumento perfetto per presentare e spiegare un algoritmo e un ottimo
strumento per l’apprendimento.
Per lanciare il notebook, è necessario avviare il processo Juypter-
notebook , poi aprire il browser e accedere a http://localhost:8888 (vedi
Figura 1.4).
Figura 1.4

Notate che un notebook Jupyter è costituito da diversi blocchi,


chiamati celle.

Tecniche di progettazione degli


algoritmi
Un algoritmo è una soluzione matematica a un problema concreto.
Quando progettiamo un algoritmo, teniamo a mente i seguenti tre
problemi di progettazione.
1. Questo algoritmo sta producendo il risultato che ci aspettavamo?
2. Questo algoritmo rappresenta il modo ottimale per ottenere questi
risultati?
3. Come funzionerà l’algoritmo su dataset di maggiori dimensioni?
È importante studiare a fondo la complessità del problema, prima di
progettarne una soluzione. Per esempio, per progettare una soluzione
adeguata è utile caratterizzare il problema in termini di sue esigenze e
complessità. In generale, gli algoritmi possono essere suddivisi nelle
seguenti tipologie, in base alle caratteristiche del problema.
Algoritmi a elevata intensità di dati (data-intensive): sono
progettati per gestire grandi quantità di dati. Ci si aspetta che
abbiano requisiti di elaborazione relativamente ridotti. Un
algoritmo di compressione applicato a un file enorme è un buon
esempio di algoritmo a elevata intensità di dati. Per tali algoritmi,
la dimensione dei dati gestibili dovrebbe essere molto maggiore
della memoria del sistema di elaborazione (il singolo nodo o
l’intero cluster) e, in base ai requisiti, potrebbe essere necessario
adottare uno schema di elaborazione iterativo per poter elaborare
in modo efficiente i dati.
Algoritmi a elevata intensità di calcoli (compute-intensive):
hanno requisiti di elaborazione considerevoli, ma non elaborano
grandi quantità di dati. Un semplice esempio è l’algoritmo per
trovare un numero primo molto grande. Per massimizzare le
prestazioni dell’algoritmo è fondamentale trovare una strategia
per suddividere l’algoritmo in più fasi, in modo che almeno
alcune di esse possano essere parallelizzate.
Algoritmi a elevata intensità di dati e di calcoli: esistono alcuni
algoritmi che si trovano a gestire una grande quantità di dati ma
hanno anche notevoli requisiti di in termini di elaborazione. Gli
algoritmi utilizzati per eseguire l’analisi del sentiment per i feed
video in diretta sono un buon esempio di situazioni in cui sia i
dati sia i requisiti di elaborazione sono enormi, per poter portare a
termine l’attività. Questi sono gli algoritmi più dispendiosi in
termini di risorse e richiedono un’attenta progettazione e
un’allocazione intelligente delle risorse disponibili.
Per caratterizzare il problema in termini di complessità e requisiti, è
utile studiare i dati e calcolare le dimensioni in modo più approfondito,
cosa che faremo nel prossimo paragrafo.

La dimensione dei dati


Per classificare la dimensione dei dati del problema, osserviamo il
loro volume, la loro velocità e la loro varietà (le tre “V”), che sono
definite come segue.
Volume: è la dimensione fisica prevista dei dati che l’algoritmo
dovrà elaborare: MB, GB, TB, PB...
Velocity: è la rapidità con la quale vengono generati nuovi dati
quando viene utilizzato l’algoritmo. Può essere pari a zero, nel
caso delle attività batch, ma può essere periodica, near real-time o
real-time.
Variety: descrive quanti diversi tipi di dati l’algoritmo si troverà a
elaborare: precise tabelle, dati NoSQL o dati completamente non
trutturati.
La Figura 1.5 mostra più in dettaglio le tre “V” dei dati. Al centro di
questo grafico si trovano i dati più semplici possibili: piccolo volume,
scarsa varietà e bassa velocità. A mano a mano che ci allontaniamo dal
centro, la complessità dei dati aumenta, e può farlo in una o più delle
tre dimensioni. Per esempio, nella dimensione della velocità, abbiamo
il semplice processo Batch, seguito dal processo Periodico e quindi dal
processo Near Real-Time; infine abbiamo il processo Real-Time, che è
il più complesso da gestire nel reame della velocità dei dati. Per
esempio, la raccolta dei feed video live prodotti da un gruppo di
telecamere di monitoraggio avrà un grande volume, un’elevata velocità
e una notevole varietà e per poter archiviare ed elaborare i dati in
modo efficace sarà necessario realizzare un progetto appropriato.
D’altra parte, un semplice file .csv prodotto da Excel avrà un volume
limitato, una scarsa velocità e una limitata varietà.

Figura 1.5
La dimensione del calcolo
La dimensione del calcolo riguarda le esigenze di elaborazione e
calcolo del problema in questione. I requisiti di elaborazione di un
algoritmo determineranno il tipo di struttura più efficiente. Per
esempio, gli algoritmi di deep learning, in generale, richiedono molta
potenza di elaborazione. Questo significa che per poter impiegare
algoritmi di deep learning è importante poter disporre di
un’architettura parallela multinodo, ove possibile.

Un esempio pratico
Supponiamo di voler condurre un’analisi del sentiment relativa a un
video. Con l’analisi del sentiment cerchiamo di contrassegnare le
diverse parti di un video con le emozioni umane: tristezza, felicità,
paura, gioia, frustrazione ed estasi. È un lavoro a elevata intensità di
calcolo, per il quale è necessaria molta potenza di calcolo. Come
vedete nella Figura 1.6, per progettare la dimensione del calcolo,
abbiamo suddiviso l’elaborazione in cinque attività, composte da due
fasi. Tutta la trasformazione e la preparazione dei dati è implementata
in tre mappatori, mapper. Per questo, dividiamo il video in tre diverse
partizioni, chiamate split. Dopo l’elaborazione da parte dei mapper, il
video risultante viene immesso nei due aggregatori, chiamati reducer.
Per condurre l’analisi del sentiment, i riduttori devono raggruppare il
video in base alle emozioni. Infine, i risultati vengono combinati
nell’output.
Figura 1.6

NOTA
Il numero di mapper si traduce direttamente nel parallelismo runtime
dell’algoritmo. Il numero ottimale di mapper e reducer dipende dalle
caratteristiche dei dati, dal tipo di algoritmo che è necessario utilizzare e dalla
quantità di risorse disponibili.

Analisi delle prestazioni


L’analisi delle prestazioni di un algoritmo è un aspetto importante
della sua progettazione. Uno dei modi per stimare le prestazioni di un
algoritmo è analizzarne la complessità.
La teoria della complessità studia il “peso” degli algoritmi. Per
essere davvero utile, un buon algoritmo deve avere tre caratteristiche
chiave.
Deve essere corretto: un algoritmo non sarà molto utile se non dà
le risposte giuste.
Deve essere comprensibile: il miglior algoritmo del mondo non
servirà a niente se è troppo complicato per poter essere
implementato su un computer.
Deve essere efficiente: un algoritmo che produca un risultato
corretto e che sia implementabile non vi aiuterà molto se impiega
mille anni o se richiede 1 miliardo di terabyte di memoria per
fornire il suo risultato.
Esistono due possibili tipi di analisi per quantificare la complessità
di un algoritmo.
Analisi della complessità in termini di spazio: stima i requisiti di
memoria a runtime necessari per eseguire l’algoritmo.
Analisi della complessità in termini di tempo: stima il tempo
necessario per l’esecuzione dell’algoritmo.

Analisi della complessità in termini di


spazio
Stima la quantità di memoria richiesta dall’algoritmo per elaborare i
dati di input. Durante l’elaborazione dei dati di input, l’algoritmo deve
memorizzare alcune strutture di dati temporanee. Il modo in cui
l’algoritmo è progettato influenza il numero, il tipo e la dimensione di
queste strutture di dati. In questa era di calcolo distribuito e con
quantità sempre più grandi di dati da elaborare, l’analisi della
complessità in termini di spazio sta diventando sempre più importante.
La dimensione, il tipo e il numero di queste strutture di dati
determinano i requisiti di memoria per l’hardware sottostante. Le
moderne strutture di dati in memoria utilizzate nel calcolo distribuito,
come i Resilient Distributed Dataset (RDD), devono disporre di
meccanismi efficienti di allocazione delle risorse, che siano
consapevoli dei requisiti di memoria nelle diverse fasi di esecuzione
dell’algoritmo.
L’analisi della complessità in termini di spazio è un must per la
progettazione efficiente di algoritmi. Se non viene condotta un’analisi
adeguata nella progettazione di un determinato algoritmo, una
disponibilità di memoria insufficiente per le strutture di dati
temporanee di cui ha bisogno può innescare inutili trasferimenti dei
dati su disco, che potrebbero potenzialmente influire notevolmente
sulle prestazioni e sull’efficienza dell’algoritmo stesso.
In questo capitolo approfondiremo la complessità in termini di
tempo. La complessità in termini di spazio sarà trattata più in dettaglio
nel Capitolo 13, dove tratteremo gli algoritmi distribuiti su larga scala,
i quali hanno notevoli requisiti di memoria a runtime.

Analisi della complessità in termini di


tempo
Stima quanto tempo impiegherà un algoritmo per completare il
lavoro assegnato, in base alla sua struttura. A differenza della
complessità in termini di spazio, la complessità in termini di tempo
non dipende dall’hardware su cui verrà eseguito l’algoritmo, ma
esclusivamente dalla struttura dell’algoritmo stesso. L’obiettivo
generale dell’analisi della complessità in termini di tempo è quello di
cercare di rispondere alle seguenti, importanti domande. Questo
algoritmo sarà scalabile? In quale modo questo algoritmo gestirà
dataset più grandi?
Per rispondere a queste domande, dobbiamo determinare l’effetto
sulle prestazioni di un algoritmo a mano a mano che aumenta la
dimensione dei dati, e assicurarci che l’algoritmo sia progettato in
modo tale da renderlo non solo accurato, ma anche scalabile. Le
prestazioni di un algoritmo stanno diventando sempre più importanti
per dataset più grandi, nel mondo odierno dei “big data”.
In molti casi, potremmo avere a disposizione più di un approccio per
progettare l’algoritmo. L’obiettivo di condurre un’analisi della
complessità in termini di tempo, in questo caso, sarà il seguente.
Dato un determinato problema e più algoritmi per risolverlo, qual è il più efficiente in
termini di efficienza temporale?

Ci possono essere due approcci di base per calcolare la complessità


in termini di tempo di un algoritmo.
Approccio di profilazione post-implementazione: vengono
implementati più algoritmi candidati e ne vengono confrontate le
prestazioni.
Approccio teorico pre-implementazione: le prestazioni di ciascun
algoritmo vengono approssimate matematicamente, prima ancora
di eseguire l’algoritmo.
Il vantaggio dell’approccio teorico è che dipende solo dalla struttura
dell’algoritmo, non dall’hardware effettivo che verrà utilizzato per
eseguirlo, dalla scelta dello stack software impiegato a runtime o dal
linguaggio di programmazione utilizzato per implementare
l’algoritmo.

Stima delle prestazioni


Le prestazioni di un tipico algoritmo dipenderanno dal tipo di dati
che gli vengono forniti come input. Per esempio, se i dati fossero già
ordinati in base al contesto del problema che stiamo cercando di
risolvere, l’algoritmo potrebbe funzionare in modo incredibilmente
veloce. Se per eseguire il benchmark di questo particolare algoritmo
venisse impiegato un input ordinato, otterremmo prestazioni
irrealisticamente buone, che non rifletteranno le sue prestazioni reali,
nella maggior parte degli scenari tipici. Per gestire questa dipendenza
degli algoritmi dai dati di input, dobbiamo considerare diversi tipi di
casi quando ci troviamo a eseguire un’analisi delle prestazioni.

Il caso migliore
Nel migliore dei casi, i dati forniti come input sono già organizzati
in modo tale che l’algoritmo offra le sue prestazioni migliori. L’analisi
del caso migliore fornisce il limite superiore delle sue prestazioni.

Il caso peggiore
Il secondo modo per stimare le prestazioni di un algoritmo è quello
di cercare di trovare il tempo massimo possibile necessario perché
porti a termine il lavoro in un determinato insieme di condizioni.
Questa analisi del caso peggiore di un algoritmo è piuttosto utile, in
quanto ci permette di garantire che, indipendentemente dalle
condizioni, le prestazioni dell’algoritmo saranno sempre migliori di
quanto emerge da questa analisi. L’analisi del caso peggiore è
particolarmente utile per stimare le prestazioni quando si affrontano
problemi complessi con grandi dataset. L’analisi del caso peggiore
fornisce il limite inferiore delle prestazioni dell’algoritmo.

Il caso medio
Si inizia dividendo i vari possibili input in vari gruppi. Quindi, si
conduce l’analisi delle prestazioni con uno degli input rappresentativi
di ciascun gruppo. Infine, si calcola la media delle prestazioni di
ciascuno dei gruppi.
L’analisi del caso medio non è sempre accurata, in quanto deve
considerare tutte le diverse combinazioni e possibilità di input
all’algoritmo, cosa non sempre facile.
Scelta di un algoritmo
Come fate a sapere qual è una soluzione migliore? Come fate a
sapere quale algoritmo viene eseguito più velocemente? La
complessità in termini di tempo e la notazione Big O (trattata più
avanti in questo stesso capitolo) sono davvero ottimi strumenti per
rispondere a questo tipo di domande.
Per vederne l’utilità, prendiamo un semplice esempio in cui
l’obiettivo è quello di ordinare una lista di numeri. Ci sono un paio di
algoritmi in grado di svolgere il lavoro. Il problema è come scegliere
quello giusto.
Innanzitutto, un’osservazione che possiamo fare è che se i numeri
nella lista sono pochi, in realtà non importa quale algoritmo scegliamo
per ordinarla. Quindi, se la lista è di soli 10 numeri (n = 10), non
importa quale algoritmo scegliamo, poiché anche un algoritmo
progettato molto male probabilmente impiegherebbe non più di
qualche microsecondo. Ma non appena la dimensione della lista
diventa 1 milione, la scelta dell’algoritmo giusto farà la differenza. Un
algoritmo scritto molto male potrebbe richiedere anche un paio d’ore,
mentre un algoritmo ben progettato potrebbe terminare di ordinare la
lista in un paio di secondi. Quindi, per dataset di input più grandi, ha
molto senso investire tempo e fatica, eseguire un’analisi delle
prestazioni e scegliere un algoritmo progettato correttamente, che
svolgerà il lavoro richiesto in modo efficiente.

Notazione Big O
La notazione Big O viene utilizzata per quantificare le prestazioni di
vari algoritmi all’aumentare della dimensione dell’input ed è una delle
metodologie più utilizzate per condurre l’analisi del caso peggiore. I
seguenti paragrafi trattano i diversi tipi di notazione Big O.
Complessità costante, O(1)
Se un algoritmo impiega la stessa quantità di tempo per essere
eseguito, indipendentemente dalla dimensione dei dati di input, si dice
che viene eseguito in tempo costante, rappresentato da O(1).
Prendiamo l’esempio dell’accesso all’ennesimo elemento di un array.
Indipendentemente dalle dimensioni dell’array, ci vorrà un tempo
costante per ottenere il risultato. Per esempio, la seguente funzione
restituisce il primo elemento dell’array e ha una complessità pari a
O(1):
def getFirst(myList):

return myList[0]

Ecco l’output prodotto.

Aggiunta di un nuovo elemento in uno stack utilizzando push o


rimozione di un elemento da uno stack utilizzando pop.
Indipendentemente dalle dimensioni dello stack, ci vorrà lo stesso
tempo per aggiungere o rimuovere un elemento.
Accesso a un elemento di una tabella hash (vedremo l'argomento
nel Capitolo 2).
Bucket sort (vedremo l'argomento nel Capitolo 2).

Complessità lineare, O(n)


Si dice che un algoritmo ha una complessità lineare, rappresentata
da O(n), quando il tempo di esecuzione è direttamente proporzionale
alla dimensione dell’input. Un semplice esempio consiste
nell’aggiungere elementi a una struttura di dati unidimensionale:
def getSum(myList):
sum = 0

for item in myList:


sum = sum + item

return sum

Notate il ciclo principale dell’algoritmo. Il numero di iterazioni del


ciclo aumenta linearmente al crescere di n, producendo una
complessità O(n):

Alcuni altri esempi di operazioni di questo tipo sono i seguenti:


ricerca di un elemento;
ricerca del valore minimo fra tutti gli elementi di un array.

Complessità quadratica, O(n2)


Si dice che un algoritmo viene eseguito in un tempo quadratico se il
tempo di esecuzione di un algoritmo è proporzionale al quadrato della
dimensione dell’input; un esempio è una semplice funzione che
somma gli elementi di un array bidimensionale, come la seguente:
def getSum(myList):

sum = 0

for row in myList:

for item in row:

sum += item

return sum
Notate il ciclo interno, annidato nel ciclo principale. Questo ciclo
annidato conferisce al codice precedente la complessità O(n2):

Un altro esempio è l’algoritmo bubble sort (trattato nel Capitolo 2).

Complessità logaritmica, O(logn)


Si dice che un algoritmo viene eseguito in un tempo logaritmico
quando il tempo di esecuzione dell’algoritmo è proporzionale al
logaritmo della dimensione dell’input. A ogni iterazione, la
dimensione dell’input diminuisce di un fattore multiplo costante. Un
esempio di complessità logaritmica è la ricerca binaria. L’algoritmo di
ricerca binaria trova un determinato elemento in una struttura di dati
unidimensionale, come una lista Python. Gli elementi all’interno della
struttura di dati devono essere ordinati in ordine decrescente.
L’algoritmo di ricerca binaria è implementato tramite una funzione
denominata searchBinary, come segue:
def searchBinary(myList, item):
first = 0
last = len(myList) – 1
foundFlag = False
while(first<= last and not foundFlag):
mid = (first + last) // 2
if myList[mid] == item :
foundFlag = True
else:
if item < myList[mid]:
last = mid – 1
else:
first = mid + 1
return foundFlag
Il ciclo principale sfrutta il fatto che la lista è ordinata. Divide la
lista a metà a ogni iterazione, fino ad arrivare al risultato:

Dopo aver definito la funzione, il controllo da cui dipende la ricerca


di un determinato elemento si trova nelle righe 11 e 12. L’algoritmo di
ricerca binaria è ulteriormente trattato nel Capitolo 3.
Notate che fra i quattro tipi di notazione Big O presentati, O(n2)
offre le prestazioni peggiori e O(logn) offre le prestazioni migliori. In
effetti, le prestazioni O(logn) possono essere considerate il gold
standard per le prestazioni di qualsiasi algoritmo (cosa che però non
sempre viene raggiunta). D’altra parte, O(n2) non è così male come
O(n3), ma gli algoritmi che rientrano in questa classe non possono
essere utilizzati sui big data, poiché la loro complessità in termini di
tempo impone dei limiti alla quantità di dati che possono elaborare,
realisticamente.
Un modo per ridurre la complessità di un algoritmo consiste nello
scendere a compromessi sulla sua accuratezza, producendo un tipo di
algoritmo chiamato algoritmo approssimato.
L’intero processo di valutazione delle prestazioni degli algoritmi è
di natura iterativa, come mostrato nella Figura 1.7.
Figura 1.7

Convalida di un algoritmo
La convalida di un algoritmo conferma che sta effettivamente
fornendo una soluzione matematica al problema che stiamo cercando
di risolvere. Un processo di convalida dovrebbe controllare i risultati
per quanti più valori di input possibili e anche per quanti più tipi di
valori di input possibili.

Algoritmi esatti, approssimativi e


randomizzati
La convalida di un algoritmo dipende anche dal tipo di algoritmo, in
quanto le tecniche di test sono differenti. Differenziamo prima di tutto
gli algoritmi deterministici da quelli randomizzati.
Per gli algoritmi deterministici, un determinato input genera sempre
esattamente lo stesso output. Ma per alcune classi di algoritmi, come
input può essere presa una sequenza di numeri casuali, il che rende
l’output differente a ogni esecuzione dell’algoritmo. L’algoritmo di
clustering k-means (Figura 1.8), descritto in dettaglio nel Capitolo 6, è
un esempio di questo tipo.

Figura 1.8

Algoritmo randomizzato
Gli algoritmi possono anche essere suddivisi nei seguenti due tipi,
sulla base delle ipotesi o delle approssimazioni utilizzate per
semplificare la logica con lo scopo di renderli più veloci.
Algoritmi esatti: ci si aspetta che gli algoritmi esatti producano
una soluzione precisa senza richiedere alcuna ipotesi o
approssimazione.
Algoritmi approssimati: quando la complessità del problema è
eccessiva per le risorse disponibili, semplifichiamo il problema
sulla base di alcune ipotesi. Gli algoritmi approssimati, basati su
queste semplificazioni o ipotesi, non ci offrono una soluzione
precisa.
Facciamo un esempio per capire la differenza fra un algoritmo esatto
e uno approssimato: il famoso problema del commesso viaggiatore,
presentato nel 1930. Un commesso viaggiatore vi sfida a trovare il
percorso più breve per un determinato venditore che deve visitare tutte
le città di un elenco e poi tornare alla base. Il primo tentativo di fornire
la soluzione prevedrà la generazione di tutte le possibili permutazioni
delle città e la scelta della combinazione di città più economica. La
complessità di questo approccio nel fornire la soluzione è O(n!), dove
n è il numero di città. È ovvio che la complessità in termini di tempo
inizia a diventare ingestibile oltre le 30 città.
Se il numero di città è superiore a 30, un modo per ridurre la
complessità consiste nell’introdurre alcune approssimazioni e ipotesi.
Per gli algoritmi approssimati, è importante impostare le aspettative
di accuratezza nella raccolta dei requisiti. La convalida di un algoritmo
approssimato richiede la verifica che l’errore dei risultati rientri in un
intervallo accettabile.

Spiegabilità
Quando gli algoritmi vengono utilizzati per situazioni critiche,
diventa importante avere la capacità di spiegare il motivo che è alla
base di ogni risultato ogni volta che sia necessario. Ciò permette di
assicurarci che le decisioni basate sui risultati degli algoritmi non
introducano distorsioni.
La capacità di identificare esattamente le caratteristiche che
vengono utilizzate, direttamente o indirettamente, per prendere una
decisione è chiamata spiegabilità di un algoritmo. Gli algoritmi,
quando vengono applicati a casi d’uso critici, devono essere valutati in
termini di bias e pregiudizi. L’analisi etica degli algoritmi è diventata
un elemento standard del processo di convalida cui occorre sottoporre
quegli algoritmi che possono influenzare processi decisionali che
riguardano la vita delle persone.
Per gli algoritmi che si occupano di deep learning, la spiegabilità
può essere difficile da raggiungere. Per esempio, se un algoritmo
rifiuta la richiesta di un mutuo da parte di una persona, è importante
pretendere trasparenza e avere la capacità di spiegarne il motivo.
La spiegabilità degli algoritmi è un’area attiva di ricerca. Una delle
tecniche più efficaci, sviluppata recentemente, si chiama Local
Interpretable Model-agnostic Explanation (LIME), proposta negli atti
della 22a Association for Computing Machinery (ACM) alla
conferenza internazionale Special Interest Group on Knowledge
Discovery (SIGKDD) sulle scoperte della conoscenza e il data mining,
nel 2016. La tecnica LIME si basa su un concetto: vengono indotti
piccoli cambiamenti all’input per ogni istanza e quindi viene fatto il
tentativo di mappare il confine decisionale locale per quell’istanza. In
tal modo è in grado di quantificare l’influenza di ciascuna variabile di
tale istanza.

Riepilogo
Questo capitolo ha trattato le basi degli algoritmi. Innanzitutto,
abbiamo appreso le diverse fasi dello sviluppo di un algoritmo.
Abbiamo trattato i diversi modi di specificare la logica di un algoritmo
che sono necessari per progettarlo. Quindi, abbiamo esaminato il modo
in cui si progetta un algoritmo. Abbiamo poi imparato due diversi
modi per analizzare le prestazioni di un algoritmo. Infine, abbiamo
studiato i diversi aspetti della convalida di un algoritmo.
Con lo studio di questo capitolo, dovreste essere in grado di
comprendere lo pseudocodice di un algoritmo e le diverse fasi dello
sviluppo e dell’implementazione di un algoritmo. Avete anche
imparato a usare la notazione Big O per valutare le prestazioni di un
algoritmo.
Il prossimo capitolo riguarda le strutture di dati utilizzate negli
algoritmi. Inizieremo esaminando le strutture di dati disponibili in
Python. Vedremo quindi come possiamo utilizzare queste strutture di
base per creare strutture di dati più sofisticate, come gli stack, le code e
gli alberi, che sono necessarie per sviluppare algoritmi complessi.
Capitolo 2

Strutture di dati utilizzate negli


algoritmi

Gli algoritmi necessitano di impiegare strutture di dati per contenere


i dati temporanei nel corso dell’esecuzione. La scelta delle giuste
strutture di dati è essenziale per ottenere un’implementazione
efficiente degli algoritmi. Alcune classi di algoritmi adottano una
logica ricorsiva o iterativa, e necessitano di strutture di dati
appositamente progettate. Per esempio, un algoritmo ricorsivo può
essere implementato più facilmente, offrendo prestazioni migliori,
utilizzando strutture di dati annidate. Questo capitolo tratta le strutture
di dati nel contesto degli algoritmi. Poiché in questo libro utilizziamo
Python, il capitolo si concentra sulle strutture di dati specifiche di
Python, ma i concetti presentati possono essere generalizzati anche ad
altri linguaggi, come Java e C++.
Alla fine del capitolo, dovreste essere in grado di capire il modo in
cui Python gestisce le strutture di dati, anche complesse, e quale tipo di
struttura dovrebbe essere usata per un determinato tipo di dati.

Le strutture di dati in Python


In qualsiasi linguaggio, le strutture di dati vengono utilizzate per
memorizzare e manipolare dati complessi. In Python, le strutture di
dati sono contenitori che hanno lo scopo di aiutare a gestire,
organizzare e ricercare i dati in modo efficiente. Sono utilizzate per
memorizzare in collezioni un insieme di dati che devono essere
archiviati ed elaborati insieme. In Python, abbiamo cinque diverse
strutture di dati che possono essere utilizzate per memorizzare
collezioni.
Liste: sequenze ordinate e mutabili di elementi.
Tuple: sequenze ordinate e immutabili di elementi.
Insiemi: gruppi di elementi non ordinati.
Dizionari: gruppi non ordinati di coppie chiave-valore.
Data frame: strutture bidimensionali, dedicate a memorizzare dati
bidimensionali.
Nei prossimi paragrafi esaminiamo più in dettaglio tutte queste
strutture.

Liste
In Python, la lista è la principale struttura di dati utilizzata per
memorizzare una sequenza mutabile di elementi. Non è necessario che
la sequenza dei punti dei dati memorizzati nella lista sia dello stesso
tipo.
Per creare una lista, gli elementi dei dati devono essere racchiusi fra
parentesi quadre e separati da una virgola. Per esempio, il codice
seguente crea una lista di quattro elementi di tipi di dati differenti:
>>> aList = ["John", 33, "Toronto", True]

>>> print(aList)

['John', 33, 'Toronto', True]

In Python, una lista è un modo pratico per creare strutture di dati


unidimensionali scrivibili, utili soprattutto nelle diverse fasi interne
degli algoritmi.

Utilizzo delle liste


Le funzioni di servizio delle strutture di dati ne facilitano molto
l’utilizzo, in quanto possono essere impiegate per gestire i dati
contenuti nelle liste stesse.
Vediamo come possiamo usarle.
Indicizzazione della lista: poiché in una lista la posizione di un
elemento è deterministica, per ottenere un elemento che si trova
in una determinata posizione può essere utilizzato il suo indice. Il
codice seguente illustra questo concetto:
>>> bin_colors = ['Red', 'Green', 'Blue', 'Yellow']

>>> bin_colors[1]

'Green'

La Figura 2.1 mostra la lista di quattro elementi creata da questo


codice.
Notate che l’indice parte da 0 e quindi l’indice 1 estrae Green, che è
il secondo elemento della lista, ovvero bin_colors[1].
Slicing della lista: il recupero di un sottoinsieme degli elementi di
una lista specificando un intervallo di indici è detto slicing. Per
creare una sezione della lista può essere utilizzato il codice
seguente:
>>> bin_colors = ['Red', 'Green', 'Blue', 'Yellow']

>>> bin_colors[0:2]

['Red', 'Green']
Figura 2.1

È da notare che le liste sono una delle strutture di dati


unidimensionali più utilizzate in Python.
Esaminiamo il seguente frammento di codice:
>>> bin_colors = ['Red', 'Green', 'Blue', 'Yellow']

>>> bin_colors[2:]

['Blue', 'Yellow']

>>> bin_colors[:2]

['Red', 'Green']

Quando l’indice iniziale non è specificato, si parte dall’inizio


della lista e quando l’indice finale non è specificato, si termina
alla fine della lista. Il codice precedente mostra esattamente
questo concetto.

NOTA
Durante lo slicing di una sezione di una lista, l’intervallo va indicato come
segue: il primo numero (che va incluso) e il secondo numero (che NON va
incluso) separati dal segno “:”. Per esempio, bin_colors[0:2] includerà
bin_colors[0] e bin_colors[1] ma non bin_colors[2]. Durante l’utilizzo delle
liste, occorre tenerlo bene a mente, poiché alcuni programmatori Python si
lamentano del fatto che questo comportamento non è molto intuitivo.
Indicizzazione negativa: in Python possiamo abbiamo impiegare
indici negativi, che partono a contare dalla fine della lista. Il
concetto è illustrato nel codice seguente:
>>> bin_colors = ['Red', 'Green', 'Blue', 'Yellow']

>>> bin_colors[:-1]

['Red', 'Green', 'Blue']

>>> bin_colors[:-2]

['Red', 'Green']

>>> bin_colors[-2:-1]

['Blue']

Notate che gli indici negativi sono particolarmente utili quando


come punto di riferimento è meglio usare l’ultimo elemento
invece del primo.
Annidamento: un elemento di una lista può essere di un tipo di
dati semplice o complesso. Ciò consente di creare liste annidate.
Questa possibilità fornisce funzionalità importanti per gli
algoritmi iterativi e ricorsivi.
Esaminiamo il seguente codice, che è un esempio di una lista
annidata all’interno di un’altra lista:
>>> a = [1, 2, [100, 200, 300], 6]

>>> max(a[2])

300

>>> a[2][1]

200

Iterazione: Python consente di eseguire l’iterazione di ogni


elemento di una lista utilizzando un ciclo for. Il concetto è
illustrato nel seguente esempio:
>>> bin_colors = ['Red', 'Green', 'Blue', 'Yellow']

>>> for aColor in bin_colors:

print(aColor + " Square")

Red Square
Green Square

Blue Square

Yellow Square

Notate che il codice precedente scorre la lista e mostra ogni suo


elemento.

Funzioni lambda
Esistono molte funzioni lambda che possono essere usate per le
liste. Sono particolarmente importanti nel contesto degli algoritmi e
forniscono la possibilità di creare una funzione al volo. In letteratura
vengono anche chiamate funzioni anonime. Questo paragrafo ne
illustra l’uso.
Filtraggio dei dati: per filtrare i dati, definiamo innanzitutto un
predicato, ovvero una funzione che accetta in input un unico
argomento e restituisce un valore booleano. Il codice seguente ne
mostra l’utilizzo:
>>> list(filter(lambda x: x > 100, [-5, 200, 300, -10, 10, 1000]))

[200, 300, 1000]

Notate che, in questo codice, filtriamo con filter() una lista


utilizzando la funzione lambda, la quale specifica i criteri di
filtraggio. Una funzione di filtraggio è progettata per filtrare gli
elementi di una sequenza sulla base di un determinato criterio. In
Python una funzione di filtraggio viene solitamente realizzata con
una lambda. Oltre a filtrare liste, può essere utilizzata anche per
filtrare gli elementi di tuple o insiemi. Nel codice precedente, il
criterio è x > 100. Il codice scorrerà tutti gli elementi della lista e
filtrerà (e quindi scarterà) gli elementi che non superano questo
criterio.
Trasformazione dei dati: la funzione map() può essere utilizzata per
trasformare i dati utilizzando una funzione lambda. Un esempio è il
seguente:
>>> list(map(lambda x: x ** 2, [11, 22, 33, 44, 55]))

[121, 484, 1089, 1936, 3025]

L’uso della funzione map() insieme a una funzione lambda fornisce


funzionalità piuttosto potenti: può essere utilizzata per specificare
un trasformatore da applicare a ogni elemento della sequenza
data. Nel codice precedente, il trasformatore eleva al quadrato il
numero. Pertanto, stiamo usando la funzione map per elevare al
quadrato ogni elemento della lista.
Aggregazione dei dati: per l’aggregazione dei dati, potete
utilizzare la funzione reduce(), che applica ricorsivamente una
funzione su coppie di valori per ciascun elemento della lista:
from functools import reduce

def doSum(x1,x2):

return x1 +x2

x = reduce(doSum, [100, 122, 33, 4, 5, 6])

Notate che la funzione reduce() richiede la definizione di una


funzione di aggregazione dei dati. La funzione di aggregazione
dei dati nel codice precedente è doSum() e definisce come
aggregherà gli elementi della lista data. L’aggregazione partirà dai
primi due elementi, che verranno sostituiti dal risultato. Questo
processo di riduzione viene ripetuto fino a raggiungere la fine,
producendo un numero aggregato. x1 e x2 nella funzione doSum()
rappresentano i due numeri di ciascuna di queste iterazioni e
doSum() definisce il loro criterio di aggregazione.

Il blocco di codice precedente produce un singolo valore: 270.


La funzione range
La funzione range() può essere utilizzata per generare con facilità un
ampio elenco di numeri. Viene impiegata per popolare
automaticamente le sequenze di numeri che compongono una lista.
La funzione range() è molto semplice da usare. Possiamo impiegarla
semplicemente specificando il numero di elementi che vogliamo che
siano presenti nella lista. Per default, parte da 0 e aumenta di 1:
>>> x = range(6)

>>> x

[0, 1, 2, 3, 4, 5]

Possiamo anche specificare il numero iniziale, il numero finale e il


passo:
>>> oddNum = range(3, 29, 2)

>>> oddNum

[3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27]

In pratica la funzione range() precedente darà tutti i numeri dispari da


3 a 29.

La complessità, in termini di tempo, delle operazioni sulle


liste
La complessità in termini di tempo delle varie funzioni per liste può
essere riassunta come segue, utilizzando la notazione Big O.
Operazione Complessità in termini di tempo
Inserimento di un elemento O(1)

O(n) (nel caso peggiore è necessario iterare


Eliminazione di un elemento
l’intera lista)
Slicing (porzionamento) di una O(n)
lista
Estrazione di un elemento O(n)

Copiare una lista O(n)


Tenete presente che il tempo impiegato per aggiungere un singolo
elemento è indipendente dalla dimensione della lista. Le altre
operazioni menzionate nella tabella dipendono invece dalla
dimensione della lista: a mano a mano che aumenta, l’impatto sulle
prestazioni diventa maggiore.

Tuple
La seconda struttura di dati che può essere utilizzata per
memorizzare una collezione di dati è la tupla. A differenza delle liste,
le tuple sono strutture di dati immutabili (ovvero sono di sola lettura).
Le tuple sono costituite da diversi elementi racchiusi fra parentesi
tonde ().
Come le liste, gli elementi di una tupla possono essere di tipi di dati
differenti e anche di tipi di dati complessi. Quindi, gli elementi di una
tupla possono benissimo essere ulteriori tuple, creando così una
struttura di dati annidata. La capacità di creare strutture di dati
annidate è particolarmente utile negli algoritmi iterativi e ricorsivi.
Il codice seguente mostra come creare tuple:
>>> bin_colors = ('Red', 'Green', 'Blue', 'Yellow')

>>> bin_colors[1]

'Green'

>>> bin_colors[2:]

('Blue', 'Yellow')

>>> bin_colors[:-1]

('Red', 'Green', 'Blue')

# Tupla annidata

>>> a = (1, 2, (100, 200, 300), 6)

>>> max(a[2])

300

>>> a[2][1]

200
NOTA
Ove possibile, per motivi prestazionali le strutture di dati immutabili (come le
tuple) dovrebbero essere preferite rispetto alle strutture di dati mutabili (come le
liste). Soprattutto quando si tratta di big data, le strutture di dati immutabili sono
notevolmente più veloci di quelle mutabili. C’è un prezzo da pagare per la
possibilità di modificare gli elementi dei dati contenuti nelle liste, e dovremmo
analizzare attentamente ciò di cui abbiamo davvero bisogno, in modo da poter
implementare il codice come tuple di sola lettura, per rendere il tutto molto più
veloce.

Notate che, nel codice precedente, a[2] fa riferimento al terzo


elemento, che è a sua volta una tupla, (100, 200, 300) . E a[2][1] fa
riferimento al secondo elemento di questa tupla, che è 200.

La complessità, in termini di tempo, delle operazioni sulle


tuple
La complessità in termini di tempo delle varie funzioni che operano
sulle tuple può essere riassunta come segue (usando la notazione Big
O).
Operazione Complessità in termini di tempo
append O(1)

Notate che la funzione append() aggiunge un elemento alla fine della


tupla (che deve esistere). La sua complessità è O(1).

Dizionari
La possibilità di conservare i dati come coppie chiave-valore è
importante, soprattutto nel caso degli algoritmi distribuiti. In Python,
una collezione di queste coppie chiave-valore viene memorizzata come
una struttura di dati chiamata dizionario. Per creare un dizionario,
occorre scegliere come chiave l’attributo più adatto a identificare i dati
durante l’elaborazione. Il valore può essere un elemento di qualsiasi
tipo, per esempio un numero o una stringa o può anche essere di un
tipo di dati complesso, per esempio una lista. È anche possibile creare
dizionari annidati, utilizzando come valore un ulteriore dizionario.
Per creare un semplice dizionario, che assegni dei colori ad alcune
variabili, le coppie chiave-valore devono essere racchiuse fra parentesi
graffe, { }. Per esempio, il codice seguente crea un semplice dizionario
composto da tre coppie chiave-valore:
>>> bin_colors = {

"manual_color": "Yellow",

"approved_color": "Green",

"refused_color": "Red"

>>> print(bin_colors)

{'manual_color': 'Yellow', 'approved_color': 'Green', 'refused_color': 'Red'}

Le tre coppie chiave-valore create dalla parte di codice precedente


sono illustrate nella Figura 2.2.

Figura 2.2

Vediamo ora come operare sul valore associato a una chiave.


1. Per estrarre il valore associato a una chiave, potete utilizzare la
funzione get o impiegare la chiave come un indice:
>>> bin_colors.get('approved_color')
'Green'
>>> bin_colors['approved_color']

'Green'

2. Per modificare il valore associato a una chiave, potete utilizzare il


seguente codice:
>>> bin_colors['approved_color'] = "Purple"

>>> print(bin_colors)

{'manual_color': 'Yellow', 'approved_color': 'Purple', 'refused_color':


'Red'}

Notate che il codice precedente mostra come modificare il valore


corrispondente a una determinata chiave in un dizionario.

La complessità, in termini di tempo, delle operazioni sui


dizionari
La tabella seguente mostra la complessità in termini di tempo delle
operazioni su un dizionario, in notazione Big O.
Operazione Complessità in termini di tempo
Estrarre un valore o una chiave O(1)

Modificare un valore o una chiave O(1)

Copiare un dizionario O(n)

Una cosa importante da notare dall’analisi della complessità del


dizionario è che il tempo impiegato per estrarre o modificare un valore
o una chiave è totalmente indipendente dalla dimensione del
dizionario. Ciò significa che il tempo impiegato per aggiungere una
coppia chiave-valore a un dizionario di dimensione 3 o di dimensione
un milione è lo stesso.

Insiemi
Un insieme è definito come una collezione di elementi che possono
essere di diverso tipo. Gli elementi sono racchiusi fra parentesi graffe:
{ }. Per esempio, osservate il seguente blocco di codice:

>>> green = {'grass', 'leaves'}

>>> print(green)

{'grass', 'leaves'}

La caratteristica distintiva di un insieme è che memorizza un solo


valore per ciascun elemento: se provassimo ad aggiungere un elemento
già presente nell’insieme, questa operazione verrà ignorata, come
illustrato di seguito:
>>> green = {'grass', 'leaves', 'leaves'}

>>> print(green)

{'grass', 'leaves'}

Per mostrare il tipo di operazioni che possiamo eseguire sugli


insiemi, definiamo due insiemi:
un insieme di nome yellow, che contiene oggetti gialli;
un altro insieme di nome red, che contiene oggetti rossi.

Notate che alcuni oggetti sono comuni fra questi due insiemi. I due
insiemi e la loro relazione possono essere rappresentati con l’aiuto del
diagramma di Venn rappresentato nella Figura 2.3.
Figura 2.3

Per implementare questi due insiemi in Python, utilizzeremo del


codice simile a questo:
>>> yellow = {'dandelions', 'fire hydrant', 'leaves'}

>>> red = {'fire hydrant', 'blood', 'rose', 'leaves'}

Ora consideriamo il codice seguente, che mostra il funzionamento


delle operazioni sugli insiemi in Python:
>>> yellow | red

{'dandelions', 'fire hydrant', 'blood', 'rose', 'leaves'}

>>> yellow & red

{'fire hydrant'}

Come mostra il frammento di codice precedente, in Python gli


insiemi sono dotati delle operazioni di unione e intersezione. Come
sappiamo, un’operazione di unione di due insiemi combina gli
elementi di entrambi gli insiemi e l’operazione di intersezione di due
insiemi restituisce l’insieme degli elementi comuni ai due insiemi.
Notate quanto segue:
yellow | red si utilizza per ottenere l’unione dei due insiemi
precedentemente definiti;
yellow & red si utilizza per ottenere l’intersezione fra gli insiemi

yellow e red.

Analisi della complessità, in termini di tempo, delle


operazioni sugli insiemi
Di seguito è riportata l’analisi della complessità in termini di tempo
delle operazioni sugli insiemi.
Operazione Complessità
Aggiunta di un elemento O(1)

Rimozione di un elemento O(1)

Copia di un insieme O(n)

Una cosa importante da notare nell’analisi della complessità degli


insiemi è che il tempo impiegato per aggiungere un elemento è
totalmente indipendente dalla dimensione dell’insieme.

Dataframe
Un dataframe è una struttura di dati utilizzata per memorizzare dati
tabulari e disponibile nel pacchetto pandas di Python. È una delle
strutture di dati più importanti per gli algoritmi e viene normalmente
utilizzata per elaborare dati strutturati. Consideriamo la seguente
tabella.
id name age decision
1 Fares 32 True
2 Elena 23 False
3 Steven 40 True

Ora, proviamo a rappresentare questi dati usando un dataframe. Un


semplice dataframe può essere creato utilizzando il seguente codice:
>>> import pandas as pd

>>> df = pd.DataFrame([

... ['1', 'Fares', 32, True],

... ['2', 'Elena', 23, False],

... ['3', 'Steven', 40, True]])

>>> df.columns = ['id', 'name', 'age', 'decision']

>>> df

id name age decision

0 1 Fares 32 True

1 2 Elena 23 False

2 3 Steven 40 True

Notate che, nel codice precedente, df.column è una lista che specifica
il nome delle varie colonne.
NOTA
Il dataframe viene utilizzato anche in molti altri linguaggi e framework per
implementare strutture di dati tabulari. Vi sono esempi di framework in R e
Apache Spark.

Terminologia per i dataframe


Esaminiamo ora la terminologia utilizzata nel contesto dei
dataframe.
Asse (axis): nella documentazione di pandas, una singola colonna o
riga di un dataframe è chiamata asse.
Etichetta (label): un dataframe consente di denominare le colonne
e le righe con un’etichetta.

Creazione di un sottoinsieme di un dataframe


Fondamentalmente, ci sono due modi per creare un sottoinsieme di
un dataframe (supponiamo che il nome del sottoinsieme sia myDF):

selezione delle colonne;


selezione delle righe.
Vediamoli uno per uno.

Selezione delle colonne


Negli algoritmi di machine learning, la selezione del giusto insieme
di feature è un compito importante. Di tutte le feature che potremmo
avere, non tutte potrebbero essere necessarie in una determinata fase
dell’algoritmo. In Python, la selezione delle feature è ottenuta tramite
la selezione delle colonne.
Una colonna può essere recuperata per nome, come di seguito:
>>> df[['name', 'age']]

name age

0 Fares 32

1 Elena 23

2 Steven 40

In un dataframe il posizionamento di una colonna è deterministico.


Una colonna può essere recuperata per posizione, come segue:
>>> df.iloc[:, 3]

0 True

1 False

2 True

Notate che, in questo codice, stiamo estraendo le prime tre righe del
dataframe.

Selezione delle righe


Ogni riga di un dataframe corrisponde a un punto dei dati nello
spazio del problema. Dobbiamo eseguire una selezione delle righe se
vogliamo creare un sottoinsieme dei punti dei dati nello spazio del
problema. Questo sottoinsieme può essere creato utilizzando uno dei
due metodi seguenti:
specificando la loro posizione;
specificando un filtro.
Un sottoinsieme di righe può essere recuperato per posizione come
segue:
>>> df.iloc[1:3,:]

id name age decision

1 2 Elena 23 False

2 3 Steven 40 True

Notate che il codice precedente restituisce tutte le colonne delle


prime due righe.
Per creare un sottoinsieme specificando un filtro, è necessario
utilizzare una o più colonne per definire il criterio di selezione. Per
esempio, un sottoinsieme di punti dei dati può essere selezionato come
segue:
>>> df[df.age > 30]
id name age decision
0 1 Fares 32 True
2 3 Steven 40 True

>>> df[(df.age < 35) & (df.decision == True)]


id name age decision

0 1 Fares 32 True

Notate che questo codice crea un sottoinsieme di righe che soddisfa


la condizione specificata nel filtro.

Matrici
Una matrice è una struttura di dati bidimensionale con un numero
fisso di colonne e righe. Ogni elemento di una matrice può essere
identificato per colonna e per riga.
In Python, è possibile creare una matrice utilizzando un array numpy,
come mostrato nel codice seguente:
>>> myMatrix = np.array([[11, 12, 13], [21, 22, 23], [31, 32, 33]])

>>> print(myMatrix)

[[11 12 13]
[21 22 23]

[31 32 33]]

>>> print(type(myMatrix))

<class 'numpy.ndarray'>

Notate che il codice precedente crea una matrice composta da tre


righe e tre colonne.

Operazioni con le matrici


Sono molte le operazioni disponibili per la manipolazione dei dati di
una matrice. Per esempio, proviamo a trasporre la matrice precedente.
Useremo la funzione transpose(), che convertirà le colonne in righe e
viceversa:
>>> myMatrix.transpose()

array([[11, 21, 31],

[12, 22, 32],

[13, 23, 33]]))

Notate che le operazioni con le matrici sono molto utilizzate nella


manipolazione dei dati multimediali.
Ora che abbiamo imparato a conoscere le strutture di dati disponibili
in Python, nel prossimo paragrafo passiamo ai tipi di dati astratti.

I tipi di dati astratti in Python


L’astrazione, in generale, è un concetto utilizzato per definire
sistemi complessi in termini delle loro funzioni fondamentali e
comuni. L’uso di questo concetto per creare strutture di dati generiche
ha dato vita ai tipi di dati astratti. Celando i dettagli implementativi e
fornendo all’utente una struttura di dati generica e indipendente
dall’implementazione, l’uso di tipi di dati astratti permette di creare
algoritmi in un codice che risulta più semplice e pulito. I tipi di dati
astratti possono essere implementati in qualsiasi linguaggio di
programmazione, come C++, Java e Scala, ma in questo paragrafo
implementeremo i tipi di dati astratti usando Python. Cominciamo con
i vettori.

Vettori
Un vettore è una struttura monodimensionale per la memorizzazione
dei dati ed è una delle strutture di dati più utilizzate in Python.
Esistono due modi per creare vettori in Python.
Utilizzando una lista Python: questo è il modo più semplice per
creare un vettore.
>>> myVector = [22, 33, 44, 55]

>>> print(myVector)

[22 33 44 55]

>>> print(type(myVector))

<class 'list'>

Questo codice crea una lista di quattro elementi.


Utilizzo di un array numpy: questo è un altro modo molto utilizzato
per creare un vettore.
>>> myVector = np.array([22, 33, 44, 55])

>>> print(myVector)

[22 33 44 55]

>>> print(type(myVector))

<class 'numpy.ndarray'>

Notate che con questo codice abbiamo creato myVector a partire da


np.array .

NOTA
In Python, possiamo rappresentare interi usando i caratteri di sottolineatura per
separare le parti. Questo rende i numeri più leggibili e meno soggetti a errori.
Ciò è particolarmente utile quando si ha a che fare con numeri molto grandi.
Quindi, in Python un miliardo (mille milioni, 1.000.000.000) può essere
rappresentato come a = 1_000_000_000.

Stack
Uno stack è una struttura di dati lineare adatta a memorizzare una
lista unidimensionale. Può memorizzare i dati in modalità Last-In,
First-Out (LIFO), mentre le code operano in modalità First-In, Last-
Out (FILO). La caratteristica distintiva di uno stack è il modo in cui gli
elementi vengono aggiunti e rimossi. Un nuovo elemento può essere
aggiunto o rimosso solo da un’estremità.
Di seguito le operazioni relative agli stack.
isEmpty() : restituisce true se lo stack è vuoto.
push() : aggiunge un nuovo elemento.
: restituisce l’ultimo elemento aggiunto e lo rimuove.
pop()

La Figura 2.4 mostra come utilizzare le operazioni push() e pop() per


aggiungere e rimuovere dati da uno stack.
Figura 2.4

La parte superiore della Figura 2.4 mostra l’uso delle operazioni


push() per aggiungere elementi allo stack. Nei passaggi 1.1, 1.2 e 1.3, le

operazioni push() vengono utilizzate tre volte per aggiungere allo stack
altrettanti elementi. La parte inferiore della figura viene utilizzata per
recuperare i valori archiviati dallo stack. Nei passaggi 2.2 e 2.3, le
operazioni pop() vengono utilizzate per recuperare due elementi dallo
stack, in formato LIFO.
Creiamo in Python una classe chiamata Stack, sulla quale definiremo
tutte le operazioni relative a un tipico stack. Il codice di questa classe è
il seguente:
class Stack:

def __init__(self):

self.items = []

def isEmpty(self):

return self.items == []

def push(self, item):


self.items.append(item)

def pop(self):

return self.items.pop()

def peek(self):

return self.items[len(self.items) – 1]

def size(self):

return len(self.items)

Per inserire nello stack quattro elementi, si usa il seguente codice:

Il codice precedente crea uno stack, vi inserisce (push()) quattro punti


dei dati, poi estrae (pop()) l’ultimo elemento e infine controlla se lo
stack è vuoto (isEmpty()).

La complessità, in termini di tempo, delle operazioni sugli


stack
Esaminiamo la complessità in termini di tempo delle operazioni
sugli stack (usando la notazione Big O).
Operazioni Complessità
push O(1)
pop O(1)
size O(1)
peek O(1)

Una cosa importante da notare è che le prestazioni delle quattro


operazioni menzionate nella tabella precedente non dipendono dalla
dimensione dello stack.

Esempi d’uso
Uno stack viene utilizzato come struttura di dati in molte situazioni.
Per esempio, quando un utente desidera sfogliare la cronologia in un
browser web, il modello di accesso ai dati è di tipo LIFO, perfetto per
uno stack. Un altro esempio è quando un utente desidera annullare
un’operazione di editing in un software di elaborazione testi.

Code
Come uno stack, anche una coda memorizza elementi in una
struttura unidimensionale. Gli elementi vengono aggiunti e rimossi in
formato FIFO. Gli elementi vengono pertanto aggiunti alla fine della
coda e vengono estratti dalla cima della coda. L’operazione di
rimozione degli elementi dalla cima della coda si chiama dequeue().
L’operazione di aggiunta di nuovi elementi alla fine della coda si
chiama enqueue().
Nella Figura 2.5, la parte superiore mostra l’operazione di
accodamento, enqueue(). I passaggi 1.1, 1.2 e 1.3 aggiungono (in fondo)
alla coda tre elementi e in 1.4 si può vedere la coda risultante. Notate
che Yellow rappresenta la fine della coda e Red la cima della coda.
La parte inferiore della Figura 2.5 mostra un’operazione di
rimozione dalla (cima della) coda. I passaggi 2.2, 2.3 e 2.4 rimuovono
a uno a uno gli elementi dalla cima della coda.

Figura 2.5

La coda mostrata nello schema precedente può essere implementata


utilizzando il seguente codice:
class Queue(object):

def __init__(self):

self.items = []

def isEmpty(self):

return self.items == []

def enqueue(self, item):

self.items.insert(0, item)

def dequeue(self):

return self.items.pop()

def size(self):

return len(self.items)

Di seguito vediamo le operazioni di inserimento e rimozione di


elementi a e da una coda:
Notate che il codice precedente crea una coda, vi inserisce quattro
elementi e poi estrae due elementi.

L’idea di base dietro l’uso di stack e code


Esaminiamo l’idea di base dietro l’uso di stack e code utilizzando
un’analogia. Supponiamo di avere una struttura in cui inseriamo la
posta in arrivo. La accumuliamo finché non abbiamo un po’ di tempo
per aprirla e guardarla, busta per busta. Ci sono due modi per farlo.
Mettiamo le lettere in una pila e ogni volta che riceviamo una
nuova lettera, la mettiamo in cima alla pila di lettere. Quando
vogliamo leggere una lettera, iniziamo da quella che sta sopra.
Questo è ciò che chiamiamo stack. Tenete presente che per prima
verrà letta l’ultima lettera arrivata, in alto. Prendere una lettera
dalla cima dell’elenco è l’operazione pop. Ogni volta che arriva
una nuova lettera, l’operazione di poggiarla in cima alla pila è
l’operazione push. Se finiamo per avere una pila di un’altezza
considerevole e ci arrivano molte lettere, c’è la possibilità che non
avremo mai la possibilità di raggiungere una lettera molto
importante che ci aspetta in fondo alla pila.
Mettiamo le lettera sempre in pila, ma questa volta vogliamo
esaminare prima la prima lettera arrivata: ogni volta che abbiamo
il tempo di occuparci della corrispondenza, ci preoccupiamo di
estrarre prima quella più vecchia. Questa è ciò che chiamiamo
coda. L’aggiunta di una lettera alla coda è chiamata enqueue,
accodamento. La rimozione della lettera dalla coda è chiamata
dequeue.

Alberi
Nel contesto degli algoritmi, un albero è una delle strutture di dati
più utili, grazie alle sue capacità di archiviazione gerarchica dei dati.
Nella progettazione di nuovi algoritmi, utilizziamo alberi ovunque sia
necessario rappresentare relazioni gerarchiche fra i punti dei dati che
dobbiamo memorizzare o elaborare.
Vediamo meglio questa struttura di dati così interessante e
importante.
Ogni albero ha un insieme finito di nodi, con un elemento di
partenza, la radice, e un insieme di nodi uniti fra loro da collegamenti
chiamati rami.

Terminologia
Esaminiamo un po’ la terminologia relativa di questa struttura.
Un nodo senza genitore è detto nodo radice. Per esempio, nella
Nodo
Figura 2.7, il nodo radice è A. Negli algoritmi, di solito, il nodo radice
radice
contiene il valore più importante della struttura ad albero.
Livello del La distanza del nodo dalla radice è chiamata livello del nodo. Per
nodo esempio, nella Figura 2.7, il livello dei nodi D, E ed F è 2.
Due nodi di un albero sono chiamati fratelli se si trovano allo stesso
Nodi fratelli livello e hanno lo stesso genitore. Per esempio, se osserviamo la
Figura 2.7, i nodi B e C sono fratelli.
Il nodo F è figlio del nodo C, se i nodi sono collegati direttamente e se
Nodo figlio il livello del nodo C è minore del livello del nodo F. Viceversa, il nodo
e genitore C è genitore del nodo F. I nodi C e F nella Figura 2.7 sono un
esempio di questa relazione genitore-figlio.
Grado di Il grado di un nodo è il numero di figli che ha. Per esempio, nella
un nodo Figura 2.7, il nodo B ha grado 2.
Il grado di un albero è uguale al massimo grado che si può trovare
Grado di
nei nodi che lo costituiscono. Per esempio, l’albero presentato nella
un albero
Figura 2.7 ha grado 2.
È una porzione di un albero, composto da un nodo (che diventa
quindi il nodo radice del sottoalbero) e da tutti i suoi figli. Per
Sottoalbero
esempio, il sottoalbero del nodo E dell’albero presentato nella Figura
2.7 è costituito dal nodo E (nodo radice) e dai nodi G e H (nodi figli).
In un albero, è un nodo senza figli. Per esempio, nella Figura 2.7, D,
Nodo foglia
G, H e F sono quattro nodi foglia.
Nodo Qualsiasi nodo che non sia né radice né nodo foglia. Un nodo
interno interno, pertanto, avrà un nodo padre e almeno un nodo figlio.

NOTA
Gli alberi sono un tipo particolare di rete o grafo, argomento che studieremo nel
Capitolo 6. Per l’analisi di grafi e reti, usiamo i termini link (collegamento) o
edge (spigolo) invece di branch (ramo). La rimanente terminologia resta
sostanzialmente invariata.

Tipi di alberi
Esistono diversi tipi di alberi, catalogati come segue.
Albero binario: se il grado dell’albero è 2, l’albero è detto binario.
Per esempio, quello rappresentato nella Figura 2.6 è un albero
binario, in quanto ha grado 2.
Notate che la Figura 2.6 mostra un albero che ha quattro livelli e
otto nodi.

Figura 2.6

Albero completo: è un albero in cui tutti i nodi hanno lo stesso


grado, che poi rappresenta anche il grado dell’albero stesso.
La Figura 2.7 mostra vari tipi di alberi e nodi.

Figura 2.7
Notate che nella Figura 2.7 l’albero binario a sinistra non è
completo, poiché il nodo C ha grado 1 e tutti gli altri nodi hanno grado
2. L’albero al centro e quello a sinistra sono entrambi alberi completi.
Albero perfetto: è un tipo speciale di albero completo in cui tutti i
nodi foglia si trovano allo stesso livello. Per esempio, l’albero
binario a destra della Figura 2.7 è perfetto e completo, poiché tutti
i nodi foglia sono allo stesso livello, ovvero al livello 2.
Albero ordinato: i figli di un nodo sono organizzati secondo un
certo ordine, in base a specifici criteri. Per esempio, un albero può
essere ordinato da sinistra a destra in modo crescente: i suoi nodi
allo stesso livello aumenteranno di valore spostandosi da sinistra
a destra.

Esempi d’uso
L’albero è una delle principali strutture utilizzate per lo sviluppo di
alberi decisionali, come vedremo nel Capitolo 7. A causa della sua
struttura gerarchica, l’albero è molto utilizzato anche negli algoritmi di
analisi delle reti, come vedremo in dettaglio nel Capitolo 6. Gli alberi
vengono utilizzati anche in molti algoritmi di ricerca e ordinamento, in
cui devono essere implementate strategie divide et impera.

Riepilogo
In questo capitolo abbiamo trattato le strutture di dati che possono
essere utilizzate per implementare i vari tipi di algoritmi. Con lo studio
di questo capitolo, dovreste essere in grado di selezionare la struttura
di dati corretta da utilizzare per memorizzare ed elaborare i dati
mediante un algoritmo, e quindi dovreste anche essere in grado di
comprendere le motivazioni alla base delle nostre scelte in termini di
prestazioni dell’algoritmo.
Il prossimo capitolo tratta gli algoritmi di ordinamento e ricerca, per
i quali utilizzeremo alcune delle strutture di dati presentate in questo
capitolo.
Capitolo 3

Algoritmi di ordinamento e
ricerca

In questo capitolo esamineremo vari algoritmi utilizzati per


l’ordinamento e la ricerca. Questa è un’importante classe di algoritmi
che possono essere usati come sono o come supporto per algoritmi più
complessi, presentati nei prossimi capitoli. Questo capitolo inizia
presentando i diversi tipi di algoritmi di ordinamento e confrontando le
prestazioni dei vari approcci alla progettazione di un algoritmo di
ordinamento. Successivamente, esamineremo in dettaglio alcuni
algoritmi di ricerca. Infine, esploreremo un esempio pratico di utilizzo
degli algoritmi di ordinamento e ricerca presentati in questo capitolo.
Alla fine di questo capitolo, sarete in grado di comprendere i vari
algoritmi utilizzati per l’ordinamento e la ricerca e i loro punti di forza
e punti deboli. Poiché gli algoritmi di ricerca e ordinamento sono gli
elementi costitutivi della maggior parte degli algoritmi più complessi,
conoscerli nei dettagli vi aiuterà a comprendere anche gli algoritmi più
complessi e recenti.

Introduzione agli algoritmi di


ordinamento
Nell’era dei big data, la capacità di ordinare e cercare in modo
efficiente gli elementi contenuti in una struttura di dati complessa è
piuttosto importante, in quanto è una funzionalità richiesta da molti
altri algoritmi moderni. Come vedremo in questo capitolo, la giusta
strategia per ordinare e cercare i dati dipenderà dalla dimensione e dal
tipo dei dati. Sebbene il risultato finale sia esattamente lo stesso, sarà
necessario impiegare il giusto algoritmo di ordinamento e ricerca per
avere una soluzione efficiente di un problema concreto.
In questo capitolo esamineremo i seguenti algoritmi di ordinamento:
bubble sort;
merge sort;
insertion sort;
shell sort;
selection sort.

Scambiare le variabili in Python


Nell’implementazione di algoritmi di ordinamento e ricerca, è
necessario poter scambiare il valore di due variabili. In Python, c’è un
modo semplice per scambiare due variabili, che è il seguente:
var1 = 1

var2 = 2

var1, var2 = var2, var1

Vediamo se ha funzionato:

Questo semplice modo di scambiare i valori viene utilizzato da tutti


gli algoritmi di ordinamento e ricerca riportati in questo capitolo.
Iniziamo esaminando l’algoritmo bubble sort, nel prossimo
paragrafo.
Bubble sort: ordinamento “a bolle”
Bubble sort è l’algoritmo più semplice e lento utilizzabile per
l’ordinamento. È progettato in modo che il valore maggiore nella lista
raggiunga la cima mentre l’algoritmo esegue il suo ciclo di iterazioni.
Poiché la sua prestazione, come abbiamo detto nel caso peggiore è
O(N 2), dovrebbe essere utilizzato per dataset piuttosto piccoli.

La logica alla base del bubble sort


Il bubble sort si basa su varie iterazioni o passi. Per una lista di
dimensione N, il bubble sort svolgerà N – 1 passi. Concentriamoci sul
primo passo.
L’obiettivo del primo passo è quello di spingere in cima alla lista il
valore maggiore. Vedremo pertanto il valore maggiore della lista
“ribollire” verso l’alto a mano a mano che procedono i passi.
Il bubble sort confronta due valori adiacenti. Se il valore maggiore
precede il valore successivo, scambia i valori. Questo passo continua
fino a raggiungere la fine della lista. Ciò è rappresentato nella Figura
3.1.
Figura 3.1

Vediamo ora come implementare il bubble sort usando Python:


#Passo 1 del bubble sort

lastElementIndex = len(list) – 1

print(0, list)

for idx in range(lastElementIndex):

if list[idx] > list[idx + 1]:

list[idx], list[idx + 1] = list[idx + 1], list[idx]

print(idx + 1, list)

Se implementiamo il primo passo del bubble sort in Python, il


codice avrà il seguente aspetto:
Una volta completato il primo passo, il valore maggiore sarà in cima
alla lista. L’algoritmo passa quindi al secondo passo. Il suo obiettivo è
quello di spostare il secondo valore più elevato nella seconda
posizione della lista. Per fare ciò, l’algoritmo confronterà nuovamente
i valori adiacenti, scambiandoli se non sono in ordine. Il secondo passo
escluderà l’elemento superiore, che è già stato messo nel posto giusto
dal primo passo e non deve più essere toccato.
Dopo aver completato il secondo passo, l’algoritmo continua a
eseguire il terzo passo e così via, finché tutti i punti dei dati della lista
si trovano in ordine crescente. L’algoritmo avrà bisogno di N – 1 passi
per ordinare completamente una lista di dimensione N.
L’implementazione completa del bubble sort in Python è la seguente:

Ora esaminiamo le prestazioni dell’algoritmo bubble sort.


Un’analisi delle prestazioni dell’algoritmo bubble sort
È più facile considerare che il bubble sort prevede due livelli di
cicli.
Un ciclo esterno: questi sono i passi dell’algoritmo. Per esempio,
il passo 1 è la prima iterazione del ciclo esterno.
Un ciclo interno: è quello che ordina gli elementi rimanenti nella
lista, fino a quando il valore maggiore non “risale” verso destra. Il
primo passo eseguirà N – 1 confronti, il secondo passo avrà N – 2
confronti e ogni passo successivo ridurrà il numero di confronti di
1.
A causa dei due livelli di cicli, la complessità di esecuzione nel caso
peggiore sarebbe O(n2).

Insertion sort
L’idea di base dell’“ordinamento per inserimento” è che in ogni
iterazione, rimuoviamo dalla struttura di dati un punto e lo inseriamo
nella posizione corretta. Nella prima iterazione, selezioniamo due
punti e li ordiniamo. Poi espandiamo la selezione con il terzo punto e
lo inseriamo nella posizione corretta. L’algoritmo avanza fino a
spostare tutti i punti nelle posizioni corrette. Questo processo è
rappresentato nella Figura 3.2.
Figura 3.2

L’algoritmo insertion sort può essere programmato in Python come


segue:
def InsertionSort(list):

for i in range(1, len(list)):

j = i- 1

element_next = list[i]

while (list[j] > element_next) and (j >= 0):

list[j+1] = list[j]

j=j- 1

list[j+1] = element_next

return list

Notate che nel ciclo principale, iteriamo sull’intera lista. A ogni


iterazione, i due elementi adiacenti sono list[j] (l’elemento corrente) e
list[i] (l’elemento successivo).
In list[j] > element_next and j >= 0, confrontiamo l’elemento corrente
con l’elemento successivo.
Proviamo a impiegare questo codice per ordinare un array:
Valutiamo le prestazioni dell’algoritmo insertion sort.
È ovvio, dalla descrizione dell’algoritmo, che se la struttura dei dati
è già ordinata, l’insertion sort funzionerà molto velocemente, con un
tempo di esecuzione lineare: O(n). Il caso peggiore si ha quando
ciascuno dei cicli interni deve spostare tutti gli elementi della lista. Se
il ciclo interno è definito da i, la prestazione nel caso peggiore
dell’algoritmo insertion sort è data dalla seguente formula:

In generale, l’algoritmo insertion sort può essere utilizzato su


piccole strutture di dati. Per strutture più grandi, l’algoritmo è
sconsigliato a causa delle sue prestazioni medie: quadratiche.

Merge sort
Finora abbiamo presentato due algoritmi di ordinamento: il bubble
sort e l’insertion sort, le cui prestazioni sono migliori se i dati sono già
almeno parzialmente ordinati. Il terzo algoritmo presentato in questo
capitolo è merge sort, sviluppato nel 1940 da John von Neumann. La
caratteristica distintiva di questo algoritmo è che le sue prestazioni non
dipendono dal fatto che i dati di input siano ordinati. Come
MapReduce e altri algoritmi per big data, si basa su una strategia
divide et impera. Nella prima fase, denominata splitting, l’algoritmo
continua a dividere ricorsivamente i dati in due parti, fino a quando la
dimensione dei dati è inferiore alla soglia definita. Nella seconda fase,
chiamata merging, l’algoritmo continua a unire ed elaborare i dati fino
a produrre il risultato finale. La logica di questo algoritmo è spiegata
nella Figura 3.3.
Esaminiamo innanzitutto lo pseudocodice dell’algoritmo di merge
sort:
mergeSort(list, start, end)

if(start < end)

midPoint = (end – start) / 2 + start

mergeSort(list, start, midPoint)

mergeSort(list, midPoint + 1, start)

merge(list, start, midPoint, end)

Come possiamo vedere, l’algoritmo ha i seguenti tre passaggi.


1. Divide la lista di input in due parti uguali.
2. Usa la ricorsione per dividere la lista fino a quando la lunghezza
di ogni lista è 1.
3. Unisce le parti ordinate in una lista ordinata e la restituisce.
Figura 3.3

Nella pagina seguente è riportato il codice da impiegare per


l’implementazione dell’algoritmo merge sort.
Questo codice Python 8 genera il seguente output:

Notate che il risultato è una lista ordinata.

Shell sort
L’algoritmo bubble sort confronta gli elementi vicini e li scambia se
necessario. Se abbiamo una lista parzialmente ordinata, il bubble sort
dovrebbe fornire prestazioni ragionevoli, poiché uscirà non appena non
si verificherà più alcuno scambio di elementi in un ciclo.
Ma per una lista completamente non ordinata, di dimensioni N,
possiamo dire che il bubble sort dovrà iterare completamente N – 1
passaggi per ordinarla completamente.
Donald Shell ha proposto l’ordinamento che da lui prende il nome,
shell sort, chiedendosi se fosse il caso di selezionare gli elementi
immediatamente vicini per le operazioni di confronto e scambio.
Ora, cerchiamo di capire questo concetto. Nel primo passaggio,
invece di selezionare due elementi vicini, utilizziamo elementi che si
trovano a un gap fisso, ordinando eventualmente una sottolista
costituita da una coppia di punti. La situazione è mostrata nella Figura
3.4. Nel secondo passaggio, lo Shell sort ordina sottoliste contenenti
quattro elementi. Nei passaggi successivi, il numero di elementi per
sottolista continua ad aumentare e il numero di sottoliste continua a
diminuire, fino a raggiungere una situazione in cui c’è una sola
sottolista costituita dall’intera lista di tutti gli elementi. A questo
punto, possiamo supporre che la lista sia ordinata.
Figura 3.4

In Python, il codice per implementare l’algoritmo di ordinamento


Shell è il seguente:
def ShellSort(list):

distance = len(list) // 2

while distance > 0:

for i in range(distance, len(list)):

temp = input_list[i]

j = i

# Ordina la sottolista per questa distanza

while j >= distance and list[j – distance] > temp:

list[j] = list[j – distance]

j = j-distance

list[j] = temp

# Riduce la distanza dall'elemento successivo


distance = distance// 2

return list

Il codice precedente può essere utilizzato per ordinare la lista:

Notate che la chiamata alla funzione ShellSort() produce


l’ordinamento dell’array di input.

Analisi delle prestazioni dello Shell sort


Lo Shell sort non è adatto ai big data, ma può essere utilizzato per
dataset di medie dimensioni. In breve, offre prestazioni
ragionevolmente buone su una lista con un massimo di 6.000 elementi,
specialmente se i dati sono già parzialmente ordinati. Nel migliore dei
casi, se una lista è già ordinata, sarà necessario un solo passaggio sugli
N elementi per convalidare l’ordine, producendo una prestazione, nel
migliore dei casi, pari a O(N).

Selection sort
Come abbiamo visto in precedenza in questo capitolo, il bubble sort
è uno degli algoritmi di ordinamento più semplici. L’algoritmo
selection sort rappresenta un miglioramento del bubble sort, in cui
cerchiamo di ridurre al minimo il numero totale di scambi richiesti. È
progettato per effettuare un solo scambio per passaggio, rispetto agli N
– 1 per passaggio con l’algoritmo bubble sort. Invece di far “risalire” il
valore più grande in piccoli passi (come fa il bubble sort, con
conseguenti N – 1 scambi), in ogni passaggio cerchiamo il valore più
grande e lo spostiamo in cima. Quindi, dopo la prima iterazione, il
valore più grande sarà già in cima. Dopo il secondo passaggio, dopo il
valore maggiore si troverà il secondo valore più grande. A mano a
mano che l’algoritmo progredisce, i valori successivi si sposteranno
sempre nella posizione corretta. L’ultimo valore verrà spostato dopo
l’(N – 1)-esimo passaggio. Quindi, l’ordinamento della selezione
richiede N – 1 passaggi per ordinare N elementi (Figura 3.5).
Ecco l’implementazione dell’algoritmo selection sort in Python:
def SelectionSort(list):
for fill_slot in range(len(list) – 1, 0, -1):
max_index = 0
for location in range(1, fill_slot + 1):
if list[location] > list[max_index]:
max_index = location
list[fill_slot], list[max_index] = list[max_index], list[fill_slot]

Figura 3.5

Quando viene eseguito, l’algoritmo selection sort produrrà il


seguente output:
Notate che l’output è la lista ordinata.

Le prestazioni dell’algoritmo selection sort


La prestazione nel caso peggiore dell’algoritmo selection sort è O(N
2
). Notate che le sue prestazioni peggiori sono simili a quelle del
bubble sort e che, pertanto, questo algoritmo non dovrebbe essere
utilizzato per dataset di una certa grandezza. Tuttavia, l’algoritmo
selection sort è progettato meglio del bubble sort e le sue prestazioni
medie sono migliori a causa della riduzione del numero di scambi.

Scelta di un algoritmo di ordinamento


La scelta del corretto algoritmo di ordinamento dipende sia dalla
dimensione sia dallo stato dei dati di input. Per l’ordinamento di
piccole liste di input, un algoritmo avanzato introdurrà nel codice
elementi di complessità non necessari, con un miglioramento
trascurabile delle prestazioni. Per esempio, non è necessario utilizzare
l’algoritmo merge sort per piccoli dataset. Il bubble sort sarà molto più
facile da capire e implementare. Se i dati sono già parzialmente
ordinati, possiamo trarne vantaggio utilizzando l’algoritmo insertion
sort. Per dataset più grandi, è meglio usare l’algoritmo merge sort.

Introduzione agli algoritmi di


ricerca
La ricerca efficiente di dati conservati in strutture complesse è una
delle funzionalità più importanti di un programma. L’approccio più
semplice e, prevedibilmente, poco efficiente, consiste nel cercare i dati
richiesti in modo lineare. Ma con dati di grandi dimensioni abbiamo
bisogno di impiegare algoritmi più sofisticati e progettati in modo
specifico per accelerare la ricerca dei dati.
In questo paragrafo esamineremo i seguenti algoritmi di ricerca:
ricerca lineare;
ricerca binaria;
ricerca per interpolazione.
Esaminiamoli in modo più dettagliato.

Ricerca lineare
Una delle strategie più semplici per la ricerca di dati consiste nello
scorrere semplicemente ogni elemento, alla ricerca di quanto
desiderato. Per ogni punto dei dati viene cercata una corrispondenza, e
quando la corrispondenza viene trovata, vengono restituiti i risultati e
l’algoritmo esce dal ciclo. In caso contrario, l’algoritmo continua a
cercare fino a raggiungere la fine della struttura che ospita i dati.
L’ovvio svantaggio della ricerca lineare è che può essere molto lenta, a
causa della sua ricerca esaustiva. Il vantaggio è che i dati non devono
essere ordinati, come invece è richiesto dagli altri algoritmi presentati
in questo capitolo.
Esaminiamo il codice per la ricerca lineare:
def LinearSearch(list, item):

index = 0

found = False

# Confronta il valore con ciascun elemento

while index < len(list) and found is False:

if list[index] == item:
found = True

else:

index = index + 1

return found

Ecco l’output del codice precedente:

Notate che l’esecuzione della funzione LinearSearch() restituisce il


valore True se riesce a trovare l’elemento ricercato.

Le prestazioni della ricerca lineare


Come abbiamo visto, la ricerca lineare è un algoritmo semplice, che
effettua una ricerca esaustiva. Il suo comportamento, nel caso
peggiore, è O(N).

Ricerca binaria
Il prerequisito dell’algoritmo di ricerca binaria è che i dati siano
ordinati. L’algoritmo divide iterativamente una lista in due parti e
restringe via via gli indici inferiore e superiore fino a trovare il valore
cercato:
def BinarySearch(list, item):
first = 0
last = len(list) – 1
found = False
while first<= last and not found:
midpoint = (first + last) // 2
if list[midpoint] == item:
found = True
else:
if item < list[midpoint]:
last = midpoint- 1
else:
first = midpoint+1

return found

Nella pagina seguente è riportato l’output prodotto dal codice


precedente.

Notate che la chiamata alla funzione BinarySearch() restituisce True se


trova il valore nella lista di input.

Le prestazioni della ricerca binaria


La ricerca binaria è così chiamata perché a ogni iterazione,
l’algoritmo seziona i dati in due parti. Se i dati hanno N elementi,
l’iterazione richiederà un massimo di O(logN) passaggi. Ciò significa
che l’algoritmo ha un tempo di esecuzione pari a O(logN).

Ricerca per interpolazione


La ricerca binaria si concentra sulla sezione centrale dei dati. La
ricerca per interpolazione è più sofisticata. Utilizza il valore cercato
per stimare la sua posizione nell’array ordinato. Proviamo a capirne il
funzionamento con un esempio. Supponiamo di voler cercare in un
dizionario inglese la parola “river”. Useremo questa informazione per
interpolare e iniziare a cercare fra le parole che iniziano per “r”. Una
ricerca per interpolazione più generalizzata può essere programmata
come segue:
def IntPolsearch(list,x):
idx0 = 0
idxn = (len(list) – 1)
found = False
while idx0 <= idxn and x >= list[idx0] and x <= list[idxn]:
# Trova Il punto centrale
mid = idx0 + int(((float(idxn – idx0) / (list[idxn] – list[idx0])) * (x –
list[idx0])))
# Confronta il valore al punto centrale con Il valore cercato
if list[mid] == x:
found = True
return found
if list[mid] < x:

idx0 = mid + 1

return found

L’output è il seguente:

Notate che per poter utilizzare IntPolsearch(), l’array deve essere già
ordinato.

Le prestazioni della ricerca per interpolazione


Se i dati sono distribuiti in modo non uniforme, le prestazioni
dell’algoritmo di ricerca per interpolazione saranno scadenti. La
prestazione peggiore di questo algoritmo è O(N), ma se i dati sono
ragionevolmente uniformi, la prestazione migliore è O(log(log N)).

Applicazioni pratiche
La capacità di ricercare in modo efficiente e accurato i dati in un
determinato repository è fondamentale per molte applicazioni. A
seconda dell’algoritmo di ricerca scelto, potrebbe essere necessario
iniziare ordinando prima i dati. La scelta dei corretti algoritmi di
ordinamento e ricerca dipende dal tipo e dalla dimensione dei dati,
nonché dalla natura del problema che si sta cercando di risolvere.
Proviamo a utilizzare gli algoritmi presentati in questo capitolo per
risolvere il problema di dover accoppiare un nuovo richiedente presso
il dipartimento immigrazione di un Paese con i record storici
disponibili. Quando qualcuno richiede un visto per entrare nel Paese, il
sistema cerca di trovare il richiedente nei record storici esistenti. Se
trova almeno una corrispondenza, il sistema calcola il numero di volte
in cui l’ingresso dell’individuo è stato approvato o rifiutato in passato.
Se invece non trova alcuna corrispondenza, il sistema classifica il
nuovo richiedente e gli assegna un nuovo identificatore. La capacità di
cercare, individuare e identificare una persona nei dati storici è
fondamentale per il sistema. Queste informazioni sono importanti,
perché se l’individuo ha presentato una richiesta di visto in passato e
tale richiesta è stata rifiutata, ciò potrebbe influire negativamente sulla
sua richiesta attuale. Allo stesso modo, se la sua richiesta di visto è già
stata approvata in passato, tale approvazione può aumentare le
probabilità che anche la richiesta attuale venga approvata. In genere, il
database storico conterrà milioni di righe e avremo bisogno di una
soluzione ben progettata per individuare i richiedenti nel database
storico.
Supponiamo che la tabella storica nel database sia simile alla
seguente.
Personal Application First Decision
Surname DOB Decision
ID ID name date
2000-09- 2018-08-
45583 677862 John Doe Approved
19 07
1970-03- 2018-06-
54543 877653 Xman Xsir Rejected
10 07
1973-02- 2018-05-
34332 344565 Agro Waka Rejected
15 05
2000-09- 2018-03-
45583 677864 John Doe Approved
19 02
1975-01- 2018-04-
22331 344553 Kal Sorts Approved
02 15

In questa tabella, la prima colonna, Personal ID, è associata a ciascuno


dei richiedenti univoci presenti nel database storico. Se il database
storico contiene 30 milioni di richiedenti univoci, allora avremo 30
milioni di Personal ID univoci. Ogni Personal ID identifica quindi un
richiedente nel database storico.
La seconda colonna è Application ID . Ciascun Application ID identifica
univocamente una richiesta nel sistema. Una persona potrebbe aver
presentato domanda più di una volta, in passato. Quindi, questo
significa che nel database storico avremo più Application ID univoci
rispetto ai Personal . John Doe avrà un solo Personal
ID ID ma può avere
più Application , come mostra la tabella precedente.
ID

La tabella precedente mostra solo un piccolo estratto del dataset


storico, ma supponiamo che contenga quasi un milione di righe,
corrispondenti ai record degli ultimi dieci anni di richieste. Le nuove
richieste arrivano continuamente a un ritmo medio di circa due al
minuto. Per ogni richiedente, dobbiamo fare quanto segue.
Rilasciare un nuovo Application ID per il richiedente.
Vedere se troviamo quel richiedente nel database storico.
Se troviamo una corrispondenza, dobbiamo utilizzare il suo
Personal ID, trovato nel database storico. Nel database storico

dobbiamo anche determinare quante volte la sua richiesta è stata


approvata o respinta.
Se non troviamo alcuna corrispondenza, dobbiamo emettere un
nuovo Personal ID per quella persona.

Supponiamo che arrivi una nuova persona con le seguenti


credenziali:
First Name: John
Surname: Doe

DOB: 2000-09-19

Ora, come possiamo progettare un’applicazione in grado di eseguire


una ricerca efficiente ed economicamente vantaggiosa?
Una strategia di ricerca per la nuova applicazione può essere ideata
come segue.
Ordinare il database storico in base alla data di nascita, DOB.
Quando arriva una nuova richiesta, rilasciare al richiedente un
nuovo Application ID.
Trovare tutti i record che corrispondono a quella data di nascita.
Questa sarà la ricerca principale.
Fra i record trovati come corrispondenze, eseguire una ricerca
secondaria, utilizzando First name e Surname.
Se viene trovata una corrispondenza, utilizzare il Personal ID per
far riferimento al candidato. Calcolare il numero di approvazioni
e rifiuti che ha ricevuto.
Se invece non viene trovata alcuna corrispondenza, rilasciare al
richiedente un nuovo Personal ID.

Proviamo a scegliere l’algoritmo giusto per ordinare il database


storico. Possiamo tranquillamente escludere il bubble sort, poiché la
dimensione dei dati è enorme. Lo Shell sort funzionerà meglio, ma
solo se gli elenchi sono già parzialmente ordinati. Quindi, il merge sort
può essere l’opzione migliore per ordinare il database storico.
Quando arriva una nuova richiesta, dobbiamo cercare e individuare
quella persona nel database storico. Poiché i dati sono già ordinati,
possiamo utilizzare la ricerca per interpolazione o la ricerca binaria.
Poiché è probabile che i candidati siano equamente distribuiti, per data
di nascita, DOB, possiamo tranquillamente utilizzare la ricerca binaria.
La prima ricerca è in base alla data di nascita: otterremo un insieme
di richiedenti che condividono la stessa data di nascita. Ora, dobbiamo
trovare la persona che ha presentato la richiesta all’interno del piccolo
sottoinsieme di persone che condividono la stessa data di nascita.
Poiché abbiamo ridotto con successo i dati a un piccolo sottoinsieme,
per cercare il richiedente possiamo utilizzare qualsiasi algoritmo di
ricerca, incluso il bubble sort. Notate che qui abbiamo semplificato un
po’ il problema della ricerca secondaria. Dobbiamo anche calcolare il
numero totale di approvazioni e rifiuti, aggregando poi i risultati della
ricerca se viene trovata più di una corrispondenza.
In uno scenario reale, nella ricerca secondaria ogni individuo dovrà
essere identificato utilizzando un algoritmo di ricerca fuzzy,
“approssimativa”, poiché il nome e il cognome potrebbero essere stati
scritti in modo leggermente diverso. La ricerca potrebbe dover
utilizzare un qualche tipo di algoritmo di “valutazione della
somiglianza per implementare la ricerca fuzzy: i dati la cui
somiglianza supera una certa soglia sono considerati uguali.

Riepilogo
In questo capitolo abbiamo presentato una serie di algoritmi di
ordinamento e ricerca, trattandone i punti di forza e i punti deboli.
Abbiamo anche valutato le prestazioni di questi algoritmi e imparato
quando è il caso di utilizzare ciascun algoritmo.
Nel prossimo capitolo studieremo gli algoritmi dinamici. Vedremo
anche un esempio pratico di progettazione di un algoritmo e i dettagli
dell’algoritmo di classificazione delle pagine. Infine, studieremo
l’algoritmo di programmazione lineare.
Capitolo 4

Progettazione di algoritmi

Questo capitolo presenta i concetti fondamentali di progettazione di


vari algoritmi e discute i punti di forza e i punti deboli delle varie
tecniche per la progettazione di algoritmi. Sulla base di questi concetti,
imparerete a progettare algoritmi più efficienti.
Questo capitolo inizia discutendo le diverse scelte disponibili nella
progettazione degli algoritmi. Poi discute l’importanza di
caratterizzare lo specifico problema che stiamo cercando di risolvere.
Successivamente, utilizza il famoso “problema del commesso
viaggiatore” (Traveling Salesman Problem, TSP) come caso d’uso e vi
applica le diverse tecniche di progettazione che esamineremo. Quindi,
introduce la programmazione lineare e le sue applicazioni. Infine,
mostra come la programmazione lineare può essere utilizzata per
risolvere un problema concreto.
Alla fine di questo capitolo, dovreste essere in grado di comprendere
i concetti di base della progettazione di un algoritmo efficiente.

Introduzione ai concetti di base


della progettazione di un algoritmo
Un algoritmo, secondo l’American Heritage Dictionary, è definito
come segue:
Un insieme finito di istruzioni non ambigue che, dato un insieme di condizioni iniziali,
può essere eseguito in una sequenza prescritta per raggiungere un determinato obiettivo
e che ha un insieme riconoscibile di condizioni finali.

Progettare un algoritmo significa trovare questo “insieme finito di


istruzioni non ambigue” nel modo più efficiente per “raggiungere un
determinato obiettivo”. Per un problema concreto complesso,
progettare un algoritmo è un compito noioso. Per progettarlo al
meglio, dobbiamo prima comprendere appieno il problema che stiamo
cercando di risolvere. Iniziamo con il capire che cosa deve essere fatto
(cioè, comprendere i requisiti) prima di parlare di come sarà
implementato (cioè, progettare l’algoritmo). Per comprendere il
problema occorre affrontare i requisiti funzionali e non funzionali del
problema. Vediamo che cosa sono.
I requisiti funzionali specificano formalmente le interfacce di
input e output del problema che vogliamo risolvere e le funzioni a
esse associate. I requisiti funzionali ci consentono di comprendere
l’elaborazione dei dati, la manipolazione dei dati e i calcoli che
devono essere implementati per generare il risultato.
I requisiti non funzionali definiscono le aspettative sugli aspetti
prestazionali e di sicurezza dell’algoritmo.
Notate che la progettazione di un algoritmo riguarda il
soddisfacimento dei requisiti funzionali e non funzionali nel miglior
modo possibile in un determinato insieme di circostanze e tenendo
presente l’insieme di risorse disponibili per eseguire l’algoritmo
progettato.
Per ottenere una buona risposta, in grado di soddisfare i requisiti
funzionali e non funzionali, il progetto deve rispondere alle tre
seguenti preoccupazioni, come abbiamo visto nel Capitolo 1.
Problema 1: l’algoritmo progettato produrrà il risultato che ci
aspettiamo?
Problema 2: è questo il modo ottimale per ottenere questi
risultati?
Problema 3: come si comporterà l’algoritmo su dataset più
grandi?
In questo paragrafo, esaminiamo queste preoccupazioni una per una.

Problema 1: l’algoritmo progettato


produrrà il risultato che ci aspettiamo?
Un algoritmo è una soluzione matematica a un problema concreto.
Per essere utile, quindi, deve produrre risultati accurati. Il modo in cui
verificare la correttezza di un algoritmo non può essere considerato a
posteriori, ma dovrebbe essere inserito nelle valutazioni iniziali, di
progettazione dell’algoritmo. Prima di definire una strategia per
verificare l’efficacia di un algoritmo, dobbiamo considerare i due
aspetti seguenti.
Definire la verità: per verificare l’algoritmo, abbiamo bisogno di
ottenere risultati corretti, riconosciuti tali per un dato insieme di
input. Questi risultati corretti e riconosciuti sono chiamati la
verità, nel contesto del problema che stiamo cercando di
risolvere. La verità è importante, in quanto viene utilizzata come
riferimento quando lavoriamo in modo iterativo per far evolvere il
nostro algoritmo verso una soluzione sempre migliore.
Scegliere le metriche: dobbiamo anche pensare a come
quantificare la deviazione dalla verità così definita. La scelta delle
metriche corrette ci aiuterà a quantificare con precisione la qualità
del nostro algoritmo.
Per esempio, per gli algoritmi di machine learning, possiamo
utilizzare come verità i dati etichettati esistenti. Possiamo scegliere
una o più metriche, come accuratezza, richiamo o precisione, per
quantificare la deviazione dalla verità. È importante notare che, in
alcuni casi d’uso, l’output corretto non è un singolo valore, ma è
definito come un intervallo per un dato insieme di input. Mentre
lavoriamo alla progettazione e allo sviluppo del nostro algoritmo,
l’obiettivo sarà quello di migliorarlo in modo iterativo, fino a quando
non rientrerà nell’intervallo specificato nei requisiti.

Problema 2: è questo il modo ottimale


per ottenere questi risultati?
La seconda preoccupazione riguarda la ricerca della risposta alla
seguente domanda: “È questa la soluzione ottimale e possiamo
verificare che per questo problema non esista altra soluzione migliore
della nostra?”.
A prima vista, a questa domanda sembra abbastanza semplice
rispondere. Tuttavia, per determinate classi di algoritmi, i ricercatori
hanno dedicato decenni, senza successo, a verificare che una
determinata soluzione, generata da un algoritmo, fosse anche la
migliore e che non esistesse nessun’altra soluzione in grado di offrire
risultati migliori. Quindi, diventa importante capire innanzitutto il
problema, i suoi requisiti e le risorse disponibili per eseguire
l’algoritmo. Dobbiamo prendere atto della seguente affermazione:
“Dovremmo mirare a trovare la soluzione ottimale per questo
problema? Trovare e verificare la soluzione ottimale è così complesso
e dispendioso in termini di tempo che una soluzione praticabile basata
sull’euristica rappresenta, in realtà, la soluzione migliore?”.
Quindi, comprendere il problema e le sue complessità è importante e
ci aiuta a stimare i requisiti in termini di risorse.
Prima di iniziare ad approfondire questo aspetto, definiamo un paio
di termini.
Algoritmo polinomiale: un algoritmo che ha una complessità in
termini di tempo pari a O(n k), dove k è una costante.
Certificato: una soluzione candidata prodotta alla fine di
un’iterazione è denominata certificato. A mano a mano che
avanziamo in modo iterativo nella risoluzione di un determinato
problema, solitamente generiamo una serie di certificati. Se la
soluzione sta procedendo verso una convergenza, ogni certificato
generato sarà migliore del precedente. A un certo punto, quando il
nostro certificato soddisferà i requisiti, sceglieremo tale
certificato come soluzione finale.
Nel Capitolo 1 abbiamo introdotto la notazione Big O, che può
essere utilizzata per analizzare la complessità in termini di tempo di un
algoritmo. Nel contesto dell’analisi della complessità in termini di
tempo, stiamo esaminando i seguenti diversi intervalli:
il tempo impiegato da un algoritmo per produrre una soluzione
proposta, chiamata certificato (tr);
il tempo necessario per verificare la soluzione proposta
(certificato), ts.

Caratterizzare la complessità del problema


Nel corso degli anni, la comunità di ricerca ha suddiviso i problemi
in varie categorie, in base alla loro complessità. L’idea è che prima di
tentare di progettare la soluzione a un problema, ha senso prima
caratterizzarlo. In generale, esistono tre tipi di problemi.
Tipo 1: problemi per i quali possiamo garantire l’esistenza di un
algoritmo polinomiale in grado di risolverli.
Tipo 2: problemi per i quali possiamo dimostrare che non possono
essere risolti da un algoritmo polinomiale.
Tipo 3: problemi per i quali non siamo in grado di trovare un
algoritmo polinomiale per risolverli, ma non siamo nemmeno in
grado di dimostrare che una soluzione polinomiale per quei
problemi sia impossibile da trovare.
Esaminiamo le varie classi di problemi.
Polinomiale non deterministico (NP). Deve soddisfare la seguente
condizione: è garantito che esista un algoritmo polinomiale in
grado di verificare che la soluzione candidata (il certificato) è
ottimale.
Polinomiale (P). Questi sono i tipi di problemi che possono essere
considerati un sottoinsieme dei polinomiali non deterministici.
Oltre a soddisfare la condizione dei problemi polinomiali non
deterministici, i problemi polinomiali devono soddisfare un’altra
condizione: è garantito che esista almeno un algoritmo
polinomiale in grado di risolverli.
La Figura 4.1 mostra la relazione fra i problemi polinomiali non
deterministici e i problemi polinomiali.

Figura 4.1

NOTA
Se un problema è polinomiale non deterministico, è anche polinomiale? Questo
è uno dei più grandi problemi irrisolti dell’informatica. Inserito fra i Millennium
Prize Problem, selezionati dal Clay Mathematics Institute, è stato annunciato un
premio di 1 milione di dollari per la soluzione di questo problema, che avrà un
impatto importante su campi come l’intelligenza artificiale, la crittografia e le
scienze informatiche teoriche:

Continuiamo l’elenco delle varie classi di problemi.


NP-completo: questa categoria comprende i più difficili fra tutti i
problemi polinomiali non deterministici. Un problema NP-
completo soddisfa le due condizioni seguenti: non sono noti
algoritmi polinomiali in grado di generare un certificato e sono
noti algoritmi polinomiali in grado di verificare che il certificato
proposto sia ottimale.
NP-difficile: questa categoria comprende i problemi che sono
almeno altrettanto difficili di qualsiasi problema presente nella
categoria NP, ma che non sono necessariamente presenti nella
categoria NP.
La Figura 4.2 mostra un diagramma di queste diverse classi di
problemi:
Figura 4.2

Notate che la comunità di ricerca deve ancora dimostrare se P = NP.


Ma, ciononostante, è estremamente probabile che P ≠ NP. In tal caso,
non esiste una soluzione polinomiale per i problemi NP-completi.
Notate che la Figura 4.2 si basa su questo presupposto.

Problema 3: come si comporterà


l’algoritmo su dataset più grandi?
Un algoritmo elabora i dati in un modo ben definito per produrre un
risultato. In genere, con l’aumentare della dimensione dei dati, occorre
sempre più tempo per elaborare i dati e calcolare i risultati richiesti. Il
termine big data viene utilizzato per identificare in generale i dataset
che dovrebbero essere difficilmente elaborabili dall’infrastruttura e
dagli algoritmi a causa di fattori come il volume, la varietà e la
velocità. Un algoritmo ben progettato dovrebbe essere scalabile,
ovvero progettato in modo tale che, ove possibile, sia in grado di
funzionare in modo efficiente, utilizzando le risorse disponibili e
generando i risultati corretti in un lasso di tempo ragionevole. Il
progetto dell’algoritmo diventa ancora più importante quando si ha a
che fare con i big data. Per quantificare la scalabilità di un algoritmo,
dobbiamo considerare i seguenti due aspetti.
L’aumento dei requisiti in termini di risorse all’aumentare dei
dati di input: la stima di un requisito di questo tipo è chiamata
analisi della complessità in termini di spazio.
L’aumento del tempo impiegato per l’esecuzione all’aumentare
dei dati di input: la stima di un requisito di questo tipo è chiamata
analisi della complessità in termini di tempo.
La nostra è un’era definita dall’esplosione dei dati. Il termine big
data è ormai diventato di uso comune, poiché cattura le dimensioni e
la complessità dei dati che sempre più spesso devono essere elaborati
dai moderni algoritmi.
Durante la fase di sviluppo e test, molti algoritmi utilizzano solo un
piccolo campione di dati. Quando si progetta un algoritmo, è
importante considerare anche la sua scalabilità. In particolare, è
importante analizzare attentamente (ovvero sottoporre a test o
prevedere) l’effetto delle prestazioni di un algoritmo quando i dataset
aumentano di dimensioni.

Strategie per la progettazione di


algoritmi
Un algoritmo ben progettato cerca di ottimizzare l’uso delle risorse
disponibili, dividendo il problema in sottoproblemi più piccoli, ove
possibile. Esistono diverse strategie per la progettazione di algoritmi.
Una strategia si occupa dei seguenti tre aspetti degli algoritmi
candidati per la risoluzione di un problema. Aspetti che saranno
l’argomento delle prossime pagine:
la strategia divide et impera;
la strategia di programmazione dinamica;
la strategia ad algoritmo greedy.

La strategia divide et impera


Una delle strategie consiste nel trovare un modo per dividere il
problema in parti più piccole, che possano essere risolte
indipendentemente. Le sub-soluzioni prodotte per questi sottoproblemi
vengono poi combinate per generare la soluzione complessiva del
problema. Questa strategia è chiamata divide et impera.
Matematicamente, se stiamo progettando una soluzione per un
problema (P) con n input di un dataset d, dividiamo il problema in k
sottoproblemi, da P1 a Pk. Ciascuno dei sottoproblemi elaborerà una
partizione del dataset d. Tipicamente, avremo sottoproblemi da P1 a Pk
che elaborano sezioni di dataset, da d1 a dk.
Facciamo un esempio pratico.

Esempio pratico divide et impera applicato ad Apache


Spark
Apache Spark è un framework open source utilizzato per risolvere
problemi distribuiti complessi e implementa una strategia divide et
impera per risolvere i problemi. Per elaborare un problema, lo
suddivide in più sottoproblemi e li elabora indipendentemente l’uno
dall’altro. Lo dimostreremo usando un semplice esempio: il conteggio
delle parole di una lista.
Supponiamo di avere il seguente elenco di parole:
wordsList = [python, java, ottawa, news, java, ottawa]

Vogliamo calcolare la frequenza di ogni parola di questo elenco. Per


farlo, applicheremo la strategia divide et impera, nel tentativo di
risolvere questo problema in modo efficiente.
L’implementazione del divide et impera è mostrata nella Figura 4.3.
La Figura 4.3 mostra le seguenti fasi in cui è suddiviso un problema.
1. Suddivisione: i dati di input vengono divisi in partizioni, che
possono essere elaborate indipendentemente l’una dall’altra. È la
fase di splitting. Nella Figura 4.3 abbiamo tre suddivisioni, o
split.
2. Mappatura: qualsiasi operazione che può essere eseguita
indipendentemente su una suddivisione è chiamata mappatura,
mapping. Nella Figura 4.3, l’operazione di mappatura converte
ciascuna delle parole della partizione in coppie chiave-valore. In
corrispondenza delle tre suddivisioni, ci sono tre mappature, che
vengono eseguite in parallelo.
3. Riordino: è il processo di unione delle chiavi simili, terminato il
quale, ai valori possono essere applicate delle funzioni di
aggregazione. Notate che l’operazione di riordino, shuffling,
richiede molte risorse, in quanto deve riunire chiavi simili che
originariamente potevano essere distribuite nel dataset.
4. Riduzione: è il processo di applicazione della funzione di
aggregazione in base al valore delle chiavi. Nella Figura 4.3,
dobbiamo contare il numero di parole.
Figura 4.3

Vediamo come possiamo scrivere il codice per implementare questo


algoritmo. Per illustrare la strategia divide et impera, abbiamo bisogno
di un framework di calcolo distribuito. Impiegheremo Python in
esecuzione su Apache Spark.
1. Innanzitutto, per utilizzare Apache Spark, creeremo un contesto
runtime di Apache Spark:
import findspark
findspark.init()
from pyspark.sql import SparkSession
spark = SparkSession.builder.master("local[*]").getOrCreate()

sc = spark.sparkContext

2. Ora creiamo una lista di esempio contenente alcune parole.


Convertiremo questa lista nella struttura di dati distribuita nativa
di Spark, il Resilient Distributed Dataset (RDD):
wordsList = ['python', 'java', 'ottawa', 'ottawa', 'java', 'news']
wordsRDD = sc.parallelize(wordsList, 4)
# Mostra il tipo di wordsRDD

print (wordsRDD.collect())

3. Ora usiamo una funzione map() per convertire le parole in coppie


chiave-valore:
4. Usiamo la funzione reduce() per aggregare e ottenere il risultato
finale:

Questo mostra come possiamo usare la strategia divide et impera per


contare il numero di parole.
NOTA
Le moderne infrastrutture di cloud computing, come Microsoft Azure, Amazon
Web Services e Google Cloud, raggiungono la scalabilità implementando
direttamente o indirettamente, dietro le quinte, una strategia divide et impera.

La strategia di programmazione dinamica


La programmazione dinamica è una strategia proposta negli anni
Cinquanta da Richard Bellman per ottimizzare alcune classi di
algoritmi. Si basa su un meccanismo di caching intelligente che cerca
di riutilizzare i calcoli più pesanti. Questo meccanismo di caching
intelligente è chiamato memorizzazione.
La programmazione dinamica offre vantaggi prestazionali quando il
problema che stiamo cercando di risolvere può essere suddiviso in più
sottoproblemi. I sottoproblemi implicano il fatto che su di essi può
essere effettuato un calcolo parziale. L’idea è quella di eseguire quel
calcolo una sola volta (questo è il passaggio che richiede tempo) e poi
riutilizzarlo sugli altri sottoproblemi. Ciò si ottiene grazie al
meccanismo di memorizzazione, che è particolarmente utile per
risolvere problemi ricorsivi che possono valutare più volte gli stessi
input.

La strategia ad algoritmo greedy


Prima di approfondire questo paragrafo, definiamo due termini.
Overhead dell’algoritmo: ogni volta che cerchiamo di trovare la
soluzione ottimale di un determinato problema, impieghiamo del
tempo. A mano a mano che il problema che stiamo cercando di
ottimizzare diventa sempre più complesso, aumenta anche il
tempo necessario per trovare la soluzione ottimale.
Rappresentiamo l’overhead dell’algoritmo con Ωi.
Delta rispetto all’ottimale: per un dato problema di
ottimizzazione esiste una soluzione ottimale. In genere,
ottimizziamo la soluzione in modo iterativo utilizzando
l’algoritmo scelto. Per un dato problema esiste sempre una sua
soluzione perfetta, detta soluzione ottimale. Come abbiamo visto,
in base alla classificazione del problema che stiamo cercando di
risolvere, è possibile che la soluzione ottimale sia sconosciuta o
che sia necessario un tempo non ragionevole per calcolarla e
verificarla. Supponendo che la soluzione ottimale sia nota, la
differenza fra la soluzione ottimale e la soluzione corrente all’i-
esima iterazione è chiamata delta rispetto all’ottimale ed è
rappresentata da Δi.
Per problemi complessi abbiamo a disposizione due strategie.
Strategia 1: dedicare più tempo alla ricerca di una soluzione più
vicina all’ottimale in modo che Δi sia il più piccolo possibile.
Strategia 2: ridurre al minimo l’overhead dell’algoritmo, Ωi.
Usare un approccio “quick-and-dirty” e usare semplicemente una
soluzione praticabile.
Gli algoritmi greedy si basano sulla Strategia 2: non ci impegniamo
a trovare un algoritmo ottimale globale, ma scegliamo invece di ridurre
al minimo i costi dell’algoritmo.
Scegliere di utilizzare un algoritmo greedy è una strategia rapida e
semplice per trovare il valore ottimale globale per i problemi
multifase. Si basa sulla scelta di valori ottimali locali, senza fare un
reale sforzo per verificare se i valori ottimali locali sono anche ottimali
a livello globale. Generalmente, a meno che non siamo particolarmente
fortunati, un algoritmo greedy non produrrà un valore che possa essere
considerato globalmente ottimale. Tuttavia, la ricerca di un valore
ottimale globale è un’attività che richiede tempo. Quindi, l’algoritmo
greedy risulta più veloce rispetto agli algoritmi divide et impera e di
programmazione dinamica.
In generale, un algoritmo greedy è definito come segue.
1. Supponiamo di avere un dataset, D. In questo dataset, scegliamo
un elemento, k.
2. Supponiamo che la soluzione candidata (il certificato) sia S.
Consideriamo di includere k nella soluzione, S. Se può essere
incluso, allora la soluzione è Unione(S, k).
3. Ripetiamo il processo fino a quando S è piena o D è vuoto.

Applicazione pratica: risoluzione


del problema del commesso
viaggiatore
Esaminiamo, innanzitutto, la dichiarazione del problema del
commesso viaggiatore, ideato come sfida negli anni Trenta. Si tratta di
un problema NP-difficile. Per cominciare, possiamo generare
casualmente un percorso che soddisfi la condizione di visitare tutte le
città senza preoccuparci di quale sia la soluzione ottimale e poi
possiamo lavorare per cercare di migliorare la soluzione a ogni
iterazione. Ogni percorso generato in un’iterazione è chiamato
soluzione candidata (chiamato un certificato). Dimostrare che un
certificato è ottimale richiede una quantità di tempo esponenzialmente
crescente. Al contrario, utilizziamo diverse soluzioni basate su
euristiche per generare percorsi prossimi a quello ottimale, ma non
ottimali.
Un commesso viaggiatore deve visitare un determinato elenco di
città per svolgere il proprio lavoro.
Una lista di n città (denotata con V) e le distanze d fra le varie coppie di
INPUT
città d(i, j) con 1 ≤ i ≤ n e 1 ≤ j ≤ n).
Il percorso più breve che visita ciascuna città esattamente una volta e
OUTPUT
poi torna alla città iniziale.

Notate quanto segue:


sono note le distanze fra le città della lista;
ogni città dell’elenco fornito deve essere visitata esattamente una
volta.
Possiamo generare il piano per il commesso viaggiatore? Quale sarà
la soluzione ottimale in grado di ridurre al minimo la distanza totale da
lui percorsa?
Le seguenti sono le distanze fra cinque città canadesi, che possiamo
utilizzare per il problema del commesso viaggiatore.
Ottawa Montreal Kingston Toronto Sudbury
Ottawa - 199 196 450 484
Montréal 199 - 287 542 680
Kingston 196 287 - 263 634
Toronto 450 542 263 - 400
Sudbury 484 680 634 400 -

Notate che l’obiettivo è quello di ottenere un percorso che parta e


arrivi nella città iniziale. Per esempio, un percorso tipico può essere
Ottawa – Sudbury – Montreal – Kingston – Toronto – Ottawa con un
costo pari a 484 + 680 + 287 + 263 + 450 = 2.164. È questo il percorso
in cui il commesso viaggiatore percorre la distanza minima? Quale
sarà la soluzione ottimale in grado di ridurre al minimo la distanza
totale percorsa dal commesso viaggiatore? Lascio a voi il compito di
riflettere e calcolarla.

Usare una strategia a forza bruta


La prima soluzione che viene in mente per risolvere il problema del
commesso viaggiatore è quella di usare la forza bruta per trovare il
percorso più breve con cui il commesso viaggiatore può visitare ogni
città esattamente una volta e tornare alla città da cui è partito. La
strategia a forza bruta funziona come segue.
1. Valutare tutti i percorsi possibili.
2. Scegliere quello per il quale otteniamo la distanza più breve.
Il problema è che per n città ci sono (n – 1)! possibili percorsi. Ciò
significa che cinque città produrranno 4! = 24 percorsi e selezioneremo
quello che corrisponde alla distanza più breve. È ovvio che questo
metodo funzionerà solo perché non abbiamo troppe città.
All’aumentare del numero di città, la strategia a forza bruta diventa
impraticabile, a causa del gran numero di permutazioni generate
utilizzando questo approccio.
Vediamo come possiamo implementare la strategia a forza bruta in
Python.
Innanzitutto, notate che {1, 2, 3} rappresenta un percorso dalla città
1 alla città 2 alla città 3. La distanza totale di un percorso è la somma
delle distanze percorse. Supporremo che la distanza fra le città sia
semplicemente la distanza in linea d’aria (la distanza euclidea).
Definiamo prima tre funzioni di servizio.
distance_points() : calcola la distanza assoluta fra due punti.
distance_tour(): calcola la distanza totale che il commesso
viaggiatore deve percorrere per coprire un determinato percorso.
generate_cities(): genera casualmente un insieme di n città situate

in un rettangolo di larghezza 500 e altezza 300.

Esaminiamo il seguente codice:


import random

from itertools import permutations

alltours = permutations

def distance_tour(aTour):

return sum(distance_points(aTour[i – 1], aTour[i])

for i in range(len(aTour)))

aCity = complex

def distance_points(first, second): return abs(first – second)

def generate_cities(number_of_cities):

seed = 111; width = 500; height = 300

random.seed((number_of_cities, seed))

return frozenset(aCity(random.randint(1, width), random.randint(1, height))

for c in range(number_of_cities))

Nel codice precedente, abbiamo implementato alltours con la


funzione permutations del pacchetto itertools. Abbiamo anche
rappresentato la distanza con un numero complesso. Ciò significa
quanto segue.
Il calcolo della distanza fra due città, a e b, è distance(a, b).
Possiamo creare n città semplicemente richiamando
generate_cities(n).

Definiamo ora una funzione brute_force() che generi tutti i percorsi


fra le città. Una volta generati tutti i percorsi possibili, sceglierà quello
con la distanza più breve:
def brute_force(cities):

"Generate all possible tours of the cities and choose the shortest tour."

return shortest_tour(alltours(cities))

def shortest_tour(tours): return min(tours, key = distance_tour)

Definiamo ora le funzioni di servizio in grado di aiutarci a tracciare


le città.
visualize_tour() : traccia tutte le città e i collegamenti di un
determinato percorso. Evidenzia anche la città da cui è iniziato il
percorso.
visualize_segment(): utilizzata da visualize_tour() per tracciare le città

e i singoli collegamenti.
Osservate il seguente codice:
%matplotlib inline

import matplotlib.pyplot as plt

def visualize_tour(tour, style = 'bo-'):

if len(tour) > 1000: plt.figure(figsize = (15, 10))

start = tour[0:1]

visualize_segment(tour + start, style)

visualize_segment(start, 'rD')

def visualize_segment(segment, style = 'bo-'):

plt.plot([X(c) for c in segment], [Y(c) for c in segment], style, clip_on =


False)

plt.axis('scaled')

plt.axis('off')

def X(city): "X axis"; return city.real

def Y(city): "Y axis"; return city.imag


Implementiamo la funzione tsp() che fa quanto segue.

1. Genera il percorso in base all’algoritmo e al numero di città


richieste.
2. Calcola il tempo impiegato dall’algoritmo per l’esecuzione.
3. Genera un tracciato.
Una volta definita tsp(), possiamo usarla per tracciare un percorso:

Notate che l’abbiamo usata per generare il percorso per dieci città.
Poiché n = 10, genererà (10 – 1)! = 362.880 possibili permutazioni. Al
crescere di n, il numero di permutazioni cresce enormemente e il
metodo della forza bruta diviene impraticabile.

Usare un algoritmo greedy


Se usiamo un algoritmo greedy per risolvere il problema del
commesso viaggiatore, allora, a ogni passo, possiamo scegliere una
città che ci sembra ragionevole, invece di trovare quella città che
produrrà il miglior percorso in assoluto. Quindi, ogni volta che
dobbiamo selezionare una città, selezioniamo semplicemente la città
più vicina, senza preoccuparci di verificare che questa scelta produca il
percorso ottimale.
L’approccio dell’algoritmo greedy è semplice.
1. Partiamo da qualsiasi città.
2. A ogni passaggio, continuiamo a costruire il percorso spostandoci
nella città più vicina che non è stata ancora visitata.
3. Ripetiamo il Passaggio 2.
Definiamo una funzione greedy_algorithm() per implementare questa
logica:
def greedy_algorithm(cities, start = None):

C = start or first(cities)

tour = [C] unvisited = set(cities – {C})

while unvisited:

C = nearest_neighbor(C, unvisited)

tour.append(C)

unvisited.remove(C)

return tour

def first(collection): return next(iter(collection))

def nearest_neighbor(A, cities):

return min(cities, key = lambda C: distance_points(C, A))

Ora usiamo greedy_algorithm() per creare un percorso per 2.000 città:


Notate che ci sono voluti solo 0,514 secondi per generare il percorso
per 2.000 città. Il metodo della forza bruta avrebbe dovuto generare
(2000 – 1)! permutazioni, che è un numero quasi infinito.
Notate che l’algoritmo greedy si basa sull’euristica e non ci sono
prove che la soluzione che fornisce sarà ottimale.
Ora, esaminiamo il progetto dell’algoritmo PageRank.

L’algoritmo PageRank
Come esempio pratico, esaminiamo l’algoritmo PageRank, che è
stato inizialmente utilizzato da Google per classificare i risultati delle
ricerche effettuate dagli utenti. L’algoritmo genera un numero che
quantifica l’importanza di un risultato nel contesto della ricerca
eseguita dall’utente. È stato progettato da due studenti, Larry Page e
Sergey Brin, a Stanford sul finire degli anni Novanta, che
successivamente fondarono Google.
NOTA
Il nome dell’algoritmo PageRank prende il nome da Larry Page, che lo ha
creato con Sergey Brin alla Stanford University.

Prima di tutto definiamo formalmente il problema per il quale è


stato inizialmente progettato PageRank.

Definizione del problema


Ogni volta che un utente effettua una ricerca con un motore di
ricerca web, in genere si ottiene un gran numero di risultati. Per
rendere tali risultati utili per l’utente finale, è importante classificare le
pagine web utilizzando alcuni criteri. Questa classificazione viene poi
utilizzata per presentare i risultati all’utente e dipende dai criteri
definiti dall’algoritmo utilizzato.

Implementazione dell’algoritmo
PageRank
La parte più importante dell’algoritmo PageRank consiste nel
trovare il modo migliore per calcolare l’importanza di ogni pagina
restituita dai risultati della query. Per calcolare un numero da 0 a 1 in
grado di quantificare l’importanza di una determinata pagina,
l’algoritmo impiega le informazioni relative ai seguenti due
componenti.
Informazioni specifiche per la query inserita dall’utente: questo
componente stima, nel contesto della query inserita dall’utente,
quanto è rilevante il contenuto della pagina web. Il contenuto
della pagina dipende direttamente dall’autore della pagina.
Informazioni non pertinenti alla query inserita dall’utente: questo
componente cerca di quantificare l’importanza di ogni pagina
web nel contesto dei suoi link, delle visualizzazioni e delle
somiglianze. Questo componente è difficile da calcolare, poiché
le pagine web sono eterogenee ed è difficile sviluppare criteri che
possano essere applicati in tutto il Web.
Per implementare l’algoritmo PageRank in Python, per prima cosa,
importiamo le librerie necessarie:
import numpy as np

import networkx as nx

import matplotlib.pyplot as plt

%matplotlib inline

A scopo dimostrativo, supponiamo di analizzare solo cinque pagine


web in Rete. Chiamiamo questo insieme di pagine myPages e
supponiamo che si trovino in una rete chiamata myWeb:
myWeb = nx.DiGraph()

myPages = range(1, 5)

Ora colleghiamole casualmente per simulare una vera rete:


connections = [(1, 3), (2, 1), (2, 3), (3, 1), (3, 2), (3, 4), (4, 5), (5, 1),
(5, 4)]

myWeb.add_nodes_from(myPages)

myWeb.add_edges_from(connections)

Ora, tracciamo questo grafo:


pos = nx.shell_layout(myWeb)

nx.draw(myWeb, pos, arrows = True, with_labels = True)

plt.show()

Questo codice crea la rappresentazione grafica della nostra rete,


illustrata nella Figura 4.4.
Figura 4.4

Nell’algoritmo PageRank, i pattern di una pagina web sono


contenuti in una matrice di transizione. Esistono algoritmi che
aggiornano costantemente la matrice di transizione per catturare lo
stato in continua evoluzione del Web. La dimensione della matrice di
transizione è n × n, dove n è il numero di nodi. I valori presenti nella
matrice sono le probabilità che un visitatore di una pagina segua i vari
link in uscita.
Nel nostro caso, il grafo precedente rappresenta il nostro web
statico. Definiamo una funzione che possa essere utilizzata per creare
la matrice di transizione:
Notate che questa funzione restituirà G, che rappresenta la matrice di
transizione per il nostro grafo. Generiamo ora la matrice di transizione
per il nostro grafo:

Notate che la matrice di transizione per il nostro grafo è 5 × 5. Ogni


colonna corrisponde a ciascun nodo nel grafico. Per esempio, la
colonna 2 riguarda il secondo nodo. C’è una probabilità 0,5 che il
visitatore passerà dal nodo 2 al nodo 1 o al nodo 3. Notate che la
diagonale della matrice di transizione è 0 poiché nel nostro grafo non
esiste un link in uscita da un nodo a se stesso. In una rete vera e
propria, questo potrebbe essere possibile.
Notate che la matrice di transizione è di tipo sparso. All’aumentare
del numero di nodi, la maggior parte dei suoi valori sarà uguale a 0.
La programmazione lineare
L’algoritmo alla base della programmazione lineare è stato
sviluppato da George Dantzig presso l’Università della California a
Berkeley nei primi anni Quaranta. Dantzig ha utilizzato questo
concetto per sperimentare la pianificazione della logistica di
approvvigionamento e capacità per le truppe mentre lavorava per
l’aeronautica statunitense. Alla fine della Seconda guerra mondiale,
Dantzig iniziò a lavorare per il Pentagono e fece maturare il suo
algoritmo, trasformandolo in una tecnica che chiamò programmazione
lineare e adattandolo alla pianificazione dei combattimenti.
Oggi viene utilizzato per risolvere importanti problemi concreti che
riguardano la minimizzazione o la massimizzazione di una variabile in
base a determinati vincoli. Ecco alcuni esempi di questi problemi:
ridurre al minimo i tempi di riparazione di un’auto in un’officina
meccanica, in base alle risorse;
allocare le risorse distribuite disponibili in un ambiente di calcolo
distribuito, per ridurre al minimo i tempi di risposta;
massimizzare i profitti di un’azienda sulla base dell’allocazione
ottimale delle risorse disponibili.

Formulazione di un problema di
programmazione lineare
Le condizioni per utilizzare la programmazione lineare sono le
seguenti:
dovremmo essere in grado di formulare il problema attraverso una
serie di equazioni;
le variabili utilizzate nell’equazione devono essere lineari.
Definizione della funzione obiettivo
Notate che l’obiettivo di ciascuno dei tre esempi precedenti è la
minimizzazione o massimizzazione di una variabile. Questo obiettivo è
formulato matematicamente come una funzione lineare di altre
variabili, ed è chiamato funzione obiettivo. Lo scopo di un problema di
programmazione lineare è quello di minimizzare o massimizzare la
funzione obiettivo, rimanendo entro i vincoli specificati.

Specificare i vincoli
Quando si cerca di minimizzare o massimizzare qualcosa, nei
problemi concreti ci sono sempre dei vincoli da rispettare. Per
esempio, quando si cerca di ridurre al minimo il tempo necessario per
riparare un’auto, bisogna anche considerare che i meccanici in grado di
occuparsene sono disponibili in numero limitato. Specificare ogni
vincolo tramite un’equazione lineare è una parte importante della
formulazione di un problema di programmazione lineare.

Applicazione pratica:
pianificazione della capacità con la
programmazione lineare
Vediamo un caso d’uso pratico in cui la programmazione lineare
può essere utilizzata per risolvere un problema concreto. Supponiamo
di voler massimizzare i profitti di una fabbrica che produce due diversi
tipi di robot.
Modello avanzato (A): fornisce funzionalità complete. La
produzione di ogni unità del modello avanzato si traduce in un
profitto di 4.200 dollari.
Modello base (B): fornisce solo le funzionalità di base. La
produzione di ogni unità del modello base si traduce in un profitto
di 2.800 dollari.
Per fabbricare un robot occorrono tre diversi tipi di persone. Il
numero esatto di giorni necessari per fabbricare un robot di ogni tipo è
il seguente.
Tipo di Robot Tecnico Specialista AI Ingegnere
Robot A: modello avanzato 3 giorni 4 giorni 4 giorni
Robot B: modello base 2 giorni 3 giorni 3 giorni

La fabbrica funziona su cicli di 30 giorni. Vi è 1 solo specialista di


intelligenza artificiale, disponibile per tutti i 30 giorni di un ciclo.
Ciascuno dei 2 ingegneri si prenderà 8 giorni di riposo nel corso di
questi 30 giorni. Quindi, ogni ingegnere è disponibile solo per 22
giorni in un ciclo. Vi è 1 solo tecnico, disponibile per 20 giorni su un
ciclo di 30 giorni.
La tabella seguente mostra il numero di persone che abbiamo in
fabbrica.
Tecnico Specialista AI Ingegnere
Numero di persone 1 1 2
Numero totale di giorni in un 1 × 20 = 20 1 × 30 = 30 2 × 22 = 44
ciclo giorni giorni giorni

Questa tabella può essere modellata come segue.


Massimo profitto = 4200 × A + 2800 × B.
Il tutto soggetto ai seguenti vincoli.
A ≥ 0: il numero di robot avanzati prodotti può essere 0 o più.
B ≥ 0: il numero di robot di base prodotti può essere 0 o più.
3 A + 2 B ≤ 20: vincoli della disponibilità del tecnico.
4 A + 3 B ≤ 30: vincoli della disponibilità dello specialista AI.
4 A + 3 B ≤ 44: vincoli della disponibilità degli ingegneri.
Innanzitutto, importiamo il pacchetto Python pulp, utilizzato per
implementare la programmazione lineare:
import pulp

Quindi, richiamiamo la funzione LpProblem di questo pacchetto per


creare un’istanza della classe del problema. Chiamiamo l’istanza Profit
maximising problem:
# Istanziamo la classe del problema

model = pulp.LpProblem("Profit maximising problem", pulp.LpMaximize)

Quindi, definiamo due variabili lineari, A e B. La variabile A


rappresenta il numero di robot avanzati che vengono prodotti e la
variabile B rappresenta il numero di robot di base che vengono
prodotti:
A = pulp.LpVariable('A', lowBound = 0, cat = 'Integer')

B = pulp.LpVariable('B', lowBound = 0, cat = 'Integer')

Definiamo la funzione obiettivo e i vincoli come segue:


# Funzione obiettivo
model += 5000 * A + 2500 * B, "Profit"
# Vincoli
model += 3 * A + 2 * B <= 20
model += 4 * A + 3 * B <= 30

model += 4 * A + 3 * B <= 44

Usiamo la funzione solve() per generare una soluzione:


# Soluzione del problema

model.solve()

pulp.LpStatus[model.status]

Quindi, stampiamo i valori di A e B e il valore della funzione


obiettivo:
NOTA
La programmazione lineare è ampiamente utilizzata nell’industria manifatturiera
per trovare il numero ottimale di prodotti tale da ottimizzare l’uso delle risorse
disponibili.

Riepilogo
In questo capitolo abbiamo esaminato vari approcci alla
progettazione di un algoritmo. Abbiamo esaminato i compromessi
legati alla scelta del progetto più adatto di un algoritmo. Abbiamo
esaminato le migliori pratiche per formulare un problema concreto.
Abbiamo anche visto come si risolve un concreto problema di
ottimizzazione. Le lezioni apprese grazie a questo capitolo possono
essere utilizzate per implementare algoritmi ben progettati.
Nel prossimo capitolo ci concentreremo sugli algoritmi basati su
grafi. Inizieremo esaminando diversi modi di rappresentare i grafi,
quindi, studieremo le tecniche per esaminare i “dintorni” dei punti dei
dati per condurre una specifica indagine. Infine, studieremo i modi
ottimali per trarre informazioni dai grafi.
Capitolo 5

Algoritmi per grafi

Esiste una classe di problemi computazionali che può essere


rappresentata al meglio in termini grafici. Tali problemi possono essere
risolti utilizzando una classe di algoritmi chiamati algoritmi per grafi.
Per esempio, gli algoritmi per grafi possono essere utilizzati per
cercare in modo efficiente un valore in una rappresentazione grafica
dei dati. Per funzionare in modo efficiente, questi algoritmi dovranno
prima scoprire la struttura del grafo. Devono anche trovare la giusta
strategia per seguire gli archi (o lati o spigoli) del grafo per leggere i
dati memorizzati nei suoi nodi (o vertici). Poiché gli algoritmi per grafi
hanno bisogno di cercare dei valori, una strategia di ricerca efficiente è
fondamentale per poter progettare algoritmi efficienti. L’utilizzo di
algoritmi per grafi è uno dei modi più efficienti per cercare
informazioni in strutture di dati complesse e interconnesse, collegate
tramite relazioni significative. Nell’era dei big data, dei social media e
dei dati distribuiti, tali tecniche stanno diventando sempre più
importanti e utili.
In questo capitolo inizieremo presentando i concetti fondamentali
degli algoritmi per grafi. Quindi, esamineremo le basi della teoria
dell’analisi delle reti. Successivamente, esamineremo le varie tecniche
che possono essere utilizzate per attraversare i grafi. Infine,
esamineremo un caso di studio che mostra come gli algoritmi per grafi
possono essere utilizzati per il rilevamento delle frodi.
Alla fine di questo capitolo, avrete una buona comprensione di che
cosa sono i grafi e di come impiegarli per rappresentare strutture di
dati interconnesse ed estrarre informazioni da entità collegate da
relazioni dirette o indirette; inoltre imparerete a usare i grafi per
risolvere alcuni complessi problemi concreti.

Rappresentazione dei grafi


Un grafo è una struttura che rappresenta i dati in termini di nodi e
archi. Un grafo è rappresentato come aGraph = (v, E), dove v
rappresenta un insieme di nodi e E rappresenta un insieme di archi.
Notate che aGraph ha |v| nodi e |E| archi.
Un nodo, v ∈ v, rappresenta un oggetto concreto, come una persona,
un computer o un’attività. Un arco, ε ∈ E, collega due nodi di una
rete:
ε(v1, v2) | ε ∈ E & vi ∈ v
L’equazione precedente indica che in un grafo, tutti gli archi
appartengono a un insieme E e tutti i nodi appartengono a un insieme
v.
Un arco collega due nodi e quindi rappresenta una relazione fra di
essi. Per esempio, può rappresentare le seguenti relazioni:
un legame di amicizia fra due persone;
un legame fra due persone su LinkedIn;
una connessione fisica fra due nodi in un cluster di computer;
una persona che partecipa a una conferenza di ricerca.
In questo capitolo, utilizzeremo il pacchetto Python networkx per
rappresentare i grafi. Proviamo a creare un semplice grafo utilizzando
il pacchetto networtx in Python. Per cominciare, proviamo a creare un
grafo aGraph vuoto, senza alcun nodo:
import networkx as nx
G = nx.Graph()

Ora aggiungiamogli un nodo:


G.add_node("Mike")

Possiamo anche aggiungere più nodi, usando una lista:


G.add_nodes_from(["Amine", "Wassim", "Nick"])

E possiamo anche aggiungere un arco fra i nodi che abbiamo creato:


G.add_edge("Mike", "Amine")

Vediamo ora gli archi e i nodi:

Notate che se aggiungiamo un arco, l’operazione aggiunge


automaticamente anche i nodi citati, se non esistono, come in questo
caso:
G.add_edge("Amine", "Imran")

Se ora chiediamo l’elenco degli archi, ecco l’output che otteniamo:

La richiesta di aggiungere a un grafo un nodo già presente nel grafo


viene del tutto ignorata: il nodo esiste già. Ma la reazione dipende dal
tipo di grafico che abbiamo creato.

Tipi di grafi
I grafi possono essere classificati in quattro tipi:
grafi non orientati;
grafi orientati;
multigrafi non orientati;
multigrafi orientati.
Esaminiamoli in dettaglio.

Grafi non orientati


Nella maggior parte dei casi, le relazioni rappresentate dai nodi che
costituiscono un grafo possono essere considerate non direzionali. Tali
relazioni non impongono alcun orientamento alla relazione. Tali archi
sono chiamati archi non orientati e il grafo risultante è chiamato grafo
non orientato. La Figura 5.1 mostra un grafo non orientato.
Ecco alcuni esempi di relazioni non orientate:
Mike e Amine si conoscono;
il nodo A e il nodo B sono connessi in una rete peer-to-peer.

Figura 5.1
Grafi orientati
Un grafo in cui la relazione fra i nodi ha un certo orientamento è
chiamato grafo orientato. La Figura 5.2 mostra un grafo orientato.

Figura 5.2

Ecco alcuni esempi di relazioni orientate:


Mike e la sua casa (Mike possiede una casa, ma la casa non
possiede Mike);
John e Paul (dove John è il capo di Paul, ma non viceversa).

Multigrafi non orientati


A volte, i nodi sono legati da più tipi di relazioni. In tal caso,
possono esserci più archi che collegano gli stessi due nodi. Questi tipi
di grafi, in cui sono consentiti più archi sugli stessi nodi, sono chiamati
multigrafi non orientati. Dobbiamo indicare esplicitamente se un grafo
è un multigrafo o meno.
La Figura 5.3 mostra l’aspetto di un multigrafo non orientato.
Figura 5.3

Un esempio di relazione multidirezionale? Mike e John sono


compagni di corso ma sono anche colleghi di lavoro.

Multigrafi orientati
Se esiste una relazione direzionale fra i nodi di un multigrafo, lo
chiamiamo multigrafo orientato (Figura 5.4).
Figura 5.4

Un esempio di multigrafo orientato? In ufficio John è il capo di


Mike, ma in aula John insegna a Mike il linguaggio di
programmazione Python.

Tipi speciali di archi


Gli archi collegano fra loro i nodi di un grafo e rappresentano la
relazione fra di essi. Oltre agli archi semplici, esistono i seguenti tipi di
archi speciali.
Cappio: a volte, un nodo può avere una relazione con se stesso.
Per esempio, John trasferisce denaro dal proprio conto aziendale
al proprio conto personale. Una relazione così particolare può
essere rappresentata da un arco diretto verso il nodo stesso, un
“cappio”.
Iperarco: a volte, lo stesso arco collega più di un nodo. Un arco
che rappresenta una relazione che collega più nodi è chiamato
iperarco. Per esempio, supponiamo che Mike, John e Sarah
potrebbero lavorare su un progetto comune.

NOTA
Un grafo che ha uno o più ipernodi è detto ipergrafo.

La Figura 5.5 mostra un grafo con un cappio e un iperarco.

Figura 5.5

Un grafo può anche avere più tipi speciali di archi. Ciò significa che
un determinato grafo può avere contemporaneamente sia cappi sia
iperarchi.

Reti egocentriche
L’intorno immediato di un determinato nodo m può avere
informazioni importanti sufficienti per condurre un’analisi esaustiva
del nodo. L’ego-centro, o la egonet, si basa su questa idea. La egonet
di un determinato nodo m è costituita da tutti i nodi direttamente
connessi a m, più lo stesso nodo m. Il nodo m è chiamato ego e i suoi
vicini sono chiamati altri.
La Figura 5.6 mostra la egonet del nodo 3 di un grafo.
Figura 5.6

Notate che l’egonet rappresenta un intorno di grado 1. Questo


concetto può essere esteso agli intorni di grado n, che sono costituiti da
tutti i nodi che hanno una distanza n dal nodo considerato.

Analisi di reti sociali


L’analisi di reti sociali (SNA, Social Network Analysis) è una delle
applicazioni più importanti della teoria dei grafi. Un’analisi del grafo
di una rete è considerata un’analisi di social network se vale quanto
segue:
i nodi del grafico rappresentano persone;
gli archi che collegano i nodi rappresentano le relazioni sociali
esistenti fra persone, come un’amicizia, un hobby in comune, una
parentela, una relazione, un’antipatia e così via;
la domanda cui stiamo cercando di rispondere attraverso l’analisi
del grafo ha un forte aspetto sociale.
L’analisi delle reti sociali riflette i comportamenti umani e questo
dovrebbe sempre essere tenuto in considerazione quando si lavora in
questo campo. Mappando in un grafo le relazioni umane, l’analisi delle
reti sociali fornisce informazioni sulle interazioni umane, in grado di
aiutarci a capire le azioni delle persone.
Creando un intorno per ogni individuo e analizzando le sue azioni in
base alle sue relazioni sociali, potete ottenere spunti interessanti e
talvolta anche sorprendenti. Gli approcci alternativi all’analisi dei
singoli individui, sulla base delle loro funzioni lavorative, possono
invece fornire solo informazioni limitate.
Quindi, l’analisi delle reti sociali può essere utilizzata per quanto
segue:
comprendere i comportamenti degli utenti sulle piattaforme di
social media, come Facebook, Twitter o LinkedIn;
individuare le frodi;
individuare i comportamenti criminali nella società.

NOTA
LinkedIn ha contribuito molto alla ricerca e allo sviluppo di nuove tecniche di
analisi delle reti sociali. In effetti, LinkedIn può essere considerato un pioniere di
molti algoritmi in questo settore.

Pertanto, l’analisi delle reti sociali, grazie all’architettura


intrinsecamente distribuita e interconnessa dei social network, è uno
dei casi d’uso più potenti per la teoria dei grafi. Un altro modo per
astrarre un grafo consiste nel considerarlo una rete e applicargli un
algoritmo progettato per le reti. L’intera area di studio è chiamata
teoria dell’analisi delle reti.

Introduzione all’analisi delle reti


Sappiamo che dei dati interconnessi possono essere rappresentati
attraverso una rete. L’analisi delle reti studia le metodologie sviluppate
per esplorare e analizzare i dati rappresentati sotto forma di rete.
Esaminiamo quindi alcuni aspetti importanti dell’analisi delle reti.
Innanzitutto, notate che l’unità di base di una rete è il nodo. Una rete
è un’interconnessione di nodi, in cui ogni connessione rappresenta la
relazione fra le varie entità oggetto di indagine. Lo scopo è quello di
quantificare l’utilità e l’importanza di un nodo della rete nel contesto
del problema che stiamo cercando di risolvere. Ci sono varie tecniche
in grado di aiutarci a quantificare questa importanza.
Esaminiamo alcuni dei concetti importanti utilizzati nell’analisi
delle reti.

Scoprire il percorso più breve


Un percorso è una sequenza di nodi compresi fra un nodo iniziale e
un nodo finale, in cui nessun nodo appare due volte. Un percorso
rappresenta quindi una sequenza di nodi fra quello di partenza e quello
di arrivo. Sarà dunque un insieme di nodi, p, che collega il nodo
iniziale con il nodo finale, senza ripetizioni.
La lunghezza di un percorso si calcola contando gli archi che lo
costituiscono. In genere cerchiamo il percorso più breve. Il calcolo del
percorso più breve è ampiamente utilizzato negli algoritmi della teoria
dei grafi, ma non sempre è facile da calcolare. Possono essere utilizzati
diversi algoritmi per trovare il percorso più breve fra un nodo iniziale e
un nodo finale. Uno dei più noti è l’algoritmo di Dijkstra, pubblicato
alla fine degli anni Cinquanta. Può essere utilizzato dai dispositivi GPS
(Global Positioning System) per calcolare la distanza minima fra una
partenza e una destinazione. L’algoritmo di Dijkstra è utilizzato anche
negli algoritmi di routing delle reti.
NOTA
È in corso una vera e propria battaglia fra Google e Apple per progettare il
miglior algoritmo per Google Maps e Apple Maps. La sfida che devono
affrontare consiste nel rendere l’algoritmo talmente veloce da riuscire a
calcolare il percorso più breve in pochi secondi.

Più avanti in questo stesso capitolo parleremo dell’algoritmo


Breadth-First Search (BFS) che esegue una ricerca in ampiezza, e che
può essere modificato per diventare l’algoritmo di Dijkstra.
L’algoritmo Breadth-First Search presuppone che ogni arco di un grafo
abbia lo stesso costo. Per l’algoritmo di Dijkstra, il costo
dell’attraversamento di un grafo può essere diverso e questa
informazione deve essere considerata per trasformare l’algoritmo
Breadth-First Search nell’algoritmo di Dijkstra.
Come abbiamo visto, l’algoritmo di Dijkstra prevede una partenza e
una destinazione e calcola il percorso più breve. Se vogliamo trovare
tutti i percorsi più brevi, possiamo usare l’algoritmo di Floyd-
Warshall.

Creare un intorno
Trovare strategie per creare un intorno attorno ai nodi di interesse è
fondamentale per gli algoritmi sui grafi. Le metodologie per la
creazione di intorni si basano sulla selezione di archi diretti rispetto al
nodo di interesse. Un modo per creare un intorno consiste nello
scegliere una strategia di ordine k, che seleziona i nodi che si trovano
entro k archi di distanza dal nodo di interesse.
Esaminiamo i vari criteri per la creazione di intorni.
Triangoli
Nella teoria dei grafi, trovare nodi ben collegati fra loro è
importante ai fini dell’analisi. Una tecnica consiste nel cercare di
identificare nella rete dei triangoli, che sono sottografi costituiti da tre
nodi direttamente collegati fra loro.
Esaminiamo il caso d’uso del rilevamento delle frodi, che
esamineremo anche verso la fine di questo capitolo. Se una egonet di
un nodo m è costituita da tre nodi, incluso il nodo m, allora questa
egonet è un triangolo. Il nodo m sarà l’ego e i due nodi a esso collegati
saranno gli altri. Se entrambi i nodi altri sono noti per essere
fraudolenti, possiamo tranquillamente dichiarare che anche il nodo m è
fraudolento. Se uno dei nodi altri è coinvolto in una frode, dovremo
condurre un’ulteriore ricerca per ottenere prove conclusive della frode.

Densità
Definiamo innanzitutto un grafo completo. Chiamiamo così un grafo
in cui ogni nodo è direttamente connesso a ogni altro nodo. Se
abbiamo un grafo completo N, allora il numero di archi totali nel grafo
(Edgestotal) può essere rappresentato come segue:

È qui che entra in gioco la densità. La densità (density) misura il


numero di archi osservati rispetto al numero massimo di archi, se
Edgesobserved è il numero di archi che vogliamo osservare, allora:

Notate che, per un triangolo, la densità della rete è 1, e questa


rappresenta la rete massimamente connessa.
Le metriche di centralità
Esistono diverse misure per comprendere la centralità di un nodo in
un grafo o sottografo. Per esempio, si può quantificare l’importanza di
una persona in una rete sociale o l’importanza di un edificio in una
città.
Nell’analisi dei grafi sono ampiamente utilizzate le seguenti
metriche di centralità:
grado;
betweenness;
closeness;
autovettore.
Esaminiamoli in dettaglio.

Grado di centralità
Il grado di un nodo è il numero di archi collegati al nodo. Può
indicare quanto è ben connesso un determinato nodo e la sua capacità
di diffondere rapidamente un messaggio attraverso una rete.
Consideriamo aGraph = (v, E), dove v rappresenta un insieme di
nodi e E rappresenta un insieme di archi. Ricordiamo che aGraph ha |v|
nodi e |E| archi. Se dividiamo il grado di un nodo per (|v| – 1),
otteniamo il grado di centralità:

Ora, vediamo un esempio specifico. Considerate il grafo


rappresentato nella Figura 5.7. Nel grafo in questione, il nodo C ha
grado 4. Il suo grado di centralità può essere calcolato come segue:
Figura 5.7

Centralità in termini di connessioni: betweenness


Anche la betweenness misura la centralità di un nodo in un grafo.
Nel contesto dei social media, quantificherà la probabilità che una
persona faccia parte della comunicazione in un sottogruppo. Per una
rete di computer, la centralità betweenness quantifica l’effetto negativo
sulla comunicazione fra i nodi del grafo, in caso di fallimento del
nodo.
Per calcolare la centralità betweenness del nodo a in un certo
aGraph = (v, E), seguiamo questi passaggi.
Calcoliamo i percorsi più brevi fra ciascuna coppia di nodi di
aGraph. Rappresentiamoli con .
Da , contiamo il numero di percorsi più brevi che
passano per il nodo a. Rappresentiamo questo numero con
.
Calcoliamo la centralità betweenness con

Centralità in termini di distanze: farness e closeness


Prendiamo un grafo g. La farness (lontananza) del nodo a nel grafo
g è definita come la somma di tutte le distanze del nodo a da tutti gli
altri nodi. Notate che qui la centralità di un nodo è quantificata in base
alla sua distanza totale da tutti gli altri nodi. La closeness (vicinanza) è
l’opposto della farness.

Centralità ad autovettore
La centralità ad autovettore assegna un punteggio a tutti i nodi di un
grafo, per misurarne l’importanza nella rete. Il punteggio sarà un
indicatore del livello di connettività di un determinato nodo rispetto ad
altri nodi importanti dell’intera rete. Quando Google ha creato
l’algoritmo PageRank, che assegna un punteggio a ogni pagina web di
Internet (per esprimere la sua importanza), l’idea derivava proprio
dalla misurazione della centralità ad autovettore.
Calcolo delle metriche di centralità
usando Python
Creiamo una rete e poi proviamo a calcolarne le metriche di
centralità. Partiamo dal seguente blocco di codice:
import networkx as nx

import matplotlib.pyplot as plt

vertices = range(1, 10)

edges = [(7, 2), (2, 3), (7, 4), (4, 5), (7, 3), (7, 5), (1, 6), (1, 7), (2, 8),
(2, 9)]

G = nx.Graph()

G.add_nodes_from(vertices)

G.add_edges_from(edges)

nx.draw(G, with_labels = True, node_color = 'y', node_size = 800)

Il grafo prodotto da questo codice è rappresentato nella Figura 5.8.

Figura 5.8

Finora abbiamo studiato diverse metriche di centralità. Calcoliamole


per l’esempio precedente:
Notate che le metriche di centralità forniscono una misura di
centralità di un determinato nodo in un grafo o sottografo. Osservando
il grafico, il nodo 7 sembra avere la posizione più centrale. E in effetti
il nodo 7 ha i valori più elevati in tutte e quattro le metriche di
centralità, che riflettono la sua importanza in questo contesto.
Ora vediamo come possiamo trarre informazioni dai grafi. I grafi
sono strutture complesse, e nei loro nodi e nei loro archi, sono
memorizzate molte informazioni. Esaminiamo alcune strategie che
possono essere utilizzate per attraversare i grafi in modo efficiente, al
fine di raccogliere informazioni utili per rispondere a domande
importanti.

Attraversamento dei grafi


L’utilità dei grafi consiste nell’estrarvi informazioni.
L’attraversamento di un grafo impiega una strategia, utilizzata per
assicurarsi che ogni nodo e arco venga visitato in modo ordinato.
Occorre fare in modo che ogni nodo e arco venga visitato esattamente
una volta, né più né meno. In linea di massima, ci possono essere due
diversi modi per attraversare un grafo per cercare i dati in esso
contenuti. Procedendo in ampiezza si usa una ricerca breadth-first
(BFS, Breadth-First Search) mentre procedendo in profondità si usa
una ricerca depth-first (DFS, Depth-First Search). Esaminiamo
entrambi questi algoritmi.

Ricerca in ampiezza: breadth-first


La ricerca breadth-first (BFS) funziona al meglio quando il
problema può essere affrontato considerando intorni che procedono a
strati o a livelli nell’aGraph con cui abbiamo a che fare. Per esempio,
quando il grafo esprime le connessioni di una persona in LinkedIn,
avremo connessioni dirette e poi connessioni secondarie, una struttura
che si traduce direttamente in più livelli.
L’algoritmo breadth-first parte da un nodo radice ed esplora prima i
nodi vicini. Poi si sposta all’intorno del livello successivo e ripete
l’esplorazione.
Vediamo un algoritmo breadth-first: consideriamo innanzitutto il
grafo non orientato rappresentato nella Figura 5.9.
Figura 5.9

Cominciamo calcolando l’intorno immediato di ciascun nodo e


immagazziniamolo in una lista, detta lista delle adiacenze. In Python,
possiamo usare la struttura di dati del dizionario per memorizzarla:
graph = {'Amin' : {'Wasim', 'Nick', 'Mike'},
'Wasim' : {'Imran', 'Amin'}, 'Imran' : {'Wasim', 'Faras'}, 'Faras' : {'Imran'},
'Mike' : {'Amin'},
'Nick' : {'Amin'}}

Per implementarla in Python, procediamo come segue.


Esamineremo prima l’inizializzazione e poi il ciclo principale.

Inizializzazione
Useremo due strutture di dati:
visited contiene tutti i nodi che sono stati visitati, e inizialmente,
sarà vuota;
queue contiene tutti i nodi che vogliamo visitare nelle prossime
iterazioni.

Il ciclo principale
Ora implementeremo il ciclo principale. Continueremo a eseguire il
ciclo fino a esaurire tutti gli elementi presenti in queue. Per ogni nodo in
, se è già stato visitato, dobbiamo visitare il suo vicino.
queue

Possiamo implementare questo ciclo principale in Python come


segue.
1. Innanzitutto, estraiamo da queue il primo nodo e scegliamolo come
nodo corrente di questa iterazione:
node = queue.pop(0)

2. Quindi, controlliamo che il nodo non sia presente nella lista


visited. Se non lo è, prima lo aggiungiamo alla lista dei nodi

visitati (visited) e poi usiamo i suoi vicini per rappresentare i suoi


nodi direttamente connessi:
visited.append(node)

neighbours = graph[node]

3. Ora aggiungiamo a queue i nodi vicini:


for neighbour in neighbours:

queue.append(neighbour)

4. Una volta completato il ciclo principale, restituiamo la struttura


visited, che contiene tutti i nodi attraversati.

5. Il codice completo, con l’inizializzazione e il ciclo principale, ha


il seguente aspetto:
Esaminiamo lo schema di attraversamento esaustivo utilizzando la
ricerca breadth-first per il grafo che abbiamo definito. Lo schema di
attraversamento per visitare tutti i nodi è illustrato nella Figura 5.10.
Potete osservare che, durante l’esecuzione, opera sempre su due
strutture di dati:
visited contiene tutti i nodi che sono già stati visitati;
queue contiene i nodi ancora da visitare.

Ecco come funziona l’algoritmo.


1. Parte dal primo nodo, Amin, che è l’unico nodo al livello 1.
2. Quindi, si sposta al livello 2 e visita i tre nodi Wasim, Nick e Mike,
uno per uno.
3. Successivamente, si sposta al livello 3 e poi al livello 4, che
hanno un solo nodo ciascuno: Imran e Faras.

Una volta che tutti i nodi sono stati visitati e aggiunti alla struttura di
dati visited, le iterazioni si fermano (vedi Figura 5.10).
Ora, proviamo a trovare una persona, nella Figura 5.10, utilizzando
la ricerca breadth-first.Specifichiamo i dati che stiamo cercando e
osserviamo i risultati:
Ora esaminiamo l’algoritmo di ricerca in profondità, depth-first.

Ricerca in profondità: depth-first


La ricerca depth-first è un’alternativa alla ricerca in ampiezza
(breadth-first) per trarre dati da un grafo. Ciò che differenzia la ricerca
depth-first dalla ricerca breadth-first è che dopo essere partiti dal nodo
radice, l’algoritmo scende il più possibile nel primo percorso
disponibile e poi passa al successivo, esaminandoli uno per uno. In
pratica, per ogni percorso raggiunge la profondità massima,
contrassegnando come visitati tutti i nodi associati a quel percorso.
Dopo aver completato il primo percorso, l’algoritmo torna indietro di
un passo e segue il percorso successivo, sempre in profondità.
L’algoritmo continua a seguire ogni percorso in profondità fino a
esaurire tutti i nodi del grafo.
Notate che un grafico può contenere cicli. Usiamo un flag booleano
per tenere traccia dei nodi che sono già stati elaborati, per evitare la
ripetizione dei cicli di ricerca.
Figura 5.10

Per implementare la ricerca depth-first, utilizzeremo la struttura di


dati stack, trattata in dettaglio nel Capitolo 2. La struttura a stack si
basa sul principio Last In, First Out (LIFO), mentre la struttura a coda,
utilizzata per la ricerca breadth-first, si basa sul principio First In, First
Out (FIFO).
Ecco il codice utilizzato per la ricerca depth-first:
def dfs(graph, start, visited = None):
if visited is None:

visited = set()

visited.add(start)

print(start)

for next in graph[start] – visited:

dfs(graph, next, visited)

return visited

Utilizziamo sempre lo stesso grafo per sottoporre a test la funzione


dfs():

graph = {'Amin' : {'Wasim', 'Nick', 'Mike'},

'Wasim' : {'Imran', 'Amin'},

'Imran' : {'Wasim', 'Faras'},

'Faras' : {'Imran'},

'Mike' : {'Amin'},

'Nick' : {'Amin'}}

Se eseguiamo questo algoritmo, l’output sarà simile al seguente:

Esaminiamo il modello di attraversamento esaustivo di questo grafo


utilizzando l’algoritmo depth-first.
1. Inizia l’iterazione dal nodo radice, Amin.
2. Quindi, passa al livello 2, Wasim. Da lì, scendi nei livelli inferiori,
fino a raggiungere la fine, che sono i nodi Imran e Fares.
3. Dopo aver completato il primo ramo, torna indietro e scendi al
livello 2 per visitare Nick e Mike.

Lo schema di attraversamento è rappresentato nella Figura 5.11.


NOTA
L’algoritmo depth-first può essere utilizzato anche per l’attraversamento degli
alberi.
Esaminiamo ora un caso di studio, che spiega come impiegare i
concetti discussi finora per risolvere un problema concreto.

Figura 5.11

Caso di studio: analisi delle frodi


Vediamo come possiamo utilizzare l’analisi delle reti sociali per
rilevare le frodi. Poiché “l’uomo è un animale sociale”, il nostro
comportamento è influenzato dalle persone che ci circondano. Per
rappresentare l’effetto di una rete sociale su una persona è stata coniata
la parola omofilia. Estendendo questo concetto, una rete di omofilia è
costituita da un gruppo di persone che è più probabile siano associate
fra loro a causa di un qualche fattore comune, per esempio la stessa
origine o lo stesso hobby, l’appartenenza allo stesso gruppo o
l’iscrizione alla stessa università, o una combinazione di altri fattori.
Per analizzare le frodi in una rete di omofilia, possiamo sfruttare i
rapporti fra l’indagato e le altre persone della sua rete, per calcolare la
probabilità di coinvolgimento nella frode. Il fatto di segnalare una
persona a causa delle sue frequentazioni è chiamato anche indagine per
associazione.
Nel tentativo di comprendere questo processo, esaminiamo
innanzitutto un caso semplice. Consideriamo una rete con nove nodi e
otto archi. In questa rete, quattro dei nodi sono casi di frode noti e sono
classificati con F. Cinque delle persone rimanenti non hanno una storia
di frodi e sono classificate come NF.
Scriveremo il codice contenente i seguenti passaggi per generare
questo grafo.
1. Importiamo i pacchetti di cui abbiamo bisogno:
import networkx as nx

import matplotlib.pyplot as plt

2. Definiamo le strutture dei dati, in termini di nodi e archi:


vertices = range(1, 10)

edges = [(7, 2), (2, 3), (7, 4), (4, 5), (7, 3), (7, 5), (1, 6), (1, 7),
(2, 8), (2, 9)]

3. Istanziamo prima il grafo:


G = nx.Graph()

4. Ora disegniamo il grafo:


G.add_nodes_from(vertices)
G.add_edges_from(edges)

pos = nx.spring_layout(G)
5. Definiamo i nodi NF:
nx.draw_networkx_nodes(G, pos,
nodelist = [1, 4, 3, 8, 9],
with_labels = True,
node_color = 'g',

node_size = 1300)

6. Ora creiamo i nodi coinvolti in frodi:


nx.draw_networkx_nodes(G, pos,
nodelist = [2, 5, 6, 7],
with_labels = True,
node_color = 'r',

node_size = 1300)

7. Creiamo le etichette per i nodi:


nx.draw_networkx_edges(G, pos, edges, width = 3, alpha = 0.5, edge_color = 'b')

labels = {}

labels[1] = r'1 NF'

labels[2] = r'2 F'

labels[3] = r'3 NF'

labels[4] = r'4 NF'

labels[5] = r'5 F'

labels[6] = r'6 F'

labels[7] = r'7 F'

labels[8] = r'8 NF'

labels[9] = r'9 NF'

nx.draw_networkx_labels(G, pos, labels,font_size = 16)

Una volta eseguito il codice precedente, otterremo un grafo come


quello rappresentato nella Figura 5.12.
Figura 5.12

Notate che abbiamo già condotto un’analisi dettagliata per


classificare ogni nodo come F o NF. Supponiamo di aggiungere alla
rete un altro nodo, denominato ?, visibile nella Figura 5.13. Non
abbiamo informazioni su questa persona e non sappiamo se sia
implicata o meno in una frode. Vogliamo classificare questa persona
come NF o F in base ai suoi legami con gli altri membri della rete
sociale.

Figura 5.13
Abbiamo escogitato due modi per classificare questa nuova persona,
che denoteremo con q, come implicato (F ) o non implicato (NF ) in
una frode:
usando un metodo semplice, che non utilizza metriche di
centralità e informazioni aggiuntive sul tipo di frode;
usando una metodologia watchtower, una tecnica avanzata che
utilizza le metriche di centralità dei nodi esistenti, nonché
informazioni aggiuntive sul tipo di frode.
Esamineremo in dettaglio entrambi questi metodi.

Una semplice analisi delle frodi


La tecnica semplice di analisi delle frodi si basa sul presupposto che
in una rete il comportamento di una persona sia influenzato dalle
persone cui è legato. In una rete, è più probabile che due nodi abbiano
un comportamento simile se hanno un legame.
Sulla base di questo presupposto, escogitiamo una tecnica semplice.
Se vogliamo trovare la probabilità che un certo nodo, q, appartenga a
F, la probabilità è rappresentata da P(F/q) e si calcola come segue:

Applichiamo questa formula alla Figura 5.13, dove Neighborhoodn


rappresenta l’intorno del nodo n e w(n, nj) rappresenta il peso del
legame fra n e nj. Inoltre, degreeq è il grado del nodo q. La probabilità
si calcola come segue:
Sulla base di questa analisi, la probabilità che questa persona sia
coinvolta in una frode è del 67%. Dobbiamo stabilire una soglia. Se
poniamo la soglia al 30%, allora questa persona è situata al di sopra
del valore di soglia e possiamo tranquillamente contrassegnarla come
F.
Notate che questo processo deve essere ripetuto per ciascuno dei
nuovi nodi della rete.
Ora, vediamo un modo più avanzato di condurre l’analisi delle frodi.

La metodologia di analisi delle frodi


watchtower
La precedente tecnica di analisi delle frodi ha le seguenti due
limitazioni.
Non valuta l’importanza di ogni nodo della rete sociale. Un
legame che vede un intero gruppo coinvolto in una frode ha
implicazioni diverse rispetto a un semplice legame alla lontana
con una persona coinvolta.
Quando etichettiamo qualcuno semplicemente come un caso noto
di frode in una rete, non consideriamo la gravità del suo crimine.
La metodologia di analisi delle frodi watchtower affronta queste due
limitazioni. Innanzitutto, esaminiamo un paio di concetti.

Segnare risultati negativi


Se una persona è nota per essere coinvolta in una frode, diciamo che
c’è un risultato negativo associato a questa persona. Non tutti i risultati
negativi sono della stessa gravità. Una persona che abbia attuato un
furto d’identità avrà un risultato negativo più serio rispetto a chi abbia
solo cercato di utilizzare una carta regalo da 20 dollari scaduta,
cercando di renderla valida.
Con un punteggio da 1 a 10, valutiamo i vari risultati negativi come
segue.
Risultato negativo Punteggio
Furto d’identità 10
Coinvolgimento in furto di carte di credito 8
Emissione di assegni falsi 7
Fedina penale sporca 6
Nessuna registrazione 0

Notate che questi punteggi avranno un peso sulla nostra analisi dei
casi di frode e derivano da dati storici.

Grado di sospetto
Il grado di sospetto (DoS, Degree of Suspicion) quantifica il nostro
livello di sospetto che una persona possa essere coinvolta in una frode.
Un valore DoS pari a 0 significa che si tratta di una persona a basso
rischio; un valore DoS pari a 9 significa che si tratta di una persona ad
alto rischio.
L’analisi dei dati storici mostra che i truffatori professionisti hanno
posizioni elevate nella loro rete sociale. Per esplicitare questo concetto,
prima calcoliamo le quattro metriche di centralità di ciascun nodo nella
nostra rete e poi prendiamo la media di questi nodi. Questo ci dice
l’importanza di quella particolare persona nella rete.
Se una persona associata a un nodo è coinvolta in una frode,
illustriamo questo esito negativo assegnandole un punteggio
utilizzando i valori predeterminati mostrati nella tabella precedente.
Questo viene fatto in modo che la gravità del reato si rifletta nel valore
DoS di ogni singola persona.
Infine, moltiplichiamo la media delle metriche di centralità e il
punteggio di esito negativo per ottenere il valore del DoS.
Normalizziamo il DoS dividendolo per il valore massimo del DoS
nella rete.
Calcoliamo il DoS per ciascuno dei nove nodi della rete precedente.
Nodo Nodo Nodo Nodo Nodo Nodo Nodo Nodo
Nodo 1
2 3 4 5 6 7 8 9
Grado di
0,25 0,5 0,25 0,25 0,25 0,13 0,63 0,13 0,13
centralità
Betweenness 0,25 0,47 0 0 0 0 0,71 0 0
Closeness 0,5 0,61 0,53 0,47 0,47 0,34 0,72 0,4 0,4
Autovettore 0,24 0,45 0,36 0,32 0,32 0,08 0,59 0,16 0,16
Media delle
metriche di 0,31 0,51 0,29 0,26 0,26 0,14 0,66 0,17 0,17
centralità
Punteggio
0 6 0 0 7 8 10 0 0
negativo
DoS 0 3 0 0 1,82 1,1 6,625 0 0
DoS
0 0,47 0 0 0,27 0,17 1 0 0
normalizzato

La Figura 5.14 mostra ciascuno dei nodi e il loro DoS normalizzato.

Figura 5.14

Per calcolare il DoS del nuovo nodo che è stato aggiunto


utilizzeremo la seguente formula:
Utilizzando i relativi valori, calcoleremo il DoS come segue:

Questo indicherà il rischio di frode associato a questo nuovo nodo


aggiunto al sistema. Su una scala da 0 a 1, questa persona ha un valore
DoS pari a 0,42. Possiamo creare diversi livelli di rischio per il DoS,
come segue.
Valore del DoS Classificazione del rischio
DoS = 0 Nessun rischio
0 < DoS <= 0,10 Basso rischio
0,10 < DoS <= 0,3 Rischio medio
DoS > 0,3 Rischio alto

Sulla base di questi criteri, possiamo vedere che il nuovo individuo


è una persona ad alto rischio e dovrebbe essere segnalato.
Di solito, in un’analisi di questo tipo non è coinvolta una
dimensione temporale, ma ora esistono tecniche avanzate in grado di
osservare gli sviluppi di un fenomeno con il passare del tempo. Ciò
consente ai ricercatori di esaminare anche l’evoluzione delle relazioni
fra i nodi della rete. Sebbene tale analisi temporali sui grafi aumenti
notevolmente la complessità del problema, può fornire interessanti
informazioni sulle prove di frode, che altrimenti non sarebbero
possibili.

Riepilogo
In questo capitolo abbiamo trattato gli algoritmi basati su grafi. Con
lo studio di questo capitolo, dovreste essere in grado di utilizzare varie
tecniche di rappresentazione, ricerca ed elaborazione dei dati
rappresentati come grafi. Abbiamo anche sviluppato la capacità di
calcolare la distanza più breve fra due nodi e abbiamo costruito intorni
nello spazio del problema. Questa conoscenza dovrebbe aiutarci a
utilizzare la teoria dei grafi per affrontare problemi come il
rilevamento delle frodi.
Nel prossimo capitolo, ci concentreremo su vari algoritmi di
machine learning senza supervisione. Molte delle tecniche discusse in
questo capitolo si ripresentano anche negli algoritmi di apprendimento
senza supervisione, per esempio per trovare prove di frode in un
dataset.
Parte II

Algoritmi di machine learning

Questa parte del libro è dedicata ai diversi tipi di algoritmi di


machine learning, con e senza supervisione, e introduce anche agli
algoritmi per l’elaborazione del linguaggio naturale. Questa parte
termina introducendo i motori di raccomandazione.
Capitolo 6

Algoritmi di machine learning


senza supervisione

Questo capitolo tratta gli algoritmi di machine learning senza


supervisione. Il capitolo si apre con un’introduzione alle tecniche di
apprendimento senza supervisione. Quindi, impareremo a conoscere
due algoritmi di clustering: clustering k-means e clustering gerarchico.
Poi esamineremo un algoritmo di riduzione della dimensionalità, che
può essere efficace quando abbiamo un gran numero di variabili di
input. Quindi vedremo come utilizzare l’apprendimento senza
supervisione per il rilevamento delle anomalie. Infine, esamineremo
una delle più potenti tecniche di apprendimento senza supervisione,
l’estrazione di regole associative. Questo paragrafo spiega anche come
i modelli scoperti dall’estrazione delle regole associative rappresentino
relazioni interessanti fra i vari punti dei dati presenti nelle transazioni,
in grado di aiutarci nel nostro processo decisionale.
Alla fine di questo capitolo, dovreste essere in grado di capire come
utilizzare l’apprendimento senza supervisione per risolvere alcuni
problemi concreti.

Introduzione all’apprendimento
senza supervisione
Ecco una definizione semplice di apprendimento senza
supervisione: è il processo che fornisce un qualche tipo di struttura a
dati non strutturati, scoprendo e utilizzando schemi presenti nei dati.
Se i dati non sono stati prodotti da un processo casuale, saranno
rilevabili dei pattern nei suoi dati, nello spazio multidimensionale del
problema. Gli algoritmi di apprendimento senza supervisione
funzionano scoprendo questi schemi e utilizzandoli per trovare una
struttura per il dataset. Questo concetto è rappresentato nella Figura
6.1.

Figura 6.1

Notate che l’apprendimento senza supervisione introduce una


struttura scoprendo uno schema presente nei dati.

L’apprendimento senza supervisione nel


ciclo di vita del data mining
Per comprendere il ruolo dell’apprendimento senza supervisione, è
importante esaminare in generale il ciclo di vita del processo di data
mining. Esistono diverse metodologie che dividono il ciclo di vita del
processo di data mining in più fasi. Attualmente, ci sono due modi
popolari per rappresentare il ciclo di vita del data mining:
CRISP-DM (Cross-Industry Standard Process for Data Mining);
SEMMA (Sample, Explore, Modify, Model, Access).
CRISP-DM è stato sviluppato da un consorzio di esperti di data
mining di diverse società, fra cui Chrysler e SPSS (Statistical Package
for Social Science). SEMMA è stato proposto da SAS (Statistical
Analysis System). Vediamo una di queste due rappresentazioni del
ciclo di vita del data mining, ovvero CRISP-DM, e cerchiamo di capire
il ruolo dell’apprendimento senza supervisione nel ciclo di vita del
data mining. Notate che anche SEMMA prevede fasi simili, nel suo
ciclo di vita.
Osservando il ciclo di vita CRISP-DM (Figura 6.2), possiamo
vedere che si compone di sei fasi distinte. Esaminiamole una per una:
Fase 1. Comprensione del problema: si tratta di raccogliere i
requisiti e tentare di comprendere appieno il problema. Definire
l’ambito del problema e riformularlo correttamente secondo un
approccio di machine learning, ovvero di apprendimento
automatico, è una parte importante di questa fase. Per esempio,
per un problema di classificazione binaria, a volte è utile
formulare i requisiti in termini di un’ipotesi che può essere
dimostrata o respinta. Questa fase riguarda anche la
documentazione delle aspettative per il modello di machine
learning che verrà poi addestrato nella Fase 4. Per esempio, per
un problema di classificazione, è necessario stabilire
l’accuratezza minima accettabile per il modello da implementare
in produzione.
Figura 6.2

NOTA
È importante notare che la Fase 1 del ciclo di vita CRISP-DM riguarda la
comprensione operativa. Si concentra su ciò che deve essere fatto, non su
come verrà fatto.

Fase 2. Studio dei dati: si tratta di comprendere quali dati sono


disponibili per il data mining. In questa fase, scopriremo se sono
disponibili i dataset giusti per il problema che stiamo cercando di
risolvere. Dopo aver identificato i dataset, è necessario scoprire la
qualità dei dati e la loro struttura. Abbiamo bisogno di scoprire
quali modelli possono essere estratti dai dati e possono
potenzialmente condurci verso importanti conoscenze.
Cercheremo anche di trovare la feature che può essere utilizzata
come etichetta (la variabile target) in base ai requisiti raccolti
nella Fase 1. Gli algoritmi di apprendimento senza supervisione
possono svolgere un ruolo importante nel raggiungimento degli
obiettivi della Fase 2. Gli algoritmi senza supervisione possono
essere utilizzati per i seguenti scopi:
- per individuare schemi presenti nel dataset;
- per comprendere la struttura del dataset, analizzando i pattern
individuati;
- per identificare o derivare la variabile target.
Fase 3. Preparazione dei dati: si tratta di preparare i dati per il
modello di machine learning che addestreremo nella Fase 4. I dati
etichettati disponibili sono divisi in due parti disuguali. La parte
più grande è costituita dai dati di training e viene utilizzata per il
successivo addestramento del modello, nella Fase 4. La parte più
piccola è costituita dai dati di test e viene utilizzata nella Fase 5
per la valutazione del modello. In questa fase, gli algoritmi di
machine learning senza supervisione possono essere utilizzati
come strumento per preparare i dati. Per esempio, possono essere
impiegati per convertire i dati non strutturati in dati strutturati,
fornendo dimensioni aggiuntive che potranno poi essere utili per
l’addestramento del modello.
Fase 4. Modellazione: questa è la fase in cui utilizziamo
l’apprendimento con supervisione per formulare i modelli che
abbiamo individuato. Ci si aspetta che prepariamo i dati in base ai
requisiti dell’algoritmo di apprendimento con supervisione scelto.
Questa è anche la fase in cui verrà identificata la feature che verrà
impiegata come etichetta. Nella Fase 3, abbiamo diviso i dati in
un dataset di training e un dataset di test. In questa fase, creiamo
formulazioni matematiche per rappresentare le relazioni presenti
nei nostri modelli. Per farlo, addestriamo il modello utilizzando i
dati di training creati nella Fase 3. La formulazione matematica
risultante dipenderà dalla scelta dell’algoritmo.
Fase 5. Valutazione: questa fase sottopone a test il modello
appena addestrato, utilizzando i dati di test messi da parte nella
Fase 3. Se questa valutazione non corrisponde alle aspettative
stabilite nella Fase 1, occorre ripetere nuovamente tutte le fasi
precedenti, a partire dalla Fase 1. Questo passaggio è illustrato
nell’immagine precedente.
Fase 6. Distribuzione: se la valutazione soddisfa o supera le
aspettative descritte nella Fase 5, il modello addestrato viene
distribuito in produzione e inizia a generare una soluzione al
problema definito nella Fase 1.

NOTA
La Fase 2 (Studio dei dati) e la Fase 3 (Preparazione dei dati) del ciclo di vita
CRISP-DM riguardano la studio e la preparazione dei dati da impiegare per
l’addestramento del modello. Queste fasi comportano l’elaborazione dei dati.
Alcune organizzazioni impiegano appositi specialisti per questa fase.

È evidente che il processo di individuare una soluzione per il


problema è completamente guidato dai dati. Viene utilizzata una
combinazione di machine learning con e senza supervisione per
formulare una soluzione praticabile. Questo capitolo si concentra
sull’apprendimento senza supervisione della soluzione.
NOTA
L’ingegnerizzazione dei dati comprende le Fasi 2 e 3 ed è la parte più lunga del
machine learning. Può richiedere fino al 70% del tempo e delle risorse di un
tipico progetto di machine learning. Gli algoritmi di apprendimento senza
supervisione possono svolgere un ruolo importante nell’ingegnerizzazione dei
dati.

I prossimi paragrafi forniscono maggiori dettagli sugli algoritmi


senza supervisione.

Tendenze attuali della ricerca


nell’apprendimento senza supervisione
Per anni, la ricerca sugli algoritmi di machine learning si è
concentrata maggiormente sulle tecniche di machine learning con
supervisione. Poiché queste possono essere utilizzate direttamente per
l’inferenza, i loro benefici in termini di tempo, costo e accuratezza
sono misurabili con una relativa facilità. I pregi degli algoritmi di
machine learning senza supervisione sono stati riconosciuti solo più
recentemente. Poiché l’apprendimento senza supervisione non è
guidato, dipende meno dalle ipotesi e, potenzialmente, può far
convergere la soluzione in qualsiasi dimensione. Sebbene sia più
difficile controllare l’ambito e i requisiti di elaborazione degli
algoritmi di apprendimento senza supervisione, essi hanno una
maggiore capacità di portare alla luce schemi nascosti. I ricercatori
stanno anche lavorando per combinare insieme tecniche di machine
learning con e senza supervisione, al fine di progettare nuovi potenti
algoritmi.

Esempi pratici
Attualmente, l’apprendimento senza supervisione viene utilizzato
per farsi un’idea dei dati e fornire loro maggiore struttura, per esempio
nella segmentazione del marketing, nel rilevamento delle frodi e
nell’analisi del paniere di mercato (argomento che verrà trattato più
avanti in questo stesso capitolo). Vediamo un paio di esempi.

Classificazione vocale
L’apprendimento senza supervisione può essere utilizzato per
classificare le singole voci in un file vocale. Utilizza il fatto che la
voce di ogni individuo ha caratteristiche distinte, dando origine a
modelli audio potenzialmente separabili. Questi modelli possono poi
essere utilizzati per il riconoscimento vocale. Per esempio, Google
utilizza questa tecnica nei propri dispositivi Google Home, per
addestrarli a distinguere le voci di più persone. Una volta addestrato,
Google Home può personalizzare la risposta in base all’utente che ha
effettuato la richiesta.
Per esempio, supponiamo di avere una conversazione registrata di
tre persone che parlano fra loro per mezz’ora. Utilizzando algoritmi di
apprendimento senza supervisione, possiamo identificare le voci di
queste tre persone. Notate che attraverso l’apprendimento senza
supervisione, stiamo definendo una struttura a dei dati non strutturati.
Questa struttura ci offre ulteriori dimensioni utili nello spazio del
problema, che possono essere utilizzate per ottenere informazioni e
preparare i dati per l’algoritmo di machine learning scelto. La Figura
6.3 mostra come viene utilizzato l’apprendimento senza supervisione
per il riconoscimento vocale.

Figura 6.3

Notate che, in questo caso, l’apprendimento senza supervisione


suggerisce di aggiungere una nuova feature, con tre valori distinti.

Classificazione dei documenti


Gli algoritmi di machine learning senza supervisione possono essere
applicati anche a un archivio di dati testuali non strutturati. Per
esempio, se disponiamo di un dataset di documenti PDF,
l’apprendimento senza supervisione può essere utilizzato per eseguire
le seguenti operazioni:
individuare gli argomenti trattati nel dataset;
associare ogni documento PDF a uno degli argomenti individuati.
Questo uso dell’apprendimento senza supervisione per la
classificazione dei documenti è rappresentato nella Figura 6.4. Questo
è un altro esempio in cui stiamo dotando di una struttura dei dati non
strutturati. Notate che, in questo caso, l’apprendimento senza
supervisione suggerisce di aggiungere una nuova feature con cinque
diversi valori.

Figura 6.4

Gli algoritmi di clustering


Una delle tecniche più semplici e potenti utilizzate
nell’apprendimento senza supervisione si basa sul raggruppamento di
elementi simili attraverso algoritmi di clustering. Viene utilizzato per
valutare un determinato aspetto dei dati che è correlato al problema
che stiamo cercando di risolvere. Gli algoritmi di clustering cercano di
individuare raggruppamenti naturali nei punti dei dati. Poiché questo
raggruppamento non si basa su alcun obiettivo o ipotesi, è classificato
come una tecnica di apprendimento senza supervisione.
I raggruppamenti creati dai vari algoritmi di clustering si basano
sulla ricerca delle somiglianze fra i vari punti dei dati nello spazio del
problema. Il modo migliore per determinare le somiglianze fra i punti
dei dati varia da problema a problema e dipende dalla natura del
problema con cui abbiamo a che fare. Esaminiamo i vari metodi che
possono essere utilizzati per calcolare le somiglianze fra i vari punti
dei dati.

Quantificare le somiglianze
L’affidabilità del raggruppamento creato dagli algoritmi di
clustering si basa sul presupposto che possiamo quantificare con
precisione le somiglianze, la vicinanza fra i vari punti dei dati nello
spazio del problema. Per farlo possiamo utilizzare varie misure di
distanza. Ecco tre dei metodi più utilizzati per quantificare le
somiglianze:
distanza euclidea;
distanza di Manhattan;
distanza del coseno.
Esaminiamo queste misure di distanza in modo più dettagliato.

Distanza euclidea
La distanza fra i diversi punti può quantificare la somiglianza fra
due punti dei dati ed è ampiamente utilizzata nelle tecniche di machine
learning senza supervisione, come il clustering. La distanza euclidea è
la misura più comune e semplice. Viene calcolata misurando la
distanza più breve fra due punti dei dati nello spazio
multidimensionale. Per esempio, consideriamo due punti, A(1, 1) e
B(4, 4), in uno spazio bidimensionale, come mostrato nella Figura 6.5.

Figura 6.5

Per calcolare la distanza fra A e B, cioè d(A, B), possiamo usare la


seguente formula pitagorica:

Notate che questo calcolo è relativo a uno spazio del problema


bidimensionale. Per uno spazio del problema n-dimensionale,
possiamo calcolare la distanza fra due punti A e B come segue:
Distanza di Manhattan
In molte situazioni, misurare la distanza più breve fra due punti
utilizzando la distanza euclidea non ci dirà veramente la somiglianza o
la vicinanza fra due punti. Per esempio, se due punti dei dati
rappresentano posizioni su una mappa, la distanza effettiva dal punto
A al punto B utilizzando un mezzo di trasporto, come un’auto o un
taxi, sarà maggiore della distanza euclidea. Per situazioni come queste,
usiamo la distanza di Manhattan, che segna il percorso effettivo più
breve fra due punti e riflette meglio la vicinanza di quei due punti nel
contesto, per esempio, di una città. La Figura 6.6 mette a confronto la
distanza di Manhattan e quella euclidea.
Figura 6.6

Notate che la distanza di Manhattan sarà sempre uguale o maggiore


della corrispondente distanza euclidea calcolata.

Distanza del coseno


Le misure di distanza euclidea e di Manhattan non funzionano bene
in uno spazio multidimensionale. In uno spazio del problema a elevata
dimensionalità, la distanza del coseno riflette in modo più accurato la
vicinanza fra due punti dei dati. La distanza del coseno viene calcolata
misurando il coseno dell’angolo formato dai due punti rispetto a un
punto di riferimento. Come si vede nella Figura 6.7, se i punti dei dati
sono nella stessa direzione, l’angolo sarà stretto, indipendentemente
dalla loro distanza. D’altra parte, se sono in direzioni differenti,
l’angolo sarà grande.

Figura 6.7

NOTA
I dati testuali possono quasi essere considerati uno spazio altamente
dimensionale. Poiché la misura della distanza del coseno funziona molto bene
con gli spazi altamente dimensionali, è una buona scelta per i dati testuali.

Notate che nella Figura 6.7, la distanza del coseno è il coseno


dell’angolo formato da A(2, 5) e B(4.4) rispetto all’origine, cioè X(0,
0). In realtà, qualsiasi punto dello spazio del problema, non
necessariamente l’origine, può fungere da punto di riferimento dei dati.

L’algoritmo di clustering k-means


Il nome dell’algoritmo deriva dal fatto che cerca di creare un certo
numero, k, di cluster calcolando le medie (means) per trovare la
vicinanza fra i punti dei dati. Anche se utilizza un approccio di
clustering relativamente semplice, è ancora molto in uso per la sua
scalabilità e velocità. Come algoritmo, il clustering k-means utilizza
una logica iterativa che sposta il centro dei cluster fino a individuare il
punto dei dati più rappresentativo del raggruppamento cui
appartengono.
È importante notare che gli algoritmi k-means mancano di una delle
funzionalità di base necessarie per il clustering: che per un certo
dataset, l’algoritmo k-means non può determinare il numero più
appropriato di cluster. Questo numero, k, dipende dal numero di
raggruppamenti naturalmente presenti in un determinato dataset. La
filosofia alla base di questa omissione è quella di mantenere
l’algoritmo il più semplice possibile, per massimizzare le sue
prestazioni. Questo design “snello” rende l’algoritmo k-means adatto a
dataset anche di grandi dimensioni. Il presupposto è che verrà
utilizzato un meccanismo esterno per calcolare k. Il modo migliore per
determinare k dipende dal problema che stiamo cercando di risolvere.
In alcuni casi, k è specificato direttamente dal contesto del problema di
clustering. Per esempio, se vogliamo dividere una classe di studenti di
data science in due cluster, uno composto da studenti con competenze
di data science e l’altro con competenze di programmazione, allora k
sarà 2. In altri problemi, il valore di k potrebbe non essere così ovvio.
In tali casi, sarà necessario utilizzare una procedura iterativa trial-and-
error o un algoritmo euristico per stimare il numero più appropriato di
cluster per quello specifico dataset.

La logica del clustering k-means


Questo paragrafo descrive la logica dell’algoritmo di clustering k-
means. Vediamo i suoi aspetti, uno per uno.

Inizializzazione
Per raggruppare i punti dei dati, l’algoritmo k-means utilizza una
misura della distanza per trovare la somiglianza o vicinanza fra i punti
dei dati. Prima di utilizzare l’algoritmo k-means, è necessario
selezionare la misura della distanza più appropriata. Per default, verrà
utilizzata la distanza euclidea. Inoltre, se il dataset contiene valori
anomali, è necessario ideare un meccanismo per determinare i criteri
che devono essere identificati e per rimuovere dal dataset tali valori
anomali.

I passaggi dell’algoritmo k-means


I passaggi coinvolti nell’algoritmo di clustering k-means sono i
seguenti.
1. Scegliamo il numero di cluster, k.
2. Fra i punti dei dati, scegliamo casualmente k punti come candidati
centri dei cluster.
3. In base alla misura della distanza selezionata, calcoliamo in modo
iterativo la distanza fra ciascun punto nello spazio del problema e
ciascuno dei k centri del cluster. In base alla dimensione del
dataset, questo passaggio può richiedere molto tempo. Per
esempio, se abbiamo 10.000 punti e k = 3, sarà necessario
calcolare 30.000 distanze.
4. Assegniamo ogni punto dei dati nell’area del problema al centro
(cluster) più vicino.
5. Ora a ogni punto dei dati nello spazio del problema abbiamo
assegnato un cluster. Ma non abbiamo finito, poiché la scelta
iniziale dei centri dei cluster è stata casuale. Dobbiamo verificare
che i centri selezionati casualmente rappresentino effettivamente
il centro di gravità di ciascun cluster. Ricalcoliamo i centri dei
cluster calcolando la media dei punti dei dati costitutivi di
ciascuno dei k cluster. Questo passaggio spiega perché questo
algoritmo è chiamato k-means.
6. Se il Passaggio 5 ha spostato i centri dei cluster, ciò obbliga a
ricalcolare per ciascun punto dei dati l’assegnazione al relativo
cluster. Per questo, occorre tornare al (costoso, dal punto di vista
computazionale) Passaggio 3 per ripeterlo. Se i centri del cluster
non si sono spostati o se la nostra condizione di arresto
predeterminata (per esempio, il numero di iterazioni massime) è
soddisfatta, abbiamo finito.
La Figura 6.8 mostra il risultato dell’esecuzione dell’algoritmo k-
means in uno spazio del problema bidimensionale:

Figura 6.8 In (a) i punti dei dati prima del clustering; in (b) i cluster risultanti dopo aver
eseguito l’algoritmo di clustering k-means.

Notate che i due cluster risultanti creati dopo l’esecuzione di k-


means sono ben differenziati, in questo specifico caso.
Condizione di arresto
Per l’algoritmo k-means, la condizione di arresto predefinita si ha
quando non vi è più alcuno spostamento dei centri dei cluster nel
Passaggio 5. Ma, come in molti altri algoritmi, anche gli algoritmi k-
means possono richiedere molto tempo per convergere, specialmente
durante l’elaborazione di grandi dataset in uno spazio del problema a
elevata dimensionalità. Invece di aspettare che l’algoritmo converga,
possiamo definire esplicitamente la condizione di stop come segue.
Specificando il tempo massimo di esecuzione: condizione di
arresto t > tmax, dove t è il tempo di esecuzione corrente e tmax è il
tempo di esecuzione massimo che abbiamo impostato per
l’algoritmo.
Specificando le iterazioni massime: condizione di arresto m >
mmax, dove m è l’iterazione corrente e mmax è il numero massimo di
iterazioni che abbiamo impostato per l’algoritmo.

Programmare l’algoritmo k-means


Vediamo come possiamo programmare l’algoritmo k-means in
Python.
1. Per prima cosa, importiamo i pacchetti di cui avremo bisogno di
programmare l’algoritmo k-means. Notate che stiamo importando
il pacchetto sklearn:
from sklearn import cluster
import pandas as pd

import numpy as np

2. Per utilizzare il clustering k-means, creiamo 20 punti dei dati in


uno spazio del problema bidimensionale. Saranno i punti dei dati
che utilizzeremo per il clustering k-means:
dataset = pd.DataFrame({
'x' : [11, 21, 28, 17, 29, 33, 24, 45, 45, 52, 51, 52, 55, 53, 55,
61, 62, 70, 72, 10],
'y' : [39, 36, 30, 52, 53, 46, 55, 59, 63, 70, 66, 63, 58, 23, 14,
8, 18, 7, 24, 10]

})

3. Prendiamo due cluster (k = 2) e creiamo il cluster chiamando le


funzioni fit():
myKmeans = cluster.KMeans(n_clusters = 2)

myKmeans.fit(dataset)

4. Creiamo una variabile centroids: un array che contiene la


posizione del centro dei cluster. Nel nostro caso, essendo k = 2,
l’array avrà dimensione 2. Creiamo anche un’altra variabile
denominata labels che rappresenta l’assegnazione di ciascun punto
dei dati a uno dei due cluster. Poiché ci sono 20 punti dei dati,
questo array avrà una dimensione pari a 20:
centroids = myKmeans.cluster_centers_

labels = myKmeans.labels_

5. Ora mostriamo questi due array: centroids e labels:

Notate che il primo array mostra l’assegnazione al cluster per


ciascun punto dei dati; il secondo mostra le coordinate dei due
centri di cluster.
6. Tracciamo e osserviamo i cluster usando matplotlib:
I punti più grandi nel grafico sono i centroidi determinati
dall’algoritmo k-means.

Limiti del clustering k-means


L’algoritmo k-means è progettato per essere semplice e veloce. Ma a
causa di questa sua semplicità presenta le seguenti limitazioni:
il più grande limite del clustering k-means è il fatto che il numero
iniziale di cluster deve essere predeterminato;
l’assegnazione iniziale dei centri cluster è casuale e ciò significa
che a ogni esecuzione l’algoritmo potrebbe fornire cluster
leggermente differenti;
ogni punto dei dati viene assegnato a un solo cluster;
il clustering k-means è sensibile ai valori anomali.

Clustering gerarchico
Il clustering k-means utilizza un approccio top-down, perché
l’algoritmo parte dai punti dei dati più importanti, che sono i centri dei
cluster. Esiste un approccio alternativo al clustering in cui, invece di
partire dall’alto, si avvia l’algoritmo dal basso. Il “basso”, in questo
contesto, è ciascuno dei singoli punti dei dati nello spazio del
problema. La soluzione consiste nel continuare a raggruppare insieme i
punti dei dati simili a mano a mano che si procede nella ricerca dei
centri dei cluster. Questo approccio bottom-up è utilizzato dagli
algoritmi di clustering gerarchico ed è l’argomento di questo
paragrafo.

Fasi del clustering gerarchico


Il clustering gerarchico prevede i seguenti passaggi.
1. Creiamo un cluster per ogni punto dei dati presente nello spazio
del problema. Se lo spazio del problema è costituito da 100 punti,
partiremo con 100 cluster.
2. Raggruppiamo solo i punti più vicini fra loro.
3. Controlliamo la condizione di stop e se non è ancora soddisfatta,
ripetiamo il Passaggio 2.
La struttura a cluster risultante è chiamata dendrogramma. In un
dendrogramma, è l’altezza delle linee verticali a determinare la
vicinanza degli elementi, come mostrato nella Figura 6.9. Notate che
nella figura la condizione di arresto è rappresentata da una linea
tratteggiata.
Figura 6.9

Programmazione di un algoritmo di clustering gerarchico


Vediamo ora come possiamo programmare un algoritmo gerarchico
in Python.
1. Per prima cosa dobbiamo importare AgglomerativeClustering dalla
libreria sklearn.cluster, insieme ai pacchetti pandas e numpy:
from sklearn.cluster
import AgglomerativeClustering

import pandas as pd import numpy as np

2. Quindi creiamo 20 punti dei dati in uno spazio del problema


bidimensionale:
dataset = pd.DataFrame({
'x': [11, 21, 28, 17, 29, 33, 24, 45, 45, 52, 51, 52, 55, 53, 55,
61, 62, 70, 72, 10],
'y': [39, 36, 30, 52, 53, 46, 55, 59, 63, 70, 66, 63, 58, 23, 14, 8,
18, 7, 24, 10]

})
3. Quindi creiamo il cluster gerarchico specificando gli
iperparametri. Usiamo la funzione fit_predict() per elaborare
effettivamente l’algoritmo:
cluster = AgglomerativeClustering(n_clusters = 2, affinity =
'euclidean',
linkage = 'ward')

cluster.fit_predict(dataset)

4. Ora esaminiamo l’associazione di ciascun punto dei dati ai due


cluster che sono stati creati:

Potete vedere che l’assegnazione dei cluster dell’algoritmo


gerarchico è molto simile a quello dell’algoritmo k-means.

Valutazione dei cluster


L’obiettivo di un clustering di qualità è quello di distinguere bene i
punti dei dati che appartengono ai vari cluster. Ciò implica quanto
segue:
i punti dei dati che appartengono allo stesso cluster dovrebbero
essere il più possibile simili;
i punti dei dati che appartengono a cluster distinti dovrebbero
essere il più possibile differenti.
Può certamente essere utilizzato l’intuito per valutare graficamente i
risultati del clustering, ma esistono metodi matematici in grado di
quantificare la qualità dei cluster. L’analisi della silhouette è una di
queste tecniche: confronta la prossimità e la separazione dei cluster
creati dall’algoritmo k-means. La silhouette disegna un grafico che
mostra la prossimità di ogni punto di un cluster rispetto agli altri punti
nei cluster vicini e per ciascun cluster associa un numero
nell’intervallo [0, 1]. La tabella seguente mostra il significato delle
cifre di questo intervallo.
Intervallo Significato Descrizione
Il clustering k-means ha prodotto gruppi ben
0,71-1 Eccellente
differenziabili fra loro.
Il clustering k-means ha prodotto gruppi abbastanza
0,51-0,70 Ragionevole
differenziabili fra loro.
Il clustering k-means ha dato un risultato, ma non
0,26-0,50 Debole
dovremmo fare molto affidamento sulla sua qualità.
Nessun Utilizzando i parametri selezionati e i dati utilizzati, non è
0-0,25 clustering stato possibile creare gruppi utilizzando il clustering k-
trovato means.

Notate che ogni cluster nello spazio del problema riceverà un


punteggio separato.

Applicazioni del clustering


Il clustering viene utilizzato ovunque sia necessario per scoprire i
pattern presenti nei dataset. In ambito governativo, il clustering può
essere utilizzato per quanto segue:
analisi delle zone a elevata criminalità;
analisi sociale demografica.
Nelle ricerche di mercato, il clustering può essere utilizzato per
quanto segue:
segmentazione del mercato;
annunci mirati;
classificazione dei clienti.
Per esplorare in generale i dati e rimuovere in tempo reale il
“rumore”, come nel caso del trading di borsa viene utilizzata anche la
Principal Component Analysis (PCA).
Riduzione della dimensionalità
Ogni singola feature nei nostri dati corrisponde a una dimensione
nello spazio del problema. L’attività che consiste nel ridurre il numero
di feature per semplificare lo spazio del problema si chiama riduzione
della dimensionalità. Lo si può fare in due modi.
Selezione delle feature: si devono scegliere le feature importanti
nel contesto del problema che stiamo cercando di risolvere.
Aggregazione delle feature: si tratta di combinare fra loro due o
più feature per ridurre le dimensioni utilizzando uno dei seguenti
algoritmi:
- PCA (Principal Component Analysis), un algoritmo di machine
learning lineare senza supervisione;
- LDA (Linear Discriminant Analysis), un algoritmo di machine
learning lineare con supervisione;
- KPCA (Kernel Principal Component Analysis), un algoritmo
non lineare.
Esaminiamo più in dettaglio uno dei più noti algoritmi di riduzione
della dimensionalità, ovvero PCA.

PCA (Principal Component Analysis)


Si tratta di una tecnica di machine learning senza supervisione
utilizzabile per ridurre le dimensioni con una trasformazione lineare.
Nella Figura 6.10, possiamo vedere due componenti principali, PC1 e
PC2, che descrivono la diffusione dei punti dei dati. PC1 e PC2
possono essere utilizzati per riassumere i punti dei dati con coefficienti
appropriati.
Consideriamo il seguente codice:
from sklearn.decomposition import PCA

iris = pd.read_csv('iris.csv')
X = iris.drop('Species', axis = 1)

pca = PCA(n_components = 4)

pca.fit(X)

Figura 6.10

Ora mostriamo i coefficienti del nostro modello PCA:

Notate che il DataFrame originale ha quattro feature, Sepal.Length,


,
Sepal.Width Petal.Length e Petal.Width. Il DataFrame specifica i
coefficienti dei quattro componenti principali, PC1, PC2, PC3 e PC4. Per
esempio, la prima riga specifica i coefficienti di PC1 che possono
essere utilizzati per sostituire le quattro variabili originali.
Sulla base di questi coefficienti, possiamo calcolare i componenti
PCA per il nostro DataFrame di input X:
pca_df = (pd.DataFrame(pca.components_, columns = X.columns))
# Calcoliamo PC1 usando i coefficienti generati
X['PC1'] = X['Sepal.Length'] * pca_df['Sepal.Length'][0] +
X['Sepal.Width'] * pca_df['Sepal.Width'][0] +
X['Petal.Length'] * pca_df['Petal.Length'][0] +
X['Petal.Width'] * pca_df['Petal.Width'][0]
# Calcoliamo PC2
X['PC2'] = X['Sepal.Length'] * pca_df['Sepal.Length'][1] +
X['Sepal.Width'] * pca_df['Sepal.Width'][1] +
X['Petal.Length'] * pca_df['Petal.Length'][1] +
X['Petal.Width'] * pca_df['Petal.Width'][1]
# Calcoliamo PC3
X['PC3'] = X['Sepal.Length'] * pca_df['Sepal.Length'][2] +
X['Sepal.Width'] * pca_df['Sepal.Width'][2] +
X['Petal.Length'] * pca_df['Petal.Length'][2] +
X['Petal.Width'] * pca_df['Petal.Width'][2]
# Calcoliamo PC4
X['PC4'] = X['Sepal.Length'] * pca_df['Sepal.Length'][3] +
X['Sepal.Width'] * pca_df['Sepal.Width'][3] +
X['Petal.Length'] * pca_df['Petal.Length'][3] +

X['Petal.Width'] * pca_df['Petal.Width'][3]

Ora mostriamo X dopo il calcolo dei componenti PCA:

Ora mostriamo la varianza e proviamo a capire le implicazioni


dell’uso della PCA:
La varianza indica quanto segue.
Se scegliamo di sostituire le quattro feature originali con PC1,
saremo in grado di catturare circa il 92,3% della varianza delle
variabili originali. Introdurremo alcune approssimazioni, non
catturando il 100% della varianza delle quattro feature originali.
Se scegliamo di sostituire le quattro feature originali con PC1 e
PC2, cattureremo un ulteriore 5,3% della varianza delle variabili
originali.
Se scegliamo di sostituire le quattro feature originali con PC1,
PC2 e PC3, cattureremo un ulteriore 0,017% della varianza delle
variabili originali.
Se scegliamo di sostituire le quattro feature originali con quattro
componenti principali, cattureremo il 100% della varianza delle
variabili originali (92,4 + 0,053 + 0,017 + 0,005), ma sostituire le
quattro feature originali con altrettanti componenti principali non
ha senso, in quanto non riduce le dimensioni e non ci offre alcun
vantaggio.

Limitazioni della PCA


Questa tecnica ha però dei limiti.
Può essere utilizzata solo per le variabili continue e non per le
variabili categoriche.
Durante l’aggregazione, la tecnica PCA approssima le variabili
componenti; semplifica il problema della dimensionalità a scapito
dell’accuratezza. Questo compromesso dovrebbe essere
attentamente valutato, prima di utilizzare questa tecnica.

Mining delle regole associative


I pattern presenti in un determinato dataset sono il “tesoro da
scoprire”, per comprendere ed estrarre informazioni dai dati. Esiste un
importante insieme di algoritmi che cercano di concentrarsi sull’analisi
dei pattern in un determinato dataset. Uno dei più utilizzati fra gli
algoritmi di questa classe è l’algoritmo di mining delle regole
associative, che offre le seguenti funzionalità:
la capacità di misurare la frequenza di un pattern;
la capacità di stabilire una relazione causa-effetto fra i pattern;
la capacità di quantificare l’utilità dei pattern confrontando la loro
accuratezza rispetto a ipotesi casuali.

Esempi di utilizzo
L’estrazione delle regole associative viene utilizzata quando si cerca
di indagare le relazioni causa-effetto fra le diverse variabili presenti in
un dataset. Ecco alcuni esempi di domande cui può essere utile
rispondere.
Quali valori di umidità, nuvolosità e temperatura possono portare
a piovere domani?
Quale tipo di richiesta di indennizzo assicurativo può indicare una
frode?
Che combinazioni di farmaci possono portare a complicazioni per
i pazienti?
Analisi del paniere di mercato
Questo libro tratta i motori di raccomandazione nel Capitolo 10.
L’analisi del carrello è un modo più semplice per apprendere le
raccomandazioni. Nell’analisi del carrello, i nostri dati contengono
solo le informazioni relative agli articoli acquistati insieme e non
hanno alcuna informazione sull’utente o sul fatto che abbia apprezzato
i singoli articoli. Notate che è molto più facile ottenere questi dati
rispetto ai dati sulle valutazioni.
Per esempio, questo tipo di dati viene generato ogni volta che
acquistiamo qualcosa online e non è richiesta alcuna tecnica speciale
per ottenerli. Questi dati, se raccolti in un arco di tempo, sono chiamati
dati transazionali. Quando l’analisi delle regole associative viene
applicata ai dataset di transazioni dei carrelli della spesa utilizzati nei
negozi di alimentari, nei supermercati e nelle catene di fast food, si
parla di analisi del paniere di mercato e misura la probabilità
condizionata di acquistare determinati articoli insieme, il che aiuta a
rispondere alle seguenti domande.
Qual è il posizionamento ottimale degli articoli sullo scaffale?
Come dovrebbero apparire gli articoli nel catalogo?
Che cosa dovrebbe essere consigliato, in base ai pattern di
acquisto di un cliente?
Poiché l’analisi del paniere di mercato può stimare le correlazioni
fra gli articoli, spesso viene utilizzata negli esercizi di vendita al
dettaglio, come i supermercati, i minimarket, i drugstore e le catene di
fast food. Il vantaggio dell’analisi del paniere di mercato è che i
risultati sono quasi autoesplicativi, ovvero sono facilmente
comprensibili dagli utenti aziendali.
Vediamo un tipico supermercato. Tutti gli articoli disponibili nel
negozio possono essere rappresentati da un insieme, π = {articolo1,
articolo2, …, articolom}. Quindi, se quel supermercato vende 500
articoli, allora π sarà un insieme di dimensioni 500.
I clienti acquistano articoli da questo negozio. Ogni volta che
qualcuno acquista un articolo e paga alla cassa, l’articolo viene
aggiunto all’insieme degli articoli presenti in una determinata
transazione, detto itemset. In un determinato arco di tempo, le
operazioni vengono raggruppate in un insieme rappresentato da Δ,
dove Δ = {t1, t2, …, tn}.
Esaminiamo i seguenti semplici dati di quattro transazioni. Queste
transazioni sono riassunte nella tabella seguente.
t1 wickets, pads
t2 bat, wickets, pads, helmet
t3 helmet, ball
t4 bat, pads, helmet

Esaminiamo questo esempio in modo più dettagliato:


π = {bat, wickets, pads, helmet, ball}
che rappresenta tutti gli articoli disponibili nel negozio.
Consideriamo una delle transazioni, t3, da Δ. Notate che gli articoli
acquistati in t3 possono essere rappresentati in itemsett3 = {helmet,
ball}, che indica che il cliente ha acquistato due articoli. Poiché questo
itemset contiene due articoli, si dice che la dimensione di itemsett3 è 2.

Regole associative
Una regola associativa descrive matematicamente gli articoli legati
dalle varie transazioni. Lo fa studiando la relazione fra due itemset
nella forma X → Y, dove X ⊂ π e Y ⊂ π. Inoltre, X e Y sono itemset
non sovrapposti; il che significa che X Y = Ø.
Una regola associativa potrebbe essere descritta nella forma
seguente:
{helmet, ball} → {bicycle}
Dove, X = {helmet, ball} e Y = {bicycle}.

Tipi di regole
L’esecuzione di algoritmi di analisi associativa in genere comporta
la generazione da un dataset di un numero elevato di regole delle
transazioni, molte delle quali inutili. Per scegliere quelle regole che
forniscono informazioni utili, possiamo classificarle nei seguenti tre
tipi:
banali;
non spiegabili;
utili.
Vediamo ciascuno di questi tipi in modo più dettagliato.

Regole banali
Fra le numerose regole generate, molte saranno inutili, poiché
riassumono conoscenze banali del settore. Anche se la fiducia che
riponiamo nelle regole banali è elevata, esse rimangono inutili e non
possono essere utilizzate per alcun processo decisionale guidato dai
dati. Possiamo tranquillamente ignorarle.
Ecco alcuni esempi di regole banali.
Chi salta da un grattacielo rischia di morire.
Studiando sodo si ottengono voti migliori agli esami.
Le vendite di stufe aumentano al diminuire della temperatura.
Guidare un’auto oltre i limiti di velocità aumenta il rischio di
incidenti.

Regole non spiegabili


Fra le regole che vengono generate dopo aver eseguito l’algoritmo
delle regole associative, quelle che non hanno una spiegabilità ovvia
sono le più difficili da usare. Notate che una regola può essere utile
solo se può aiutarci a scoprire e comprendere un nuovo pattern che
dovrebbe suggerirci una certa linea d’azione. Se non è così, e non
possiamo spiegare perché l’evento X abbia portato all’evento Y, allora
la regola è non spiegabile, perché è solo una formula matematica che
finisce per esplorare una relazione inutile fra due eventi non correlati e
indipendenti.
Ecco alcuni esempi di regole non spiegabili.
Chi indossa una maglietta rossa tende a ottenere voti migliori agli
esami.
Le biciclette verdi hanno maggiori probabilità di essere rubate.
Chi acquista sottaceti compra anche pannolini.

Regole utili
Le regole utili sono esattamente quello che stiamo cercando. Sono
comprensibili e offrono suggerimenti utili. Possono aiutarci a scoprire
le possibili cause di un evento quando vengono presentate a un
pubblico che ha familiarità con il dominio aziendale. Per esempio, le
regole utili possono suggerire il miglior posizionamento di un
determinato prodotto in negozio in base agli attuali pattern di acquisto.
Possono anche suggerire quali articoli collocare vicini per
massimizzare le loro possibilità di vendita, poiché gli utenti tendono ad
acquistarli insieme.
Ecco alcuni esempi di regole utili e delle azioni che esse
suggeriscono.
Regola 1: la visualizzazione di annunci sugli account dei social
media degli utenti comporta una maggiore probabilità di vendita.
Suggerisce modi alternativi di pubblicizzare un prodotto.
Regola 2: l’utilizzo di più etichette del prezzo aumenta le
probabilità di vendita.
Il prezzo di un articolo può essere replicato più volte, mentre
viene aumentato il prezzo di un altro articolo.

Valutazione delle regole


Le regole associative possono essere valutate in tre modi:
supporto (frequenza) degli articoli;
confidenza;
lift.
Vediamoli più in dettaglio.

Supporto
Il supporto (support) è un numero che quantifica la frequenza del
pattern nel nostro dataset. Viene calcolato contando il numero di
occorrenze del pattern in questione e dividendo il risultato per il
numero totale di tutte le transazioni.
Osserviamo la seguente formula per un determinato itemseta:
numItemseta = Numero di transazioni che contengono itemseta
numtotal = Numero totale di transazioni

NOTA
Osservando semplicemente il supporto, possiamo avere un’idea di quanto sia
raro il verificarsi di un pattern. Se il supporto è basso, significa che stiamo
considerando un evento raro.

Per esempio, se itemseta = {helmet, ball} appare in 2 transazioni su


6, allora support(itemseta) = 2 / 6 = 0,33.
Confidenza
La confidenza (confidence) è un numero che quantifica quanto
fortemente possiamo associare il lato sinistro (X) con il lato destro (Y)
calcolando la probabilità condizionata. Calcola la probabilità che
l’evento X porti all’evento Y, una volta che si è verificato l’evento X.
Matematicamente, considerate la regola X → Y.
La confidenza di questa regola è rappresentata come confidence(X
→ Y) e viene misurata come segue:

Vediamo un esempio. Considerate la seguente regola:


{helmet, ball} → {wickets}
La confidenza di questa regola è calcolata dalla seguente formula:

Ciò significa che se qualcuno ha messo nel carrello {helmet,


palline}, allora c’è il 50% (0,5) di probabilità che comprerà anche i
wickets.

Lift
Un altro modo per stimare la qualità di una regola associativa
consiste nel calcolare il lift, un numero che quantifica quanto
miglioramento offre una regola associativa nel prevedere il risultato
rispetto alla sola assunzione del risultato nella parte destra
dell’equazione. Se gli itemset X e Y sono indipendenti, il lift si calcola
come segue:

Algoritmi per l’analisi delle associazioni


In questo paragrafo, esploreremo i seguenti due algoritmi che
possono essere utilizzati per l’analisi delle associazioni.
Algoritmo Apriori: proposto da Agrawal, R. e Srikant nel 1994.
Algoritmo FP-growth: un miglioramento suggerito da Han et al.
nel 2001.
Vediamo ciascuno di questi algoritmi.

L’algoritmo Apriori
Si tratta di un algoritmo iterativo e multifase utilizzato per generare
regole associative. Si basa su un approccio a generazione-e-test.
Prima di eseguire l’algoritmo apriori, dobbiamo definire due
variabili: supportthreshold e confidencethreshold. L’algoritmo è costituito
dalle seguenti due fasi.
Fase di generazione dei candidati: genera gli itemset candidati,
ovvero l’insieme di tutti gli itemset che superano la soglia di
supporto, supportthreshold.
Fase di filtro: elimina tutte le regole che non superano la soglia di
confidenza, confidencethreshold.
Dopo il filtraggio, le regole superstiti rappresentano la risposta.

Limiti dell’algoritmo apriori


Il principale collo di bottiglia nell’algoritmo apriori è la generazione
di regole candidate nella Fase 1. Per esempio, π = {elemento1,
elemento2, …, elementom} può produrre 2m possibili itemset. A causa
della sua struttura multifase, prima genera questi itemset e poi cerca di
trovare gli itemset più frequenti. Questo limite è un enorme collo di
bottiglia prestazionale e rende l’algoritmo apriori inadatto per dataset
di grandi dimensioni.

L’algoritmo FP-growth
L’algoritmo Frequent Pattern growth (FP-growth) migliora
l’algoritmo a priori. Inizia considerando l’albero delle transazioni
frequenti, FP-tree, che è un albero ordinato. Si compone di due
passaggi:
popolare l’albero FP-tree;
estrarre i pattern frequenti.
Vediamo come funzionano questi passaggi.

Popolare l’albero FP
Consideriamo i dati delle transazioni mostrati nella tabella seguente.
Rappresentiamoli innanzitutto come una matrice sparsa.
ID Bat Wickets Pads Helmet Ball
1 0 1 1 0 0
2 1 1 1 1 0
3 0 0 0 1 1
4 1 0 1 1 0

Calcoliamo la frequenza di ogni articolo e poi ordiniamo gli articoli


in ordine di frequenza, decrescente.
Articolo Frequenza
Pads 3
Helmet 3
Bat 2
Wickets 2
Ball 1

Ora riordiniamo i dati delle transazioni in base alla frequenza.


ID Articoli Articoli in ordine
t1 wickets, pads pads, wickets
t2 bat, wickets, pads, helmet helmet, pads, wickets, bat
t3 helmet, ball helmet, ball
t4 bat, pads, helmet helmet, pads, bat
Per costruire l’albero FP, iniziamo con il primo ramo. L’albero FP
inizia con la radice Null. Per costruire l’albero, possiamo rappresentare
ogni elemento con un nodo, come mostrato nella Figura 6.11 (qui è
mostrata la rappresentazione ad albero di t1). Notate che l’etichetta di
ogni nodo è costituita dal nome dell’elemento seguito dalla sua
frequenza. Inoltre, notate che l’elemento pads ha frequenza 1.

Figura 6.11

Utilizzando lo stesso schema, disegniamo tutte e quattro le


transazioni, ottenendo l’intero albero FP, che ha quattro nodi foglia,
ognuno dei quali rappresenta l’insieme di elementi associato alle
quattro transazioni. Notate che è necessario contare le frequenze di
ciascun articolo e aumentarle quando viene acquistato più volte. Per
esempio, quando all’albero FP aggiungiamo t2, la frequenza di helmet
aumenta a 2. Allo stesso modo, aggiungendo t4, aumenta ancora, a 3.
L’albero risultante è rappresentato nella Figura 6.12. Notate che si
tratta di un albero ordinato.

Figura 6.12

Estrarre i pattern frequenti


La seconda fase dell’algoritmo FP-growth comporta l’estrazione dei
pattern frequenti dall’albero FP. Creando un albero ordinato,
l’intenzione è quella di creare una struttura di dati efficiente che possa
essere facilmente attraversata per cercare pattern frequenti.
Partiamo da un nodo foglia (cioè un nodo finale) e ci spostiamo
verso l’alto. Per esempio, iniziamo dal nodo foglia bat. Dobbiamo
calcolare la base del pattern condizionale per bat. La base del pattern
condizionale si calcola specificando tutti i percorsi dal nodo foglia
verso l’alto. La base del pattern condizionale per bat sarà la seguente.
Wickets: 1 Pads: 1 Helmet: 1
Pad: 1 Helmet: 1

Il pattern frequente per bat sarà il seguente:


{wickets, pads, helmet}: bat
{pads, helmet}: bat

Il codice per l’algoritmo FP-growth


Vediamo come possiamo generare delle regole associative usando
l’algoritmo FP-growth in Python. Per farlo, utilizzeremo il pacchetto
pyfpgrowth. Se non avete mai usato pyfpgrowth, dovrete installarlo:

!pip install pyfpgrowth

Importiamo i pacchetti che dobbiamo usare per implementare questo


algoritmo:
import pandas as pd

import numpy as np

import pyfpgrowth as fp

Ora creeremo i dati di input sotto forma di transactionSet:


dict1 = {

'id': [0, 1, 2, 3],

'items': [["wickets", "pads"],

["bat", "wickets", "pads", "helmet"],

["helmet", "pads"],

["bat", "pads", "helmet"]]

transactionSet = pd.DataFrame(dict1)

Una volta generati i dati di input, genereremo i pattern, basati sui


parametri che passiamo a find_frequent_patterns(). Notate che il secondo
parametro passato a questa funzione è il supporto minimo, che in
questo caso è 1:
patterns = fp.find_frequent_patterns(transactionSet['items'], 1)
Ora vengono generati i pattern. Proviamo a vederli. I pattern
elencano le combinazioni di articoli con i relativi livelli di supporto:

E ora generiamo le regole:

Ogni regola ha un lato sinistro e un lato destro, separati da due punti


(:). Otteniamo anche il livello di supporto, regola per regola, nel nostro
dataset di input.

Applicazione pratica: clustering di


tweet simili
Gli algoritmi di machine learning senza supervisione possono essere
applicati anche in tempo reale, per raggruppare insieme tweet simili.
Faremo quanto segue.
1. Modellazione dei temi, per scoprire gli argomenti trattati in un
determinato insieme di tweet.
2. Clustering, per associare ciascun tweet a uno degli argomenti
scoperti.
Questo uso dell’apprendimento senza supervisione è rappresentato
nella Figura 6.13.

Figura 6.13

NOTA
Questo esempio richiede l’elaborazione in tempo reale dei dati di input.

Esaminiamo questi passaggi uno per uno.

Modellazione dei temi


La modellazione dei temi è il processo di scoperta degli argomenti
trattati in un insieme di documenti, così da poterli differenziare. Nel
contesto dei tweet, si tratta di trovare gli argomenti più appropriati in
cui è possibile suddividere un insieme di tweet. Latent Dirichlet
Allocation è un algoritmo molto utilizzato per la modellazione degli
argomenti. Poiché ciascuno dei tweet è un breve documento di 144
caratteri, di solito su un argomento molto specifico, possiamo scrivere
un algoritmo più semplice per modellare l’argomento. L’algoritmo è
descritto di seguito.
1. Tokenizzare i tweet.
2. Preelaborare i dati. Rimuovere le stopword, i numeri, i simboli ed
eseguire lo stemming, per riportare le parole alla loro radice.
3. Creare una Term-Document-Matrix (TDM) per i tweet. Scegliere
le 200 parole che appaiono più frequentemente nei tweet.
4. Scegliere le prime 10 parole che rappresentano direttamente o
indirettamente un concetto o un argomento. Per esempio, Moda,
New York, Programmazione, Incidente. Queste 10 parole sono gli
argomenti che abbiamo scoperto e diventeranno i centri dei
cluster per i tweet.
Vediamo il passaggio successivo, che è il clustering.

Clustering
Una volta scoperti gli argomenti, li sceglieremo come centri dei
cluster. Quindi possiamo eseguire l’algoritmo di clustering k-means,
che assegnerà ciascuno dei tweet a uno dei cluster.

Algoritmi per il rilevamento delle


anomalie
La definizione di un dizionario di anomalie riguarda elementi
difformi, anormali, peculiari o non facilmente classificabili, qualcosa
che devia dalla regola comune. Nel contesto della data science,
un’anomalia è un punto dei dati che si discosta molto dal modello
previsto. Per individuare tali punti dei dati si impiegano le tecniche di
rilevamento delle anomalie.
Ecco alcune applicazioni pratiche degli algoritmi per il rilevamento
delle anomalie:
frodi con carta di credito;
individuazione di un tumore maligno in una risonanza magnetica
(MRI);
prevenzione dei guasti nei cluster;
furto d’identità agli esami;
incidenti in autostrada.
Nei prossimi paragrafi, vedremo varie tecniche di rilevamento delle
anomalie.

Utilizzo del clustering


Gli algoritmi di clustering, come k-means, possono essere utilizzati
per raggruppare insieme i punti dei dati simili. È possibile definire una
soglia, così che qualsiasi punto che supera tale soglia può essere
classificato come un’anomalia. Il problema di questo approccio è che
anche il raggruppamento creato dal clustering k-means può essere
distorto, a causa della presenza di punti dei dati anomali e ciò può
influenzare l’utilità e l’accuratezza dell’approccio.

Utilizzo del rilevamento delle anomalie


basato sulla densità
Un approccio basato sulla densità cerca di trovare intorni densi. A
questo scopo può essere utilizzato l’algoritmo k-nearest neighbors
(KNN). Le anomalie che sono lontane dagli intorni densi scoperti sono
contrassegnate come anomalie.

Utilizzo di macchine a vettori di supporto


L’algoritmo Support Vector Machine (SVM) consente di individuare
i confini dei punti dei dati. Qualsiasi punto oltre quei confini viene
identificato come un’anomalia.

Riepilogo
In questo capitolo abbiamo esaminato varie tecniche di machine
learning senza supervisione. Abbiamo esaminato le circostanze in cui è
opportuno cercare di ridurre la dimensionalità del problema che stiamo
cercando di risolvere, e i diversi metodi per farlo. Abbiamo anche
studiato gli esempi pratici in cui le tecniche di machine learning senza
supervisione possono essere molto utili, fra cui l’analisi del paniere di
mercato e il rilevamento delle anomalie.
Nel prossimo capitolo, esamineremo varie tecniche di machine
learning con supervisione. Inizieremo con la regressione lineare e poi
esamineremo altre tecniche di machine learning con supervisione più
sofisticate, come gli algoritmi basati su alberi decisionali, SVM e
XGBoast. Studieremo anche l’algoritmo naive Bayes, particolarmente
adatto a dati testuali non strutturati.
Capitolo 7

Algoritmi classici di machine


learning con supervisione

In questo capitolo ci concentreremo sugli algoritmi di machine


learning con supervisione, fra i più importanti al giorno d’oggi. La
caratteristica distintiva di un algoritmo di machine learning con
supervisione è l’uso di dati etichettati per addestrare un modello. In
questo libro, gli algoritmi di machine learning con supervisione sono
trattati in due capitoli. In questo capitolo esamineremo gli algoritmi
classici di machine learning con supervisione, escluse le reti neurali. Il
prossimo capitolo riguarda invece l’implementazione di algoritmi di
machine learning con supervisione che utilizzano reti neurali. Il
motivo è che, dati gli sviluppi in corso in questo campo, le reti neurali
sono un argomento che merita un capitolo a parte.
Innanzitutto, introdurremo i concetti fondamentali del machine
learning con supervisione. Successivamente, esamineremo due modelli
di macchine con supervisione: i classificatori e i regressori. Per
mostrare le potenzialità dei classificatori, esamineremo prima di tutto
un problema concreto. Quindi, mostreremo sei diversi algoritmi di
classificazione utilizzabili per risolvere il problema. Poi ci
concentreremo sugli algoritmi di regressione, presentando innanzitutto
un problema tipico da risolvere tramite regressori. Successivamente,
esamineremo tre algoritmi di regressione e li useremo per risolvere
quel problema. Infine, confronteremo i risultati per riassumere i
concetti presentati nel capitolo.
L’obiettivo generale di questo capitolo è quello di farvi conoscere le
diverse tecniche di machine learning con supervisione e di farvi
scoprire quali sono le migliori tecniche di machine learning con
supervisione da applicare a specifiche classi di problemi.
Iniziamo esaminando i concetti fondamentali del machine learning
con supervisione.

Il machine learning con


supervisione
Il machine learning si concentra sull’utilizzo di approcci basati sui
dati per creare sistemi autonomi in grado di aiutarci a prendere
decisioni con o senza supervisione umana. Per creare questi sistemi
autonomi, il machine learning utilizza un gruppo di algoritmi e
metodologie per scoprire e formulare pattern ripetibili nei dati. Una
delle metodologie più utilizzate e potenti del machine learning è
l’approccio con supervisione. Nel machine learning con supervisione,
a un algoritmo viene assegnato un insieme di input, le feature. I relativi
output sono detti variabili target. Sulla base di un determinato dataset,
viene utilizzato un algoritmo di machine learning con supervisione per
addestrare un modello che catturi la complessa relazione che lega le
feature alle variabili di destinazione, rappresentandola con una formula
matematica. Questo modello addestrato è lo strumento utilizzato per le
previsioni.
Le previsioni vengono effettuate generando la variabile target a
partire da un insieme di feature tramite il modello addestrato.
NOTA
La capacità di apprendere dai dati esistenti del machine learning con
supervisione è simile alla capacità del cervello umano di apprendere
dall’esperienza. Questa capacità di apprendimento del machine learning con
supervisione è fondamentale per aprire le porte della capacità decisionale e
dell’intelligenza alle macchine.

Consideriamo un esempio in cui vogliamo utilizzare tecniche di


machine learning con supervisione per addestrare un modello in grado
di classificare un insieme di e-mail, distinguendo quelle legittime
(legit) da quelle indesiderate (spam). Per iniziare, abbiamo bisogno di
avere degli esempi, in modo che la macchina possa apprendere quale
tipo di e-mail dovrebbe essere classificata come spam. Questo compito
di apprendimento sulla base dei contenuti dei dati testuali è un
processo complesso e sfrutta un algoritmo di machine learning con
supervisione. Fra gli esempi di algoritmi di machine learning con
supervisione che possono essere utilizzati per addestrare il modello di
questo esempio vi sono gli alberi decisionali e i classificatori naive
Bayes, di cui parleremo più avanti in questo stesso capitolo.

Formulazione del machine learning con


supervisione
Prima di approfondire i dettagli degli algoritmi di machine learning
con supervisione, definiamo un po’ di terminologia di base.
Termine Descrizione
È la variabile che il nostro modello deve prevedere. In un
Variabile target modello di machine learning con supervisione ci può
essere una sola variabile target.
Se la variabile target che vogliamo prevedere è di tipo
Etichetta
categorico, viene chiamata etichetta.
L’insieme delle variabili di input utilizzate per prevedere
Feature
l’etichetta.
Ingegnerizzazione Trasformazione delle feature per prepararle per
delle feature l’algoritmo di machine learning con supervisione.
Prima di fornire un input a un algoritmo di machine
learning con supervisione, tutte le feature vengono
Vettore delle feature
combinate in una struttura di dati denominata vettore
delle feature.
I dati passati, impiegati per formulare la relazione fra la
Dati storici variabile target e le feature. I dati storici vengono forniti
tramite esempi.
I dati storici con esempi sono divisi in due parti: un
Dati di training/test dataset più grande (dati di training) e un dataset più
piccolo (dati di test).
Una formulazione matematica dei pattern che meglio
Modello
catturano la relazione fra la variabile target e le feature.
Addestramento/training Creazione di un modello utilizzando i dati di training.
Valutazione della qualità del modello addestrato,
Test
utilizzando i dati di test.
Previsione Utilizzo di un modello per prevedere la variabile target.

NOTA
Un modello di machine learning con supervisione, dopo la fase di
addestramento/training è in grado di effettuare previsioni, stimando la variabile
target in base alle feature.

Introduciamo la notazione che utilizzeremo in questo capitolo per


trattare le tecniche di machine learning.
Variabile Significato
y Etichetta effettiva
y’ Etichetta prevista
d Numero totale di esempi
b Numero di esempi di training
c Numero di esempi di test

Vediamo ora come vengono utilizzati in pratica alcuni di questi


termini.
Come abbiamo detto, un vettore delle feature è una struttura di dati
nella quale sono memorizzate tutte le feature.
Se il numero di feature è n e il numero di esempi di training è b,
allora X_train rappresenta il vettore delle feature di training. Ogni
esempio è rappresentato da una riga del vettore delle feature, quindi
X_train conterrà b righe. Se il dataset di training è formato da n
variabili, allora avrà n colonne. Pertanto, il dataset di training avrà
dimensioni n × b, come possiamo vedere nella Figura 7.1:

Figura 7.1

Ora, supponiamo di avere b esempi di training e c esempi di test. Un


determinato esempio di training è rappresentato da (X, y). Usiamo
l’apice per identificare un esempio di training all’interno del dataset di
training.
Quindi, il nostro dataset etichettato è rappresentato da D = {(X(1),
y(1)), (X(2), y(2)), …, (X(d), y(d))}.
Lo dividiamo in due parti: Dtrain e Dtest.
Quindi, il nostro dataset di training può essere rappresentato da Dtrain
= {(X(1), y(1)), (X(2), y(2)), …, (X(b), y(b))}
L’obiettivo dell’addestramento, o training, di un modello è che per
ogni esempio i-esimo nel dataset di training, il valore target previsto
dall’algoritmo si avvicini il più possibile al valore effettivo. In altre
parole, y’(i) ≈ y(i) per 1 ≤ i ≤ b.
Quindi, il nostro dataset di test può essere rappresentato da Dtest =
{(X(1), y(1)), (X(2), y(2)), …, (X(c), y(c))}.
I valori della variabile target sono rappresentati da un vettore, Y:
Y = {y(1), Y(2), …, y(m)}

Le condizioni abilitanti
Il machine learning con supervisione si basa sulla capacità di un
algoritmo di addestrare un modello utilizzando esempi. Un algoritmo
di machine learning con supervisione richiede che vengano soddisfatte
le seguenti condizioni abilitanti per poter funzionare.
Un numero sufficiente di esempi: gli algoritmi di machine
learning con supervisione hanno bisogno di un numero di esempi
sufficiente per addestrare un modello.
Pattern nei dati storici: gli esempi utilizzati per addestrare un
modello devono contenere dei pattern. La probabilità che si
verifichi il nostro evento di interesse dovrebbe dipendere da una
combinazione di pattern, tendenze ed eventi. Senza questi,
abbiamo a che fare con dati casuali che non possono essere
utilizzati per addestrare un modello.
Ipotesi valide: quando addestriamo un modello di machine
learning con supervisione utilizzando esempi, ci aspettiamo che le
ipotesi che si applicano agli esempi saranno valide anche in
futuro. Vediamo un esempio reale: se vogliamo addestrare un
modello di machine learning in grado di prevedere la probabilità
di concedere a uno studente un visto d’ingresso, tutto si basa
sull’ipotesi che le leggi e le politiche non cambieranno quando
poi il modello verrà utilizzato per fare le sue previsioni. Se
venissero applicate nuove politiche o leggi dopo l’addestramento
del modello, potrebbe essere necessario ripetere l’addestramento
del modello per fargli considerare queste nuove informazioni.
Differenziazione fra classificatori e
regressori
In un modello di machine learning, la variabile target può essere
categorica o continua. Il tipo della variabile target determina il tipo di
modello di machine learning con supervisione di cui disponiamo.
Fondamentalmente, abbiamo due tipi di modelli di machine learning
con supervisione.
Classificatori: se la variabile target è di tipo categorico, il
modello di machine learning viene chiamato classificatore. I
classificatori possono essere utilizzati per rispondere alle seguenti
domande.
- Questa crescita anormale dei tessuti è di un tumore maligno?
- In base alle condizioni meteorologiche attuali, domani pioverà?
- In base al profilo di un determinato richiedente, la sua richiesta
di mutuo dovrebbe essere approvata?
Regressori: se la variabile target è di tipo continuo, addestriamo
un regressore. I regressori possono essere utilizzati per rispondere
alle seguenti domande.
- In base alle condizioni meteorologiche attuali, quanto pioverà
domani?
- Quale sarà il prezzo di una determinata casa avente determinate
caratteristiche?
Esaminiamo più in dettaglio sia i classificatori sia i regressori.

Gli algoritmi di classificazione


Nel machine learning con supervisione, se la variabile target è di
tipo categorico, il modello è chiamato classificatore.
La variabile target è chiamata etichetta.
I dati storici sono chiamati dati etichettati.
I dati di produzione, per i quali deve essere prevista l’etichetta,
sono chiamati dati non etichettati.

NOTA
La capacità di etichettare con precisione dati non etichettati utilizzando un
modello addestrato rappresenta la vera potenza degli algoritmi di
classificazione. I classificatori prevedono le etichette dei dati non etichettati per
rispondere a una determinata domanda.

Prima di presentare i dettagli degli algoritmi di classificazione,


esaminiamo un problema che utilizzeremo come sfida per i
classificatori. Poi utilizzeremo sei diversi algoritmi per rispondere a
questa sfida, che ci aiuterà a confrontare la loro metodologia, il loro
approccio e le loro prestazioni.

La sfida fra i classificatori


Esamineremo prima un problema comune, che utilizzeremo come
sfida per sottoporre a test sei diversi algoritmi di classificazione. Usare
sei classificatori per risolvere uno stesso problema ci sarà utile in due
modi.
Tutte le variabili di input devono essere elaborate e assemblate
come una struttura di dati complessa, chiamata vettore delle
feature. L’utilizzo dello stesso vettore delle feature ci evita di
ripetere la preparazione dei dati per i sei algoritmi.
Possiamo confrontare fra loro le prestazioni dei vari algoritmi,
poiché utilizziamo come input lo stesso vettore delle feature.
La sfida fra i classificatori riguarda la previsione della probabilità
che una persona effettui un acquisto. Nel settore della vendita al
dettaglio, una delle informazioni che possono aiutare a massimizzare
le vendite è comprendere meglio il comportamento dei clienti. La
conoscenza può essere ottenuta analizzando i pattern individuati nei
dati storici. Ma, prima di tutto, enunciamo il problema.

Enunciazione del problema


Impiegando i dati storici, possiamo addestrare un classificatore
binario che sia in grado di prevedere se un determinato utente
acquisterà un prodotto in base al suo profilo?
Innanzitutto, esploriamo il dataset storico con etichette che abbiamo
a disposizione per risolvere questo problema:

Per fare un esempio, quando y = 1, parliamo di classe positiva e


quando y = 0, parliamo di classe negativa.
NOTA
Sebbene la classe, positiva o negativa, possa essere scelta arbitrariamente, è
consigliabile definire come “classe positiva” l’evento di interesse. Se stiamo
cercando di contrassegnare le transazioni fraudolente per una banca, allora la
classe positiva (ovvero y = 1) dovrebbe essere costituito dalle transazioni
fraudolente, anche se sembra controintuitivo considerare una frode un “evento
positivo”.

Ora, consideriamo quanto segue:


l’etichetta effettiva, indicata da y;
l’etichetta prevista, indicata da y`.
Notate che per la nostra sfida fra i classificatori, y rappresenta il
valore effettivo dell’etichetta trovata negli esempi. Se, nel nostro
esempio, qualcuno ha acquistato un articolo, diciamo y = 1. I valori
previsti sono rappresentati da y`. Il vettore della feature di input, x, ha
dimensione 4. Vogliamo determinare qual è la probabilità che un
utente effettui un acquisto, dato un determinato input.
Quindi, vogliamo determinare la probabilità che y = 1 sia, dato un
determinato valore del vettore delle feature x. Matematicamente,
possiamo rappresentarlo come segue:

Ora, vediamo come possiamo elaborare e assemblare le variabili di


input nel vettore delle feature, x. La metodologia per assemblare le
diverse parti di x utilizzando la pipeline di elaborazione è l’argomento
del prossimo paragrafo.

Ingegnerizzazione delle feature utilizzando una pipeline di


elaborazione dati
La preparazione dei dati per un algoritmo di machine learning è
chiamata ingegnerizzazione delle feature ed è una parte cruciale del
ciclo di vita del machine learning. L’ingegnerizzazione delle feature
viene eseguita in diverse fasi. Il codice di elaborazione multifase
utilizzato per elaborare i dati è noto collettivamente come pipeline di
dati. Il fatto che una pipeline di dati sia realizzata utilizzando passaggi
standard di elaborazione, la rende riutilizzabile e riduce lo sforzo
necessario per addestrare i modelli. Potendo utilizzando moduli
software più collaudati, migliora anche la qualità del codice.
Proviamo a progettare una pipeline di elaborazione riutilizzabile per
la sfida fra i classificatori. Come accennato, prepareremo i dati una
sola volta e poi li useremo per tutti i classificatori.

Importazione dei dati


I dati storici per questo problema sono memorizzati in un file
chiamato dataset in formato .csv. Per importare i dati come un dataset
useremo la funzione pandas pd.read_csv():
dataset = pd.read_csv('Social_Network_Ads.csv')

Selezione delle feature


Questo processo seleziona le feature che sono rilevanti per il
contesto del problema che intendiamo risolvere, ed è una parte
essenziale dell’ingegnerizzazione delle feature.
Una volta importato il file, eliminiamo la colonna User ID, che viene
utilizzata per identificare una persona e dovrebbe essere sempre
esclusa durante l’addestramento di un modello:
dataset = dataset.drop(columns = ['User ID'])

Ora vediamo l’aspetto del dataset:


dataset.head(5)

Il dataset ha questo aspetto:

Ora, vediamo come possiamo elaborare ulteriormente il dataset di


input.

Codifica one-hot
Molti algoritmi di machine learning richiedono che tutte le feature
siano variabili continue. Pertanto, se alcune delle feature sono variabili
categoriche, dobbiamo trovare una strategia per convertirle in variabili
continue. La codifica one-hot è uno dei modi più efficaci per eseguire
questa trasformazione. Per questo particolare problema, l’unica
variabile categorica che abbiamo è Gender. Convertiamola in una
variabile continua usando la codifica one-hot:
enc = sklearn.preprocessing.OneHotEncoder()

enc.fit(dataset.iloc[:,[0]])

onehotlabels = enc.transform(dataset.iloc[:,[0]]).toarray()

genders = pd.DataFrame({'Female': onehotlabels[:, 0],

'Male': onehotlabels[:, 1]})

result = pd.concat([genders,dataset.iloc[:, 1:]], axis = 1, sort = False)

result.head(5)

Una volta convertito il dataset, esaminiamolo nuovamente:

Notate che per convertire una variabile categorica in una variabile


continua, la codifica one-hot ha trasformato Gender in due colonne
distinte: Female e Male.

Specificare le feature e le etichette


Specifichiamo le feature e le etichette. Useremo y per rappresentare
l’etichetta e X per rappresentare l’insieme delle feature:
y = result['Purchased']

X= result.drop(columns = ['Purchased'])

X rappresenta il vettore delle feature e contiene tutte le variabili di


input che dobbiamo usare per addestrare il modello.

Dividere il dataset nelle parti di test e di training


Ora, dividiamo il dataset in un 25% di test e un 75% di training
utilizzando sklearn.model_selection import train_test_split:
#from sklearn.cross_validation import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.25,


random_state = 0)

Questo crea le seguenti quattro strutture di dati.


X_train : una struttura di dati contenente le feature presenti nel
dataset di training.
X_test: una struttura di dati contenente le feature presenti nel

dataset di test.
y_train: un vettore contenente i valori delle etichette nel dataset di

training.
y_test: un vettore contenente i valori delle etichette nel dataset di

test.

Ridimensionamento delle feature


Per molti algoritmi di machine learning, è bene scalare le variabili
fra 0 a 1. Questa operazione viene chiamata normalizzazione delle
feature. Applichiamo questa trasformazione di scala:
from sklearn.preprocessing import StandardScaler

sc = StandardScaler()

X_train = sc.fit_transform(X_train)

X_test = sc.transform(X_test)

Dopo aver ridimensionato i dati, essi saranno pronti per essere


utilizzati come input per i diversi classificatori che esamineremo nei
prossimi paragrafi.

Valutazione dei classificatori


Una volta che il modello è stato addestrato, dobbiamo valutarne le
prestazioni. Per fare ciò, utilizzeremo il seguente processo.
1. Divideremo in due parti il dataset da etichettare: una partizione di
training e una partizione di test. Useremo la partizione di test per
valutare il modello addestrato col training.
2. Utilizzeremo le feature della partizione di test per generare le
etichette per ogni riga. Questo è l’insieme delle etichette previste.
3. Confronteremo le etichette previste con le etichette effettive, per
valutare l’efficacia del modello.

NOTA
Se non stiamo cercando di risolvere qualcosa di molto semplice, avremo alcune
classificazioni errate quando valuteremo il modello. Il modo in cui interpretiamo
queste classificazioni errate per determinare la qualità del modello dipende
dalle metriche prestazionali che scegliamo di utilizzare.

Una volta che abbiamo sia le etichette effettive sia le etichette


previste, possiamo utilizzare una serie di metriche prestazionali per
valutare i modelli. La migliore metrica per quantificare le prestazioni
del modello dipende dai requisiti del problema che dobbiamo
risolvere, nonché dalle feature del dataset di training.

Matrice di confusione
Una matrice di confusione riassume i risultati della valutazione di
un classificatore. La matrice di confusione di un classificatore binario
ha l’aspetto rappresentato nella Figura 7.2.
Figura 7.2

NOTA
Se l’etichetta del classificatore che stiamo addestrando ha due livelli, parliamo
di classificatore binario. Il primo esempio d’uso del machine learning con
supervisione, in particolare di un classificatore binario, è stato durante la Prima
guerra mondiale, per distinguere un aereo da un uccello in volo.

La classificazione può essere suddivisa nelle seguenti quattro


categorie.
Veri positivi (VP): le classificazioni effettivamente positive che
sono state classificate correttamente.
Veri negativi (VN): le classificazioni effettivamente negative che
sono state classificate correttamente.
Falsi positivi (FP): le classificazioni effettivamente negative che
sono state classificate come positive.
Falsi negativi (FN): le classificazioni effettivamente positive che
sono state classificate come negative.
Vediamo come possiamo utilizzare queste quattro categorie per
creare varie metriche prestazionali.

Metriche prestazionali
Le metriche prestazionali permettono di quantificare l’efficacia dei
modelli addestrati. Sulla base di ciò, definiamo le seguenti quattro
metriche.
Metrica Formula

Accuratezza

Richiamo

Precisione

Punteggio F1

L’accuratezza è la proporzione fra le classificazioni corrette e tutte


le classificazioni. Durante il calcolo dell’accuratezza, non
distinguiamo fra veri positivi e veri negativi. La valutazione di un
modello attraverso l’accuratezza è semplice, ma in determinate
situazioni non molto utile.
Valutiamo le situazioni in cui abbiamo bisogno di qualcosa di più
dell’accuratezza per quantificare le prestazioni di un modello. Una di
queste è quando utilizziamo un modello per prevedere un evento raro,
come nei seguenti esempi:
un modello per prevedere le transazioni fraudolente in un
database di transazioni bancarie;
un modello per prevedere la probabilità di guasto meccanico di
una parte del motore di un aeromobile.
In entrambi questi esempi, stiamo cercando di prevedere un evento
raro. Altre due misure diventano più importanti dell’accuratezza in
queste situazioni: il richiamo e la precisione.
Il richiamo calcola il tasso di successo. Nel primo degli esempi
precedenti, è la proporzione fra le attività fraudolente correttamente
segnalate dal modello rispetto a tutte le attività fraudolente. Se, nel
nostro dataset di test, abbiamo avuto 1 milione di transazioni, di cui
100 fraudolente, e il modello è stato in grado di identificarne 78, allora
il valore di richiamo è di 78 / 100.
La precisione misura quante delle transazioni segnalate come
fraudolente dal modello erano effettivamente fraudolente. Invece di
concentrarci su tutte le transazioni fraudolente che il modello non è
riuscito a segnalare, vogliamo determinare quanto siano realmente
precisi gli eventi fraudolenti segnalati dal modello.
Il punteggio F1 riunisce insieme il richiamo e la precisione. Se un
modello ha punteggi perfetti in termini di precisione e richiamo, il suo
punteggio F1 sarà perfetto. Un punteggio elevato in F1 significa che
abbiamo addestrato un modello di alta qualità, con elevata capacità di
richiamo e precisione.

Che cos’è l’overfitting (sovradattamento)


Se un modello di machine learning si comporta ottimamente
nell’ambiente di sviluppo ma manifesta un notevole degrado
nell’ambiente di produzione, diciamo che il modello va in overfitting.
Ciò significa che il modello addestrato si è adattato fin troppo al
dataset di training. Questa è un’indicazione che le regole create dal
modello sono troppo dettagliate. Il compromesso fra varianza e bias
(pregiudizio) del modello cattura al meglio l’idea. Esaminiamo questi
concetti.
Bias
Un modello di machine learning viene addestrato in base a
determinati presupposti. In generale, queste ipotesi sono
approssimazioni semplicistiche di alcuni fenomeni concreti. Queste
ipotesi semplificano le relazioni effettivamente esistenti fra le feature e
le loro caratteristiche e fanno sì che il modello sia più facile da
addestrare. Aumentando il numero delle ipotesi aumentano anche i
pregiudizi, ovvero il bias. Quindi, durante l’addestramento di un
modello, ipotesi più semplicistiche portano a un elevato bias; ipotesi
più realistiche (più rappresentative dei fenomeni reali) portano a un
basso bias.
NOTA
Nella regressione lineare, la non linearità delle feature viene ignorata e le
feature vengono approssimate come variabili lineari. Quindi, i modelli a
regressione lineare sono intrinsecamente vulnerabili a un elevato bias.

Varianza
La varianza quantifica la precisione con cui un modello stima la
variabile target se viene utilizzato un dataset diverso per addestrarlo.
Quantifica se la formulazione matematica del nostro modello
rappresenta una buona generalizzazione dei dati sottostanti.
Regole specifiche in overfitting basate su scenari e situazioni
specifici portano a un’elevata varianza; regole generalizzate e
applicabili a una varietà di scenari e situazioni portano a una bassa
varianza.
NOTA
Il nostro obiettivo nel machine learning è quello di addestrare modelli che
esibiscano valori contenuti di bias e varianza. Raggiungere questo obiettivo non
è sempre facile. Di solito è il problema che tiene svegli la notte i data scientist.

Compromesso bias-varianza
Quando si addestra un modello di machine learning, è difficile
decidere il giusto livello di generalizzazione per le regole che
definiscono il modello addestrato. Occorre trovare il giusto livello di
generalizzazione in termini di bias e varianza.
NOTA
Ipotesi più semplicistiche offrono una maggiore generalizzazione, cosa che
riduce il bias ma aumenta la varianza.

Questo compromesso fra bias e varianza è determinato dalla scelta


dell’algoritmo, dalle feature presenti nel dataset e da vari
iperparametri. È importante raggiungere il giusto compromesso fra
bias e varianza in base agli specifici requisiti del problema che si sta
cercando di risolvere.

Le fasi dei classificatori


Una volta preparati i dati etichettati, lo sviluppo dei classificatori
comporta le fasi di addestramento (o training), valutazione e
distribuzione. Queste tre fasi di implementazione di un classificatore
sono mostrate nel ciclo di vita CRISP-DM rappresentato nella Figura
7.3 (abbiamo già esaminato nel Capitolo 5 il ciclo di vita CRISP-DM).
Figura 7.3

Nelle prime due fasi di implementazione di un classificatore


(training e test) usiamo dati etichettati. I dati etichettati sono divisi in
due partizioni: una partizione più grande (i dati di training) e una
partizione più piccola (i dati di test). Per dividere i dati etichettati nelle
partizioni di training e di test viene utilizzata una tecnica di
campionamento casuale, per assicurarsi che entrambe le partizioni
contengano modelli coerenti. Notate che, come mostra la Figura 7.3,
prima viene la fase di training, in cui i dati di training vengono
utilizzati per addestrare il modello. Una volta terminata la fase di
training, il modello addestrato viene valutato utilizzando i dati di test.
Vengono utilizzate diverse matrici prestazionali per quantificare
l’efficacia del modello addestrato. Una volta valutato il modello,
abbiamo la fase di distribuzione del modello, in cui il modello
addestrato viene effettivamente utilizzato per operazioni di inferenza,
ovvero per risolvere problemi concreti, etichettando dati non
etichettati.
Ora, esaminiamo alcuni algoritmi di classificazione. Nei prossimi
paragrafi tratteremo i seguenti algoritmi di classificazione:
algoritmo ad albero decisionale;
algoritmo XGBoost;
algoritmo a foresta casuale;
algoritmo di regressione logistica;
algoritmo SVM (Support Vector Machine);
algoritmo naive Bayes.
Iniziamo con l’algoritmo ad albero decisionale.

Algoritmo di classificazione ad albero


decisionale
Un albero decisionale si basa sull’approccio del partizionamento
ricorsivo (divide et impera), che genera un insieme di regole che
possono essere utilizzate per prevedere un’etichetta. Parte da un nodo
radice e genera più rami. I nodi interni rappresentano un test su una
determinata feature e il risultato del test è rappresentato da un ramo al
livello successivo. L’albero decisionale termina con i nodi foglia, che
contengono le decisioni. Il processo si interrompe quando il
partizionamento non migliora più il risultato.

L’algoritmo di classificazione ad albero decisionale


La caratteristica distintiva della classificazione ad albero decisionale
è la generazione di una gerarchia di regole interpretabili utilizzata poi
in fase di esecuzione per prevedere l’etichetta. L’algoritmo ha natura
ricorsiva. La creazione di questa gerarchia di regole prevede i seguenti
passaggi.
1. Ricerca della feature più importante. Fra tutte le feature,
l’algoritmo identifica quella che meglio differenzia i punti dei dati
nel dataset di training facendo riferimento all’etichetta. Il calcolo
si basa su metriche come il guadagno informativo o l’impurità di
Gini.
2. Biforcazione. Utilizzando la feature più importante, identificata al
passaggio precedente, l’algoritmo crea un criterio che viene
utilizzato per dividere il dataset di training in due rami:
- punti dei dati che soddisfano il criterio;
- punti dei dati che non soddisfano il criterio.
3. Verifica dei nodi foglia. Se un ramo risultante contiene
principalmente etichette di una classe, il ramo viene reso
definitivo, diventando così un nodo foglia.
4. Controllo delle condizioni di arresto e ripetizione. Se le
condizioni di arresto non sono soddisfatte, l’algoritmo torna al
passaggio 1, per l’iterazione successiva. In caso contrario, il
modello viene contrassegnato come addestrato e ogni nodo
situato al livello più basso dell’albero decisionale viene
etichettato come nodo foglia. La condizione di arresto può essere
semplice, in termini di numero di iterazioni, oppure può essere
utilizzata la condizione di arresto predefinita, in cui l’algoritmo si
arresta non appena raggiunge un certo livello di omogeneità per
ciascuno dei nodi foglia.
L’algoritmo ad albero decisionale può essere spiegato dal
diagramma rappresentato nella Figura 7.4. Qui, il nodo radice contiene
un gruppo di cerchi e croci. L’algoritmo crea un criterio che cerca di
separare i cerchi dalle croci. A ogni livello, l’albero decisionale crea
partizioni dei dati, che si prevede siano sempre più omogenee, oltre il
primo livello. Un classificatore perfetto otterrà nodi foglia che
contengono solo cerchi o solo croci. L’addestramento di classificatori
perfetti è solitamente difficile, a causa della casualità intrinseca del
dataset di training.

Figura 7.4

Utilizzo dell’algoritmo di classificazione ad albero


decisionale per la sfida fra i classificatori
Ora, usiamo l’algoritmo di classificazione ad albero decisionale per
il problema che abbiamo definito in precedenza, per prevedere se un
cliente finisce per acquistare un prodotto.
1. Innanzitutto, istanziamo l’algoritmo di classificazione ad albero
decisionale e addestriamo un modello utilizzando il dataset di
training che abbiamo preparato per i nostri classificatori:
classifier = sklearn.tree.DecisionTreeClassifier(criterion = 'entropy',
random_state = 100,
max_depth = 2)

classifier.fit(X_train, y_train)
2. Ora, usiamo il nostro modello addestrato per prevedere le
etichette per il dataset di test. Generiamo una matrice di
confusione che riassume le prestazioni del nostro modello
addestrato:
import sklearn.metrics as metrics
y_pred = classifier.predict(X_test)
cm = metrics.confusion_matrix(y_test, y_pred)

cm

Questo ci fornisce il seguente output:

3. Ora calcoliamo i valori di accuratezza, richiamo e precisione del


classificatore creato utilizzando l’algoritmo di classificazione ad
albero decisionale:
accuracy = metrics.accuracy_score(y_test, y_pred)
recall = metrics.recall_score(y_test, y_pred)
precision = metrics.precision_score(y_test, y_pred)

print(accuracy, recall, precision)

4. L’esecuzione del codice precedente produrrà il seguente output:

Le metriche prestazionali ci consentono di confrontare fra loro le


diverse tecniche di modellazione dell’addestramento.

I punti di forza e i punti deboli dei classificatori ad albero


decisionale
Esaminiamo i punti di forza e i punti deboli di questo algoritmo.

Punti di forza
Le regole dei modelli creati utilizzando un algoritmo ad albero
decisionale sono facilmente interpretabili. Modelli come questo
sono chiamati modelli whitebox. I modelli whitebox sono
necessari ogni volta che è necessaria la trasparenza nel tracciare i
dettagli e le ragioni delle decisioni prese dal modello. Questa
trasparenza è essenziale nelle applicazioni in cui vogliamo evitare
i pregiudizi e proteggere le comunità vulnerabili. Per esempio, nei
casi d’uso critici dei settori governativo e assicurativo è
generalmente richiesto l’uso di un modello whitebox.
I classificatori ad albero decisionale sono progettati per estrarre
informazioni da uno spazio del problema discreto. Ciò significa
che la maggior parte delle feature è costituita da variabili
categoriche; quindi, l’utilizzo di un albero decisionale per
addestrare il modello è una buona scelta.

Punti deboli

Se l’albero generato dal classificatore ad albero decisionale va


troppo in profondità, le regole finiscono per catturare troppi
dettagli, determinando un modello in overfitting, ovvero
eccessivamente adattato ai dati di training. Durante l’utilizzo di
un algoritmo ad albero decisionale, dobbiamo essere consapevoli
che questi alberi sono vulnerabili all’overfitting e quindi
dobbiamo sfoltire l’albero, ogni volta che è necessario.
Un punto debole dei classificatori ad albero decisionale è la loro
incapacità di catturare relazioni non lineari sotto forma di regole.

Casi d’uso
In questo paragrafo, esaminiamo i casi d’uso per i quali viene
utilizzato l’algoritmo ad albero decisionale.
Classificazione di record
I classificatori ad albero decisionale possono essere utilizzati per
classificare i punti dei dati, come negli esempi seguenti.
Richieste di finanziamento: un classificatore binario può
determinare se un richiedente rischia di essere inadempiente.
Segmentazione dei clienti: un classificatore binario può
suddividere i clienti in clienti di alto valore, di valore medio e di
scarso valore, in modo da condurre strategie di marketing
appropriate per ciascuna categoria.
Diagnosi medica: un classificatore binario può distinguere un
tumore benigno da uno maligno.
Analisi dell’efficacia delle cure: un classificatore binario può
individuare i pazienti che hanno reagito positivamente a una
determinata cura.

Selezione delle feature


L’algoritmo di classificazione ad albero decisionale seleziona un
piccolo sottoinsieme di feature in base alle quali creare le regole. Tale
selezione può essere utilizzata per selezionare le feature anche per un
altro algoritmo di machine learning, quando si dispone di un numero
elevato di feature.

I metodi a ensemble
È una tecnica di machine learning che prevede di creare più modelli
leggermente differenti utilizzando parametri differenti e poi di
combinarli insieme a formare un modello aggregato. Per creare
ensemble efficaci, dobbiamo trovare il giusto criterio di aggregazione.
Esaminiamo alcuni algoritmi a ensemble.
Implementazione dell’amplificazione del gradiente con
l’algoritmo XGBoost
XGBoost è stato creato nel 2014 e si basa su principi di
amplificazione del gradiente. È diventato uno degli algoritmi di
classificazione a ensemble più utilizzati. Genera un gruppo di alberi
interconnessi e utilizza la discesa del gradiente per ridurre al minimo
l’errore residuo. Questo lo rende perfetto per le infrastrutture
distribuite, come Apache Spark, o per il cloud computing, come
Google Cloud o Amazon Web Services (AWS).
Vediamo ora come si implementa l’amplificazione del gradiente con
l’algoritmo XGBoost.
1. Innanzitutto, creeremo un’istanza del classificatore XGBClassfier e
addestreremo il modello utilizzando il dataset di training:

2. Quindi, genereremo le previsioni, basate sul modello appena


addestrato:
y_pred = classifier.predict(X_test)
cm = metrics.confusion_matrix(y_test, y_pred)

cm

Otterremo il seguente output:

3. Infine, quantificheremo le prestazioni del modello:


accuracy = metrics.accuracy_score(y_test, y_pred)
recall = metrics.recall_score(y_test, y_pred)

precision = metrics.precision_score(y_test, y_pred)

print(accuracy, recall, precision)

Otterremo il seguente output:

Esaminiamo ora l’algoritmo a foresta casuale.

Utilizzo dell’algoritmo a foresta casuale


La foresta casuale è un metodo a ensemble che funziona
combinando più alberi decisionali per ridurre sia il bias sia la varianza.

Training di un algoritmo a foresta casuale


Nell’addestramento, questo algoritmo prende N campioni dai dati di
training e crea m sottoinsiemi dei nostri dati complessivi. Questi
sottoinsiemi vengono creati selezionando casualmente alcune righe e
colonne dei dati di input. L’algoritmo costruisce poi m alberi
decisionali indipendenti. Questi alberi di classificazione sono
rappresentati da C1 a Cm.

Utilizzo di una foresta casuale per le previsioni


Una volta che il modello è stato addestrato, può essere utilizzato per
etichettare nuovi dati. Ciascuno dei singoli alberi genera un’etichetta.
La previsione finale è determinata votando queste singole previsioni,
come mostrato nella Figura 7.5. Notate che nella Figura 7.5 vengono
addestrati m alberi, che sono rappresentati da C1 a Cm. Cioè Alberi =
{C1, …, Cm}.
Figura 7.5

Ciascuno degli alberi genera una previsione che è rappresentata da


un insieme:
Singole previsioni = P = {P1, …, Pm}
La previsione finale è rappresentata da Pf ed è determinata dalla
maggioranza delle singole previsioni. La funzione mode() consente di
trovare la decisione della maggioranza (la moda è il numero che si
ripete più spesso, che è la maggioranza). Il legame fra la previsione
finale e le singole previsioni è:
Pf = moda(P)

Distinzione fra algoritmo a foresta casuale e ad amplificazione del gradiente


Gli alberi generati dall’algoritmo a foresta casuale sono totalmente
indipendenti l’uno dall’altro. Nessun albero conosce i dettagli degli
altri alberi dell’ensemble. Questo lo differenzia da altre tecniche, come
l’amplificazione del gradiente.

Utilizzo dell’algoritmo a foresta casuale per la sfida fra i


classificatori
Istanziamo l’algoritmo a foresta casuale e usiamolo per addestrare il
nostro modello utilizzando i dati di training.
Esamineremo due iperparametri chiave.
: controlla quanti alberi decisionali devono essere
n_estimators

costruiti.
max_depth: controlla la profondità di ciascuno di questi alberi

decisionali.
In altre parole, un albero decisionale può continuare a dividersi fino
ad arrivare a una situazione in cui un nodo rappresenta un singolo
esempio nel dataset di training. Impostando max_depth, limitiamo il
numero di queste divisioni. In tal modo controlliamo la complessità del
modello e limitiamo anche l’overfitting rispetto ai dati di training.
Facendo riferimento al seguente output, n_estimators controlla la
larghezza e max_depth controlla la profondità del modello a foresta
casuale:

Una volta addestrato il modello a foresta casuale, usiamolo per le


previsioni:
y_pred = classifier.predict(X_test)

cm = metrics.confusion_matrix(y_test, y_pred)

cm

Otterremo il seguente output:

Ora, quantifichiamo l’efficacia del nostro modello:


accuracy = metrics.accuracy_score(y_test, y_pred)

recall = metrics.recall_score(y_test, y_pred)

precision = metrics.precision_score(y_test, y_pred)

print(accuracy, recall, precision)

Otterremo il seguente output:

Ora esaminiamo la regressione logistica.

Regressione logistica
Quello di regressione logistica è un algoritmo di classificazione
utilizzato per la classificazione binaria. Utilizza una funzione logistica
per formulare le interazioni fra le feature di input e la variabile target.
È una delle tecniche di classificazione più semplici per modellare una
variabile dipendente binaria.

Ipotesi
La regressione logistica presuppone quanto segue:
il dataset di training è completo, non ha valori mancanti;
l’etichetta è una variabile categorica binaria;
l’etichetta è ordinale, in altre parole è una variabile categorica con
valori ordinabili;
tutte le funzioni o le variabili di input sono indipendenti fra loro.

Stabilire la relazione
Per la regressione logistica, il valore previsto viene calcolato come
segue:

Supponiamo che .
Così ora:

La relazione precedente può essere rappresentata graficamente come


segue:
Notate che se z è grande, σ(z) sarà uguale a 1. Se z è molto piccola o
è un numero negativo grande, σ(z) sarà uguale a 0. Quindi, l’obiettivo
della regressione logistica è quello di trovare i valori corretti di w e j.
NOTA
La regressione logistica prende il nome dalla funzione utilizzata per formularla,
chiamata funzione logistica o sigmoide.

Le funzioni di perdita e costo


La funzione loss (perdita) definisce come vogliamo quantificare un
errore per un determinato esempio nei nostri dati di training. La
funzione cost (costo) definisce come vogliamo ridurre al minimo un
errore nell’intero dataset di training. Quindi, la funzione loss viene
utilizzata per uno degli esempi presenti nel dataset di training e la
funzione cost viene utilizzata per il costo complessivo, che quantifica
la deviazione complessiva fra i valori effettivi e quelli previsti e
dipende dalla scelta di w e h.
La funzione loss utilizzata nella regressione logistica è la seguente:

Notate che quando

La riduzione al minimo di loss produrrà un valore elevato di .


Essendo una funzione sigmoide, il valore massimo sarà 1.
Se , allora

Riducendo al minimo loss, sarà il più piccolo possibile, ovvero 0.


La funzione cost della regressione logistica è la seguente:
Quando usare la regressione logistica
La regressione logistica si comporta ottimamente con i classificatori
binari, mentre non funziona molto bene quando i dati sono enormi ma
la loro qualità non è eccezionale. Può catturare relazioni non troppo
complesse e di solito non offre le migliori prestazioni, ma
semplicemente un buon punto di riferimento per iniziare.

Utilizzo dell’algoritmo di regressione logistica per la sfida


fra i classificatori
1. Innanzitutto, istanziamo un modello a regressione logistica e lo
addestriamo utilizzando i dati di training:
from sklearn.linear_model import LogisticRegression
classifier = LogisticRegression(random_state = 0)

classifier.fit(X_train, y_train)

2. Prevediamo i valori dei dati di test e creiamo una matrice di


confusione:
y_pred = classifier.predict(X_test)
cm = metrics.confusion_matrix(y_test, y_pred)

cm

Otterremo il seguente output:

3. Ora, esaminiamo le metriche prestazionali:


accuracy = metrics.accuracy_score(y_test, y_pred)
recall = metrics.recall_score(y_test, y_pred)
precision = metrics.precision_score(y_test, y_pred)

print(accuracy, recall, precision)

4. Otterremo il seguente output:


Ora esaminiamo l’algoritmo SVM.

L’algoritmo SVM
SVM (Support Vector Machine) è un classificatore che trova un
iperpiano ottimale che massimizza il confine fra due classi. In SVM, il
nostro obiettivo è quello di massimizzare tale confine. Il confine è
definito come la distanza fra l’iperpiano di separazione (il confine di
decisione) e i campioni di training più vicini a tale iperpiano, i vettori
di supporto. Iniziamo con un esempio molto semplice, con due sole
dimensioni, X1 e X2. Vogliamo trovare una linea che separi i cerchi
dalle croci (Figura 7.6).

Figura 7.6

Abbiamo tracciato due linee ed entrambe separano perfettamente i


cerchi dalle croci. Tuttavia, deve esistere una linea, o confine
decisionale, ottimale che ci dia la migliore possibilità di classificare
correttamente la maggior parte di altri esempi. Una scelta ragionevole
potrebbe essere una linea equamente distanziata fra queste due classi,
per dare un po’ di margine a ogni classe (Figura 7.7).

Figura 7.7

Ora, vediamo come possiamo usare l’algoritmo SVM per addestrare


un classificatore per la nostra sfida.

Utilizzo dell’algoritmo SVM per la sfida fra i classificatori


1. Innanzitutto, creiamo un’istanza del classificatore SVM e quindi
utilizziamo il dataset di training per addestrarlo. L’iperparametro
kernel determina il tipo di trasformazione applicata ai dati di input
per renderli separabili linearmente:
from sklearn.svm import SVC
classifier = SVC(kernel = 'linear', random_state = 0)

classifier.fit(X_train, y_train)

2. Una volta addestrato il classificatore, generiamo alcune previsioni


e osserviamo la matrice di confusione:
y_pred = classifier.predict(X_test)
cm = metrics.confusion_matrix(y_test, y_pred)

cm

3. Otterremo il seguente output:

4. Ora, esaminiamo le varie metriche prestazionali:


accuracy = metrics.accuracy_score(y_test, y_pred)

recall = metrics.recall_score(y_test, y_pred)

precision = metrics.precision_score(y_test, y_pred)

print(accuracy, recall, precision)

Otterremo il seguente output:

L’algoritmo naive Bayes


Basato sulla teoria della probabilità, naive Bayes è uno degli
algoritmi di classificazione più semplici. Se usato correttamente, può
però fornire previsioni accurate. L’algoritmo naive Bayes si chiama
così per due motivi:
si basa su un presupposto ingenuo (naive) che vi sia indipendenza
fra le feature e la variabile di input;
si basa sul teorema di Bayes.
Questo algoritmo cerca di classificare le istanze in base alle
probabilità degli attributi/istanze precedenti, supponendo una completa
indipendenza dalle feature.
Esistono tre tipi di eventi:
gli eventi indipendenti non influiscono sulla probabilità che si
verifichi un altro evento (per esempio, non vi è nesso fra il
ricevere un’e-mail con un ingresso gratuito a un evento
tecnologico e una riorganizzazione della propria azienda);
gli eventi dipendenti influiscono sulla probabilità che si verifichi
un altro evento, cioè sono collegati in qualche modo (per
esempio, la probabilità di arrivare in tempo a una conferenza può
essere influenzata da uno sciopero del personale della compagnia
aerea o da ritardi nei voli);
gli eventi che si escludono a vicenda non possono verificarsi
contemporaneamente (per esempio, la probabilità di ottenere un
tre e un sei lanciando un dado è 0, perché questi due risultati si
escludono a vicenda).

Teorema di Bayes
Il teorema di Bayes viene utilizzato per calcolare la probabilità
condizionata fra due eventi indipendenti, A e B. La probabilità che si
verifichino gli eventi A e B è rappresentata da P(A) e P(B). La
probabilità condizionata è rappresentata da P(B|A), ovvero la
probabilità condizionata che si verifichi l’evento B dal momento che si
è verificato l’evento A:

Calcolo delle probabilità


Naive Bayes si basa sui fondamenti della probabilità. La probabilità
che si verifichi un singolo evento viene calcolata prendendo il numero
di volte in cui l’evento si è verificato e dividendolo per il numero
totale di processi che avrebbero potuto portare a quell’evento. Per
esempio, un call center riceve più di 100 chiamate di supporto al
giorno 50 volte nel corso di un mese. Vogliamo conoscere la
probabilità che una chiamata ottenga una risposta in meno di 3 minuti
in base alle volte precedenti cui è stata data risposta. Se il call center
riesce a eguagliare questo record in 27 occasioni, la probabilità di
ricevere risposta a 100 chiamate in meno di 3 minuti è la seguente:
P(100 chiamate in meno di 3 minuti) = (27 / 50) = 0,54 (54%)
È possibile rispondere a 100 chiamate in meno di 3 minuti circa la
metà delle volte, in base al fatto che ciò si è verificato 50 volte in
passato.

Regole di moltiplicazione per gli eventi AND


Per calcolare la probabilità che due o più eventi si verifichino
contemporaneamente, consideriamo se gli eventi sono indipendenti o
dipendenti. Se sono indipendenti, si utilizza una semplice
moltiplicazione:
P(risultato 1 AND risultato 2) = P(risultato 1) × P(risultato 2)
Per esempio, per calcolare la probabilità di ricevere un’e-mail con
l’ingresso gratuito a un evento tecnologico e di osservare una
riorganizzazione aziendale, si utilizza questa semplice regola di
moltiplicazione. I due eventi sono indipendenti, in quanto il verificarsi
dell’uno non influisce sulla possibilità che si verifichi anche l’altro.
Se la probabilità di ricevere l’e-mail dell’evento tecnico è del 31% e
la probabilità di osservare una riorganizzazione del personale è
dell’82%, la probabilità che si verifichino entrambi si calcola come
segue:
P(e-mail AND riorganizzazione) = P(e-mail) × P(riorganizzazione) =
(0,31) × (0,82) = 0,2542 (25%)
La regola della moltiplicazione generale
Se due o più eventi sono dipendenti, si utilizza la regola della
moltiplicazione generale. Questa formula, in effetti, è valida in caso di
eventi indipendenti o dipendenti:
P(risultato 1 AND risultato 2) = P(risultato 1) × P(risultato 2 | risultato
1)
Notate che P(risultato 2 | risultato 1) fa riferimento alla probabilità
condizionata che l’esito 2 si verifichi dopo che si è già verificato il
risultato 1. La formula considera cioè la dipendenza fra gli eventi. Se
gli eventi sono indipendenti, allora la probabilità condizionata è
irrilevante, poiché un risultato non influenza la possibilità che si
verifichi l’altro e P(risultato 2 | risultato 1) diventa semplicemente
P(risultato 2). Notate che la formula in questo caso diventa una
semplice moltiplicazione.

Regole di somma per gli eventi OR


Per calcolare la probabilità che si verifichi un evento oppure l’altro
(gli eventi si escludono a vicenda), si utilizza la seguente semplice
regola di somma:
P(risultato 1 OR risultato 2) = P(risultato 1) + P(risultato 2)
Per esempio, qual è la probabilità di ottenere un 6 oppure un 3? Per
rispondere a questa domanda, notate innanzitutto che i due risultati non
possono verificarsi contemporaneamente. La probabilità di ottenere un
6 è 1 / 6 e la probabilità di ottenere un 3 è ancora 1 / 6:
P(6 OR 3) = (1 / 6) + (1 / 6) = 0,33 (33%)
Se invece gli eventi non si escludono a vicenda e quindi possono
verificarsi contemporaneamente, si utilizza la seguente formula di
somma generale, che è valida anche se gli eventi si escludono a
vicenda:
P(risultato 1 OR risultato 2) = P(risultato 1) + P(risultato 2) ×
P(risultato 1 AND risultato 2)

Utilizzo dell’algoritmo naive Bayes per la sfida fra i


classificatori
Ora, usiamo l’algoritmo naive Bayes per valutare la sfida fra i
classificatori.
1. Innanzitutto, importiamo la funzione GaussianNB() e usiamola per
addestrare il modello:
from sklearn.naive_bayes import GaussianNB
classifier = GaussianNB()

classifier.fit(X_train, y_train)

2. Ora usiamo il modello addestrato per prevedere i risultati. Lo


useremo per prevedere le etichette per la nostra partizione di test,
che è X_test:
y_pred = classifier.predict(X_test)
cm = metrics.confusion_matrix(y_test, y_pred)

cm

3. Ora esaminiamo la matrice di confusione:

4. Ora mostriamo le matrici prestazionali, per quantificare la qualità


del nostro modello addestrato:
accuracy = metrics.accuracy_score(y_test, y_pred)

recall = metrics.recall_score(y_test, y_pred)

precision = metrics.precision_score(y_test, y_pred)

print(accuracy, recall, precision)

Otterremo il seguente output:


Fra gli algoritmi di classificazione, il
vincitore è...
Valutiamo in una tabella le metriche prestazionali dei vari algoritmi
che abbiamo messo alla prova.
Algoritmo Accuratezza Richiamo Precisione
Albero decisionale 0,94 0,93 0,88
XGBoost 0,93 0,90 0,87
Foresta casuale 0,93 0,90 0,87
Regressione logistica 0,91 0,81 0,89
SVM 0,89 0,71 0,92
Naive Bayes 0,92 0,81 0,92

Osservando la tabella precedente, possiamo notare che il


classificatore ad albero decisionale offre le prestazioni migliori in
termini di accuratezza e richiamo. Se stiamo puntando alla precisione,
allora c’è un ex-aequo fra SVM e naive Bayes.

Gli algoritmi di regressione


Il machine learning con supervisione utilizza un algoritmo di
regressione quando la variabile target è di tipo continuo. In questo
caso, il modello di machine learning è chiamato regressore.
In questo paragrafo esamineremo vari algoritmi che possono essere
utilizzati per addestrare un modello di machine learning con
supervisione di regressione. Prima di entrare nei dettagli degli
algoritmi, creiamo una sfida per questi algoritmi, per sottoporre a test
le loro prestazioni, le loro capacità e la loro efficacia.

La sfida fra i regressori


Come abbiamo fatto per gli algoritmi di classificazione,
esamineremo prima un problema da risolvere e poi lo sottoporremo
come una sfida a tutti gli algoritmi di regressione. Si tratterà quindi di
una sfida fra i regressori. Questo approccio a “sfida” offre due
vantaggi:
possiamo preparare i dati una sola volta e poi utilizzarli per tutti e
tre gli algoritmi di regressione;
possiamo confrontare le prestazioni di tre algoritmi di regressione
in modo significativo, poiché li useremo per risolvere lo stesso
problema.
Esaminiamo, dunque, la dichiarazione della sfida.

Descrizione della sfida fra i regressori


Al giorno d’oggi è importante prevedere il consumo di carburante
dei veicoli. Un veicolo efficiente fa bene all’ambiente ed è anche
conveniente. Il consumo può essere stimato in base alla potenza del
motore e alle caratteristiche del veicolo. Creiamo una sfida per i
regressori: vogliamo addestrare un modello che sia in grado di
prevedere il consumo in miglia per gallone (MPG) di un veicolo in
base alle sue caratteristiche.
Esaminiamo il dataset storico che useremo per addestrare i
regressori.

Il dataset storico
Di seguito sono riportate le feature presenti nel dataset di dati storici
di cui disponiamo.
Nome Tipo Descrizione
NAME Categorica Identifica un veicolo
CYLINDERS Continua Numero di cilindri (fra 4 e 8)
DISPLACEMENT Continua Cilindrata del motore in pollici cubici
HORSEPOWER Continua Potenza del motore
Tempo necessario per accelerare da 0 a 60 mph (in
ACCELERATION Continu
secondi)

La variabile target per questo problema è una variabile continua, MPG,


che specifica il consumo in miglia per gallone per ciascuno dei veicoli.
Per prima cosa progettiamo la pipeline di elaborazione dei dati per
questo problema.

Ingegnerizzazione delle feature utilizzando una pipeline di


elaborazione dati
Vediamo come possiamo progettare una pipeline di elaborazione
riutilizzabile per la sfida fra i regressori. Come abbiamo detto,
prepareremo i dati una volta e poi li riutilizzeremo per tutti gli
algoritmi di regressione.
1. Iniziamo importando il dataset, come segue:
dataset = pd.read_csv('auto.csv')

2. Ora osserviamo l’aspetto del dataset:


dataset.head(5)

Ecco l’output che otterremo:

3. Procediamo con la selezione delle feature. Scartiamo la colonna


NAME, in quanto è solo un identificatore delle auto. Le colonne

utilizzate solo per identificare le righe del nostro dataset non sono
rilevanti per l’addestramento del modello:
dataset = dataset.drop(columns = ['NAME'])

4. Convertiamo tutte le variabili di input e assegniamo tutti valori


nulli:
dataset = dataset.apply(pd.to_numeric, errors = 'coerce')

dataset.fillna(0, inplace = True)


Questo migliora la qualità dei dati e li prepara per essere utilizzati
per addestrare il modello. Ora vediamo il passaggio finale.
5. Dividiamo i dati nelle partizioni di training e di test:
from sklearn.model_selection import train_test_split

#from sklearn.cross_validation import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size =


0.25, random_state = 0)

Questo crea le seguenti quattro strutture di dati.


: una struttura contenente le feature presenti nel dataset di
X_train

training.
X_test: una struttura contenente le feature del dataset di test.

: un vettore contenente i valori delle etichette nel dataset di


y_train

training
y_test: un vettore contenente i valori delle etichette nel dataset di
test.
Ora, usiamo i dati preparati su tre diversi regressori, in modo da
poterne confrontare le prestazioni.

Regressione lineare
Di tutte le tecniche di machine learning con supervisione,
l’algoritmo di regressione lineare è il più facile da capire.
Esamineremo prima la regressione lineare semplice e poi espanderemo
il concetto alla regressione lineare multipla.

Regressione lineare semplice


Nella sua forma più semplice, la regressione lineare formula la
relazione fra una singola variabile indipendente continua e una singola
variabile indipendente continua. La regressione lineare semplice viene
impiegata per mostrare la misura in cui i cambiamenti in una variabile
dipendente (sull’asse y) possono essere attribuiti ai cambiamenti
riscontrati in una variabile indipendente (sull’asse x), come descritto
dalla seguente formula:

Questa formula può essere spiegata come segue.


y è la variabile dipendente.
X è la variabile indipendente.
w è la pendenza, che indica quanto sale la retta all’aumentare di
X.
α è l’intercettazione, che indica il valore di y quando X = 0.
Alcuni esempi di relazioni fra una singola variabile dipendente
continua e una singola variabile indipendente continua sono i seguenti:
il peso di una persona e il suo apporto calorico;
il prezzo di una casa e la sua superficie in un determinato
quartiere;
l’umidità dell’aria e la probabilità di pioggia.
Per la regressione lineare, sia la variabile di input (quella
indipendente) sia la variabile target (quella dipendente) devono essere
numeriche. La migliore relazione si trova minimizzando la somma dei
quadrati delle distanze verticali di ciascun punto da una linea tracciata
attraverso tutti i punti. Si presume che la relazione sia lineare fra la
variabile di input e la variabile target. Per esempio, più soldi vengono
investiti in ricerca e sviluppo, maggiori sono le vendite.
Vediamo un esempio specifico. Proviamo a formulare la relazione
fra le spese di marketing e le vendite di un determinato prodotto. Si
scopre che sono direttamente proporzionali le une alle altre. Nella
Figura 7.8 le spese di marketing e le vendite sono disegnate su un
grafico bidimensionale e sono mostrate come rombi blu. La relazione
può essere ben approssimata da una linea retta.
Figura 7.8

Una volta tracciata la linea, possiamo osservare la relazione


matematica esistente fra le spese di marketing e le vendite.

Valutazione dei regressori


La linea che abbiamo tracciato è un’approssimazione della relazione
fra le due variabili, dipendente e indipendente. Anche la linea migliore
esibirà qualche deviazione rispetto ai valori effettivi, come vediamo
nella Figura 7.9.

Figura 7.9

Un modo tipicamente usato per quantificare le prestazioni dei


modelli a regressione lineare consiste nell’utilizzare la radice
dell’errore quadratico medio, (RMSE, Root Mean Square Error), che
calcola la deviazione standard degli errori commessi dal modello
addestrato. Per un certo esempio nel dataset di training, la funzione
loss viene calcolata come segue:

Questo porta alla seguente funzione di cost, che riduce al minimo


loss di tutti gli esempi presenti nel dataset di training:

Proviamo a interpretare il valore RMSE. Se è di 50 dollari per il


nostro modello di esempio che prevede il prezzo di un prodotto, ciò
significa che circa il 68,2% delle previsioni rientrerà entro i 50 dollari
dal valore reale (ovvero α). Significa anche che il 95% delle previsioni
rientrerà entro 100 dollari (ovvero 2α) del valore effettivo. Infine, il
99,7% delle previsioni rientrerà nei 150 dollari del valore effettivo.
Regressione multipla
Il problema è che la maggior parte delle analisi concrete ha più di
una variabile indipendente. La regressione multipla è un’estensione
della regressione lineare semplice. La differenza fondamentale è che ci
sono ulteriori coefficienti β per le variabili descrittive aggiuntive.
Quando si addestra un modello, l’obiettivo è quello di trovare i
coefficienti β che minimizzino gli errori dell’equazione lineare.
Proviamo a formulare matematicamente la relazione fra la variabile
dipendente e l’insieme delle variabili indipendenti (le feature).
Come in una semplice equazione lineare, la variabile dipendente, y,
è quantificata come la somma di un termine di intercettazione α più il
prodotto dei coefficienti β moltiplicati per il valore x per ciascuna
feature i:
y = α + β1 x1 + β2 x2 + … + βi xi + ε
L’errore è rappresentato da ε e indica che le previsioni non sono
perfette.
I coefficienti β consentono a ciascuna feature di avere un effetto
distinto sul valore di y, a causa delle variazioni di y di una quantità βi
per ogni incremento unitario di xi. Inoltre, l’intercettazione (α) indica il
valore atteso di y quando le variabili indipendenti sono tutte uguali a 0.
Notate che tutte le variabili nell’equazione precedente possono
essere rappresentate da una manciata di vettori. Le variabili target e di
input sono ora vettori con una riga e anche i coefficienti di regressione,
βi, e gli errori, ε, sono vettori.

Utilizzo dell’algoritmo di regressione lineare per la sfida fra


i regressori
Ora addestriamo il modello utilizzando la parte di training del
dataset.
1. Iniziamo importando il pacchetto di regressione lineare:
from sklearn.linear_model import LinearRegression

2. Poi, istanziamo il modello a regressione lineare e addestriamolo


utilizzando il dataset di training:
regressor = LinearRegression()

regressor.fit(X_train, y_train)

3. Ora, prevediamo i risultati utilizzando la parte di test del dataset:


y_pred = regressor.predict(X_test)
from sklearn.metrics import mean_squared_error
from math import sqrt

sqrt(mean_squared_error(y_test, y_pred))

4. Otterremo il seguente output:

Come abbiamo visto nel paragrafo precedente, RMSE è la


deviazione standard dell’errore. Indica che il 68,2% delle previsioni
rientrerà nel 4,36 del valore della variabile target.

Quando utilizzare la regressione lineare?


La regressione lineare si utilizza per risolvere molti problemi
concreti, inclusi i seguenti:
previsioni di vendita;
previsione dei prezzi ottimali dei prodotti;
quantificazione della relazione causale fra un evento e la risposta,
per esempio negli studi clinici sui farmaci, nei test di sicurezza
ingegneristica o nelle ricerche di mercato;
identificazione di modelli che possano essere utilizzati per
prevedere il comportamento futuro, in base a criteri noti, per
esempio per la previsione di richieste di risarcimento, danni da
calamità naturali, risultati elettorali e tassi di criminalità.
I punti deboli della regressione lineare
I punti deboli della regressione lineare sono i seguenti:
funziona solo con feature numeriche;
costringe a pre-elaborare i dati categorici;
non gestisce bene i dati mancanti;
impone ipotesi sui dati.

L’algoritmo ad albero di regressione


L’algoritmo ad albero di regressione è simile all’algoritmo ad albero
di classificazione, tranne per il fatto che la variabile target è di tipo
continuo, non categorico.

Utilizzo dell’algoritmo ad albero di regressione per la sfida


fra i regressori
In questo paragrafo vedremo come utilizzare un algoritmo ad albero
di regressione per la sfida fra i regressori.
1. Innanzitutto, addestriamo il modello utilizzando un algoritmo ad
albero di regressione:

2. Una volta addestrato il modello ad albero di regressione,


utilizziamo il modello addestrato per prevedere i valori:
y_pred = regressor.predict(X_test)

3. Quindi, calcoliamo il valore RMSE per quantificare le prestazioni


del modello:
from sklearn.metrics import mean_squared_error

from math import

sqrt sqrt(mean_squared_error(y_test, y_pred))

Otteniamo il seguente output:


L’algoritmo di regressione con
amplificazione del gradiente
Esaminiamo ora l’algoritmo di regressione con amplificazione del
gradiente. Utilizza un insieme di alberi decisionali nel tentativo di
formulare meglio i pattern presenti nei dati.

Utilizzo dell’algoritmo di regressione con amplificazione del


gradiente per la sfida fra i regressori
In questo paragrafo, vedremo come utilizzare l’algoritmo di
regressione con amplificazione del gradiente per la sfida fra i
regressori.
1. Innanzitutto, addestriamo il modello utilizzando l’algoritmo di
regressione con amplificazione del gradiente:

2. Una volta addestrato il modello, usiamolo per prevedere i valori:


y_pred = regressor.predict(X_test)

3. Infine, calcoliamo il valore RMSE per quantificare le prestazioni


del modello:
from sklearn.metrics import mean_squared_error
from math import sqrt
sqrt(mean_squared_error(y_test, y_pred))

4. Otterremo il seguente output:

Fra gli algoritmi di regressione, il


vincitore è...
Valutiamo le prestazioni dei tre algoritmi di regressione che
abbiamo usato sugli stessi dati ed esattamente sullo stesso caso d’uso.
Algoritmo RMSE
Regressione lineare 4,36214129677179
Albero di regressione 5,2771702288377
Regressione ad amplificazione del gradiente 4,0348363733089085

Osservando le prestazioni degli algoritmi di regressione, è evidente


che la regressione con amplificazione del gradiente offre le migliori
prestazioni, in quanto ha l’RMSE più basso. Seconda è la regressione
lineare. L’algoritmo ad albero di regressione ha dato i risultati
peggiori, su questo problema.

Esempio pratico: previsioni del


tempo
Vediamo come possiamo usare i concetti sviluppati in questo
capitolo per prevedere il tempo. Supponiamo di voler prevedere se
domani pioverà sulla base dei dati raccolti in un anno per una
determinata città.
I dati disponibili per addestrare questo modello si trovano nel file
CSV weather.csv.
1. Importiamo i dati come data frame pandas:
import numpy as np
import pandas as pd

df = pd.read_csv("weather.csv")

2. Osserviamo le colonne del data frame:

3. Quindi, esaminiamo i nomi delle prime 13 colonne del file


weather.csv:

4. Ora, esaminiamo i nomi delle ultime 10 colonne del file


weather.csv:

5. Usiamo x per rappresentare le feature di input. Scarteremo il


campo Data dall’elenco delle feature, in quanto non è utile nel
contesto delle previsioni. Scarteremo anche l’etichetta RainTomorrow:
x = df.drop(['Date', 'RainTomorrow'], axis = 1)
6. Usiamo y per rappresentare l’etichetta:
y = df['RainTomorrow']

7. Ora dividiamo i dati del dataset di training, train_test_split:


from sklearn.model_selection import
train_test_split train_x , train_y , test_x , test_y =

train_test_split(x, y , test_size = 0.2, random_state = 2)

8. Poiché l’etichetta è una variabile binaria, stiamo addestrando un


classificatore. Quindi, la regressione logistica sarà una buona
scelta. Innanzitutto, istanziamo il modello a regressione logistica:
model = LogisticRegression()

9. Ora possiamo usare train_x e test_x per addestrare il modello:


model.fit(train_x , test_x)

10. Una volta addestrato il modello, usiamolo per effettuare le


previsioni:
predict = model.predict(train_y)

11. Ora, troviamo l’accuratezza del modello che abbiamo addestrato:

Ora, potremo utilizzare questo classificatore binario per prevedere


se domani pioverà.

Riepilogo
In questo capitolo, abbiamo iniziato esaminando le basi del machine
learning con supervisione. Quindi, abbiamo esaminato in modo più
dettagliato vari algoritmi di classificazione. Successivamente, abbiamo
trattato diversi metodi per valutare le prestazioni dei classificatori e
abbiamo studiato vari algoritmi di regressione. Infine, abbiamo
esaminato diversi metodi per valutare le prestazioni degli algoritmi che
abbiamo studiato.
Il prossimo capitolo si occupa delle reti neurali e degli algoritmi di
deep learning. Esamineremo i metodi utilizzati per addestrare una rete
neurale e anche vari strumenti e framework disponibili per la
valutazione e l’implementazione di una rete neurale.
Capitolo 8

Algoritmi a rete neurale

L’ideazione delle reti neurali artificiali (ANN, Artificial Neural


Network), una delle più importanti tecniche di machine learning oggi
disponibili è dovuta a una combinazione di vari fattori, fra i quali la
necessità di risolvere problemi sempre più complessi, l’esplosione di
dati disponibili e l’emergere di tecnologie come i cluster, oggi
economici e prontamente disponibili, in grado di fornire la potenza di
calcolo necessaria per eseguire algoritmi molto complessi.
In effetti, questa è l’area di ricerca in evoluzione più rapida ed è
responsabile della maggior parte dei progressi rivendicati dai settori
tecnologici all’avanguardia, come la robotica, l’elaborazione del
linguaggio naturale e gli automezzi a guida autonoma.
Esaminando la struttura di una rete neurale, la sua unità di base è il
neurone. La vera forza di una rete neurale risiede nella sua capacità di
utilizzare la potenza di più neuroni, organizzandoli in un’architettura a
più strati, detti layer. Una rete neurale crea un’architettura a layer
legando i neuroni in vari strati. Un segnale attraversa questi strati e
viene elaborato in modi diversi in ciascuno dei layer fino a quando non
viene generato l’output richiesto. Come vedremo in questo capitolo, i
layer nascosti utilizzati dalle reti neurali si comportano come livelli di
astrazione, consentendo attività di deep learning, apprendimento
profondo, ampiamente utilizzate nella realizzazione di potenti
applicazioni come Alexa di Amazon, la ricerca di immagini di Google
e Google Photo.
Questo capitolo introduce innanzitutto i concetti e i componenti
principali di una tipica rete neurale. Quindi, presenta i vari tipi di reti
neurali disponibili e spiega i diversi tipi di funzioni di attivazione da
esse utilizzate. Quindi, tratta in dettaglio la retropropagazione
(backpropagation), l’algoritmo più utilizzato per l’addestramento di
una rete neurale. Successivamente, spiega le tecniche di trasferimento
dell’apprendimento, che può essere utilizzata per semplificare e
automatizzare parzialmente l’addestramento dei modelli. Infine,
mostra come utilizzare il deep learning per segnalare i documenti
fraudolenti tramite un’applicazione concreta.

Le reti neurali
Ispirato al funzionamento dei neuroni nel cervello umano, il
concetto di reti neurali è stato proposto da Frank Rosenblatt nel 1957.
Per comprendere appieno la sua architettura, è utile esaminare
brevemente la struttura a strati e concatenata dei neuroni nel cervello
umano (Figura 8.1).
Nel cervello umano, i dendriti si comportano come sensori,
rilevando un segnale. Il segnale viene poi trasmesso a un assone, che è
una proiezione, lunga e sottile, di una cellula nervosa. La funzione
dell’assone è quella di trasmettere il segnale a muscoli, ghiandole o
altri neuroni. Come vediamo nella Figura 8.1, il segnale attraversa il
tessuto di connessione chiamato sinapsi prima di essere trasmesso ad
altri neuroni. Notate che attraverso questa “pipeline organica”, il
segnale continua a viaggiare fino a raggiungere il muscolo o la
ghiandola target, dove provoca l’azione richiesta. In genere occorrono
dai sette agli otto millisecondi affinché il segnale attraversi la catena di
neuroni e raggiunga la sua destinazione.
Figura 8.1

Ispirato da questo vero e proprio capolavoro naturale di


elaborazione del segnale, Frank Rosenblatt ideò una tecnica che faceva
in modo che le informazioni digitali possano essere elaborate a più
riprese per risolvere un complesso problema matematico. Il suo
tentativo iniziale di progettare una rete neurale era piuttosto semplice e
somigliava a un modello a regressione lineare. Questa semplice rete
neurale non aveva layer nascosti e si chiamava perceptron. La Figura
8.2 ne illustra il funzionamento.
Figura 8.2

Proviamo a sviluppare la rappresentazione matematica di questo


perceptron. Nella Figura 8.2, sul lato sinistro troviamo i segnali in
ingresso. Troviamo un aggregatore che calcola una somma ponderata
di tutti gli input, perché ciascuno degli input (x1, x2, …, xn) viene
moltiplicato per un peso corrispondente (w1, w2, …, wn) e poi
sommato:

Notate che questo è un classificatore binario, perché l’output di


questo perceptron è vero o falso, a seconda dell’output
dell’aggregatore (mostrato come Σ nel diagramma). L’aggregatore
produrrà un segnale vero se è in grado di rilevare un segnale valido da
almeno uno degli input.
Esaminiamo ora l’evoluzione nel tempo delle reti neurali.

Evoluzione delle reti neurali


Nel paragrafo precedente, abbiamo esaminato la prima semplice rete
neurale senza layer chiamata perceptron. Purtroppo, il perceptron
aveva notevoli limiti e nel 1969 Marvin Minsky e Seymour Papert
condussero ricerche che portarono alla conclusione che un perceptron
è incapace di apprendere qualsiasi logica complessa.
In effetti, dimostrarono che per un perceptron sarebbe stato difficile
imparare anche funzioni logiche semplici, come l’OR esclusivo, lo
XOR. Ciò portò a un calo d’interesse per il machine learning in
generale e per le reti neurali in particolare, e diede inizio a un’era che
oggi è conosciuta come l’inverno dell’IA. I ricercatori di tutto il mondo
smisero di fare affidamento sull’IA, ritenendola incapace di risolvere
problemi complessi.
Uno dei motivi principali del cosiddetto inverno dell’IA erano i
limiti delle capacità elaborative disponibili all’epoca. E quando la
potenza di calcolo necessaria era disponibile, era proibitivamente
costosa. Verso la fine degli anni Novanta, i progressi nel calcolo
distribuito resero possibile la creazione di un’infrastruttura facilmente
disponibile ed economica, che portò il disgelo nell’inverno dell’IA,
cosa che ha rinvigorito la ricerca nell’IA. Ciò alla fine trasformò l’era
attuale in quella che può essere chiamata la primavera dell’IA, dove
c’è un accresciuto interesse per l’intelligenza artificiale in generale e
per le reti neurali in particolare.
Per problemi più complessi, i ricercatori hanno sviluppato una rete
neurale multilayer chiamata perceptron multilayer. Una rete neurale
multilayer è dotata di più livelli, come mostrato nella Figura 8.3:
un layer di input;
uno o più layer nascosti;
un layer di output.

NOTA
Una rete neurale profonda è una rete neurale contenente uno o più layer
nascosti. Il deep learning è il processo di training di una rete neurale.

Figura 8.3

Una cosa importante da notare è che l’unità di base di questa rete è


il neurone, e che ogni neurone di un layer è connesso a tutti i neuroni
del layer successivo. Per reti complesse, il numero di queste
interconnessioni esplode, ed esploreremo diversi modi per ridurre
queste interconnessioni senza sacrificare troppa qualità.
Per prima cosa, proviamo a formulare il problema che stiamo
cercando di risolvere.
L’input è un vettore delle feature, x, di dimensione n.
Vogliamo che la rete neurale preveda i valori. I valori previsti sono
rappresentati da y´.
Matematicamente, vogliamo determinare, dato un certo input, la
probabilità che una transazione sia fraudolenta. In altre parole, dato un
determinato valore di x, qual è la probabilità che y = 1?
Matematicamente, possiamo rappresentarlo come segue:

Notate che x è un vettore a nx-dimensionale, dove nx è il numero di


variabili di input.
Questa rete neurale ha quattro layer. I layer fra l’input e l’output
sono i layer nascosti. Il numero di neuroni nel primo layer nascosto è
indicato con

I collegamenti fra i vari nodi vengono moltiplicati per parametri


chiamati pesi. L’addestramento di una rete neurale consiste nel trovare
i corretti valori di questi pesi.
Vediamo come addestrare una rete neurale.

Addestramento di una rete neurale


Il processo di creazione di una rete neurale sulla base di un
determinato dataset è chiamato addestramento o training di una rete
neurale. Pensiamo all’anatomia di una tipica rete neurale. Addestrare
una rete neurale significa calcolare i valori più corretti per i pesi.
L’addestramento avviene in modo iterativo utilizzando una serie di
esempi sotto forma di dati di training. Gli esempi contenuti nei dati di
training contengono i valori effettivi dell’output per diverse
combinazioni di valori di input. Il processo di training delle reti neurali
differisce dal modo in cui vengono addestrati i modelli tradizionali
(trattato nel Capitolo 7).
Anatomia di una rete neurale
Vediamo in che cosa consiste una rete neurale.
Layer: i layer sono gli elementi costitutivi di una rete neurale.
Ogni layer è un modulo di elaborazione dati che funge da filtro.
Prende uno o più input, li elabora in un certo modo e produce uno
o più output. Ogni volta che i dati attraversano un livello,
subiscono una fase di elaborazione e mostrano i pattern rilevanti
per la domanda cui stiamo cercando di rispondere.
Funzione loss: fornisce il segnale di feedback utilizzato nelle
varie iterazioni del processo di apprendimento. La funzione loss
fornisce la deviazione per un singolo esempio.
Funzione cost: è la funzione loss applicata a un intero insieme di
esempi.
Ottimizzatore: determina come verrà interpretato il segnale di
feedback fornito dalla funzione loss.
Dati di input: sono i dati utilizzati per addestrare la rete neurale.
Determinano la variabile target.
Pesi: vengono calcolati addestrando la rete. I pesi corrispondono
approssimativamente all’importanza di ciascuno degli input. Per
esempio, se un determinato input è più importante di altri, dopo
l’addestramento gli viene assegnato un peso maggiore. Anche un
segnale debole di quell’input importante acquisirà forza grazie al
valore del peso (che funge da moltiplicatore). Quindi il peso
finisce per trasformare ciascuno degli input in base alla sua
importanza.
Funzione di attivazione: i valori vengono moltiplicati per pesi
diversi e poi aggregati. Il modo esatto in cui verranno aggregati e
come verrà interpretato il loro valore sarà determinato dalla
funzione di attivazione scelta.
Esaminiamo ora un aspetto molto importante dell’addestramento, o
training, delle reti neurali.
Durante l’addestramento delle reti neurali, prendiamo ciascuno degli
esempi, uno per uno, e per ciascuno di essi generiamo l’output
utilizzando il nostro modello di addestramento. Calcoliamo la
differenza fra l’output atteso e l’output previsto. Per ogni singolo
esempio, questa differenza è chiamata loss, perdita. Nel complesso, il
valore loss per l’intero dataset di training è chiamato cost, costo.
Mentre continuiamo ad addestrare il modello, cerchiamo di trovare i
giusti valori dei pesi, che si tradurranno nella riduzione del valore di
loss. Durante tutto l’addestramento, continuiamo a regolare i valori dei
pesi fino a trovare l’insieme dei valori che si traduce nel minor valore
complessivo possibile di cost. Una volta raggiunto il costo minimo, il
modello è addestrato.

Definizione della discesa del gradiente


Lo scopo dell’addestramento, o training, di un modello a rete
neurale è quello di trovare i giusti valori dei pesi. Iniziamo ad
addestrare una rete neurale impiegando valori casuali o predefiniti per
i pesi. Poi applichiamo in modo iterativo un algoritmo di
ottimizzazione, come la discesa del gradiente, per modificare i pesi in
modo da migliorare le previsioni.
Il punto di partenza di un algoritmo di discesa del gradiente è
costituito dai valori casuali dei pesi, che devono essere ottimizzati
durante l’iterazione dell’algoritmo. A ogni iterazione, l’algoritmo
procede modificando i valori dei pesi in modo da minimizzare il costo,
cost.
La Figura 8.4 spiega la logica dell’algoritmo di discesa del
gradiente. Nella figura l’input è il vettore delle feature X. Il valore
effettivo della variabile target è Y e il valore previsto della variabile
target è Y'. Determiniamo la deviazione del valore effettivo dai valori
previsti. Aggiorniamo i pesi e ripetiamo i passaggi fino a ridurre al
minimo il costo.
Come far variare il peso a ogni iterazione dell’algoritmo dipende dai
seguenti due fattori.
Direzione: in quale direzione andare per minimizzare la funzione
loss.
Tasso di apprendimento: l’entità del cambiamento nella direzione
che abbiamo scelto.
La Figura 8.5 mostra un semplice processo iterativo.
Il diagramma mostra come, variando i pesi, la discesa del gradiente
cerchi di minimizzare il costo. La velocità di apprendimento e la
direzione scelta determineranno il punto successivo del grafico da
esplorare.
È importante selezionare il valore giusto per il tasso di
apprendimento. Se il tasso di apprendimento fosse troppo piccolo, il
problema potrebbe richiedere molto tempo per convergere. Se
viceversa il tasso di apprendimento fosse troppo ampio, il problema
non convergerebbe mai: nella Figura 8.5, il punto che rappresenta la
nostra soluzione attuale continuerebbe a oscillare fra le due curve
opposte del grafico.
Figura 8.4

Figura 8.5

Ora, vediamo come minimizzare un gradiente. Considerate solo due


variabili, x e y. Il gradiente di x e y si calcola come segue:
Per ridurre al minimo il gradiente, potete utilizzare il seguente
approccio:
while(gradient != 0):
if (gradient < 0); move right
if (gradient > 0); move left

Questo algoritmo può essere utilizzato anche per trovare i valori


ottimali o pseudo-ottimali dei pesi di una rete neurale.
Notate che il calcolo della discesa del gradiente procede attraverso
la rete. Iniziamo calcolando prima il gradiente del layer finale, poi il
penultimo e poi quello precedente, fino a raggiungere il primo layer.
Questa è chiamata retropropagazione, backpropagation, ed è stata
introdotta da Hinton, Williams e Rumelhart nel 1985.
Esaminiamo ora le funzioni di attivazione.

Funzioni di attivazione
Una funzione di attivazione definisce il modo in cui verranno
elaborati gli input di un determinato neurone per generare un output.
Come mostra la Figura 8.6, ciascuno dei neuroni di una rete neurale
è dotato di una funzione di attivazione, che determina come verranno
elaborati gli input. Nella figura possiamo vedere che i risultati generati
da una funzione di attivazione vengono passati all’output. La funzione
di attivazione imposta i criteri in base ai quali devono essere
interpretati i valori degli input per generare un output.
Figura 8.6

Per gli stessi valori di input, funzioni di attivazione differenti


produrranno output differenti. Imparare a selezionare la giusta
funzione di attivazione è importante quando si utilizzano le reti neurali
per risolvere i problemi.
Esaminiamo ora le funzioni di attivazione disponibili.

Funzione soglia
È la funzione di attivazione più semplice possibile. L’output della
funzione soglia è binario: 0 o 1. Genera 1 come output se uno qualsiasi
degli input è maggiore di 1, come descritto nella Figura 8.7.
Figura 8.7

Notate che non appena le somme pesate degli input danno “segni di
vita”, l’output (y) diventa 1. Ciò rende molto sensibile la funzione di
attivazione a soglia. È abbastanza vulnerabile a un’attivazione errata a
causa di un minimo segnale in input, magari causato da un problema
tecnico o da “rumore”.

Funzione sigmoide
Può essere pensata come un miglioramento della funzione soglia.
Qui abbiamo un controllo sulla sensibilità della funzione di attivazione
(Figura 8.8).
Figura 8.8.

La funzione sigmoide, y, è definita come segue:

Può essere implementata in Python come segue:


def sigmoidFunction(z):

return 1 / (1 + np.exp(-z))

Notate che, riducendo la sensibilità della funzione di attivazione,


rendiamo l’algoritmo meno sensibile alle anomalie nell’input. Notate,
inoltre, che l’output della funzione di attivazione sigmoide è ancora
binario, ovvero 0 o 1.
Funzione ReLU
L’output delle prime due funzioni di attivazione presentate in questo
capitolo era binario. Ciò significa che prendono una serie di variabili
di input e le convertono in un output binario. La funzione di
attivazione ReLU (Rectified Linear Unit) prende come input un
insieme di variabili e le converte in un unico output continuo. Nelle
reti neurali, ReLU è la funzione di attivazione più utilizzata, e viene
solitamente impiegata nei layer nascosti, dove le variabili continue non
devono essere convertite in variabili categoriche.
La Figura 8.9 riassume la funzione di attivazione ReLU.

Figura 8.9

Notate che quando x ≤ 0, allora y = 0. Ciò significa che qualsiasi


input che è zero o meno di zero viene tradotto in un output zero:
per

per

Non appena x diventa maggiore di zero, la funzione di attivazione


dà x.
La funzione ReLU è una delle funzioni di attivazione più utilizzate
nelle reti neurali. Può essere implementata in Python come segue:
def ReLU(x):

if x < 0:

return 0

else:

return x

Funzione Leaky ReLU


Nella funzione ReLU, un valore negativo di x genera il valore zero
per y. Questo significa che alcune informazioni verranno perse, il che
allunga i cicli di training, soprattutto all’inizio dell’addestramento. La
funzione di attivazione Leaky ReLU risolve questo problema. Per
Leaky ReLu vale quanto segue:

per
per

L’effetto è rappresentato nella Figura 8.10.

Figura 8.10

Qui, ß è un parametro con un valore minore di 1.


La funzione può essere implementata in Python come segue:
def leakyReLU(x, beta = 0.01):

if x < 0:

return (beta * x)

else:

return x

Esistono tre modi per specificare il valore di ß:


possiamo specificare un valore predefinito di ß;
possiamo rendere ß un parametro nella nostra rete neurale e
possiamo lasciare che sia la rete neurale a decidere il valore (in
questo caso si parla di ReLU parametrico);
possiamo rendere ß un valore casuale (in questo caso si parla di
ReLU randomizzato).

Funzione tangente iperbolica (tanh)


La funzione tangente iperbolica è simile alla funzione sigmoide, ma
ha anche la capacità di dare un segnale negativo. La funzione è
illustrata nella Figura 8.11.

Figura 8.11

La funzione y è la seguente:

La funzione può essere implementata dal seguente codice Python:


def tanh(x):

numerator = 1 – np.exp(-2 * x)

denominator = 1 + np.exp(-2 * x)

return numerator/denominator
Funzione softmax
A volte abbiamo bisogno di più di due livelli come output della
funzione di attivazione. Softmax è una funzione di attivazione che
fornisce più di due livelli in output e ciò la rende particolarmente
adatta a problemi di classificazione multiclasse. Supponiamo di avere
n classi e di avere i valori di input. L’associazione fra i valori di input e
le classi è come segue:

La funzione softmax impiega la teoria della probabilità. La


probabilità che l’output della funzione softmax sia la classe e-esima si
calcola come segue:

NOTA
Per i classificatori binari, la funzione di attivazione nell’ultimo layer sarà la
sigmoide; per i classificatori multiclasse sarà la softmax.

Strumenti e framework
In questo paragrafo, esamineremo in dettaglio i framework e gli
strumenti disponibili per l’implementazione delle reti neurali. Nel
tempo, sono stati sviluppati molti framework per implementare le reti
neurali, ognuno con i suoi punti di forza e punti deboli. In questo
paragrafo, ci concentreremo su Keras con TensorFlow.

Keras
Keras è una delle librerie di reti neurali più utilizzate ed è scritta in
Python. È stata scritta pensando alla facilità d’uso e rappresenta il
modo più veloce per implementare il deep learning. Keras fornisce
solo blocchi di alto livello ed è considerata una libreria per modelli.

Motori di supporto per Keras


Keras ha bisogno di una libreria di deep learning di basso livello per
eseguire le manipolazioni a livello dei tensori. Questa libreria di deep
learning di basso livello è chiamata motore di backend. I motori di
backend impiegabili in Keras includono i seguenti:
TensorFlow (http://www.tensorflow.org): è il framework (di Google)
più diffuso ed è open source;
Theano (http://www.deeplearning.net/software/theano) è stato
sviluppato presso il laboratorio MILA dell’Università di
Montréal;
Microsoft Cognitive Toolkit (CNTK) è stato sviluppato da
Microsoft.
Lo stack di tecnologie impiegate nel deep learning modulare è
rappresentato nella Figura 8.12.
Figura 8.12

Il vantaggio di questa architettura modulare di deep learning è che il


backend di Keras può essere modificato senza riscrivere il codice. Per
esempio, se scopriamo che TensorFlow è migliore di Theano per un
determinato compito, possiamo semplicemente cambiare il backend
senza riscrivere alcunché.

I layer di basso livello dello stack di deep learning


I tre motori di backend che abbiamo appena menzionato possono
essere eseguiti sia su CPU sia su GPU utilizzando i layer di basso
livello dello stack. Per le CPU viene utilizzata una libreria di basso
livello per operazioni sui tensori chiamata Eigen. Per le GPU,
TensorFlow utilizza la libreria CUDA Deep Neural Network (cuDNN),
di NVIDIA.

Definizione degli iperparametri


Come abbiamo visto nel Capitolo 6, un iperparametro è un
parametro il cui valore viene scelto prima dell’inizio del processo di
apprendimento. Iniziamo con valori di buonsenso e in un secondo
momento proviamo a ottimizzarli. Per le reti neurali, gli iperparametri
importanti sono:
la funzione di attivazione;
il tasso di apprendimento;
il numero di layer nascosti;
il numero di neuroni in ogni layer nascosto.
Vediamo come possiamo definire un modello usando Keras.

Definire un modello in Keras


Sono tre i passaggi coinvolti nella definizione di un modello Keras.
1. Definire i layer. Possiamo costruire un modello in Keras in due
modi.
- Usando l’API Sequential: questo ci consente di progettare modelli
per uno stack lineare di layer. Viene utilizzata per modelli
relativamente semplici ed è la scelta abituale per la costruzione di
modelli:
Notate che qui abbiamo creato tre layer: i primi due usano la
funzione di attivazione ReLU e il terzo usa softmax.
- Usando l’API Functional: questo ci consente di progettare modelli
per grafi aciclici di layer e di creare modelli più complessi.

Notate che possiamo definire la stessa rete neurale usando l’API


Sequential o Functional. Dal punto di vista delle prestazioni, non fa

alcuna differenza quale approccio si adotta per definire il


modello.
2. Definire il processo di apprendimento. In questo passaggio,
definiamo tre cose:
- l’ottimizzatore;
- la funzione loss;
- le metriche che quantificano la qualità del modello.

Notate che usiamo la funzione model.compile() per definire


l’ottimizzatore, la funzione loss e le metriche.
3. Addestrare il modello. Una volta definita l’architettura, è il
momento di addestrare il modello:

Notate che i parametri come batch_size ed epochs sono


configurabili, ovvero sono iperparametri.

Scelta del modello sequenziale o funzionale


Il modello sequenziale crea la rete neurale come un semplice stack
di layer. Il modello sequenziale è facile e immediato da comprendere e
implementare, ma la sua architettura semplicistica presenta anche
limiti importanti. Ogni layer è connesso esattamente a un tensore di
input e output. Ciò significa che se il nostro modello ha più input o più
output in uno qualsiasi dei layer nascosti, non possiamo utilizzare un
modello sequenziale. In questo caso, dovremo utilizzare il modello
funzionale.

TensorFlow
TensorFlow è una delle librerie più utilizzate per lavorare con le reti
neurali. Nel paragrafo precedente, abbiamo visto come possiamo
usarlo come motore di backend di Keras. È una libreria open source ad
alte prestazioni che in realtà può essere utilizzata per qualsiasi calcolo
numerico. Se osserviamo lo stack, possiamo vedere che possiamo
scrivere codice TensorFlow in un linguaggio di alto livello come
Python o C++, che viene interpretato dal motore di esecuzione
distribuito TensorFlow. Questo rende TensorFlow piuttosto popolare
fra gli sviluppatori.
Il modo in cui funziona TensorFlow consiste nel creare un grafo
diretto per rappresentare il calcolo. I nodi sono collegati da archi,
ovvero gli input e gli output delle operazioni matematiche. Inoltre, essi
rappresentano array di dati.

Come funziona TensorFlow


Esaminiamo brevemente i concetti di fondo di TensorFlow come gli
scalari, i vettori e le matrici. Sappiamo che un numero semplice, come
3 o 5 , è chiamato valore scalare nella matematica tradizionale. Inoltre,
in fisica, un vettore è qualcosa che ha una grandezza e una direzione.
In termini di TensorFlow, usiamo un vettore per rappresentare un array
unidimensionale. Estendendo questo concetto, un array bidimensionale
è una matrice. Per un array tridimensionale, usiamo il termine tensore
3D. Il termine rango esprime la dimensionalità di una struttura di dati.
Pertanto, uno scalare è una struttura di dati di rango 0, un vettore è una
struttura di dati di rango 1 e una matrice è una struttura di dati di
rango 2. Queste strutture multidimensionali sono note come tensori.
Come possiamo vedere nella Figura 8.13, il rango definisce la
dimensionalità di un tensore.
Figura 8.13

Esaminiamo ora un altro parametro, shape. È una tupla di interi che


specifica la lunghezza di un array in ogni dimensione. La Figura 8.14
spiega il concetto di shape:

Figura 8.14

Usando shape e il rango, possiamo specificare i dettagli dei tensori.

La matematica dei tensori


Esaminiamo ora diversi calcoli matematici usando i tensori.
Definiamo due scalari e proviamo a sommarli e moltiplicarli usando
TensorFlow:
Possiamo sommarli e moltiplicarli e visualizzare i risultati:

Possiamo anche creare un nuovo tensore scalare sommando i due


tensori:

Possiamo anche svolgere funzioni complesse sui tensori:

I vari tipi di reti neurali


Le reti neurali possono essere costruite in vari modi. Se ogni
neurone in ogni layer è connesso a ciascuno dei neuroni di un altro
layer, allora parliamo di rete neurale densa o completamente connessa.
Esaminiamo altre forme di reti neurali.

Reti neurali convoluzionali


Le reti neurali convoluzionali (CNN, Convolution Neural Network)
vengono generalmente utilizzate per analizzare dati multimediali. Per
maggiori informazioni su come viene utilizzata una rete neurale
convoluzionale per analizzare i dati basati su immagini, dobbiamo
però presentare i seguenti due processi:
convoluzione;
pooling.

Convoluzione
Il processo di convoluzione esalta un pattern di interesse in una
determinata immagine elaborandolo con un’altra immagine più piccola
chiamata filtro (o anche kernel). Per esempio, se vogliamo trovare i
margini degli oggetti rappresentati in un’immagine, possiamo eseguire
una convoluzione dell’immagine con un filtro apposito. Il rilevamento
dei margini può aiutarci a identificare gli oggetti, a classificarli e può
essere utile in altre applicazioni. Quindi, il processo di convoluzione
consiste nel trovare le specifiche caratteristiche di un’immagine.
L’approccio alla ricerca di pattern si basa sulla ricerca di pattern che
possano essere riutilizzati su più dati differenti. I pattern riutilizzabili
sono chiamati filtri o kernel.

Pooling
Una parte importante dell’elaborazione dei dati multimediali ai fini
del machine learning è il downsampling, che offre due vantaggi:
riduce la dimensionalità complessiva del problema, diminuendo
notevolmente il tempo necessario per addestrare il modello;
grazie all’aggregazione, consente di astrarre i dettagli non
necessari nei dati multimediali, rendendoli più generici e più
rappresentativi di problemi simili.
Il downsampling è rappresentato nella Figura 8.15.

Figura 8.15

Notate che abbiamo sostituito ogni blocco di quattro pixel con un


solo pixel, scegliendo il valore maggiore dei quattro pixel “condensati”
in quell’unico pixel. Ciò significa che abbiamo eseguito il
downsampling di un fattore 4. Poiché abbiamo scelto il valore
massimo in ogni blocco, questo processo è chiamato max pooling.
Avremmo potuto scegliere il valore medio; in tal caso, si parlerebbe di
average pooling.

Reti neurali ricorrenti


Le reti neurali ricorrenti (RNN, Recurrent Neural Network) sono reti
neurali particolari, basate su un’architettura ciclica (per questo si
chiamano ricorrenti). L’aspetto importante da notare è che le reti
neurali ricorrenti hanno memoria. Ciò significa che hanno la capacità
di memorizzare informazioni dalle ultime iterazioni. Sono utilizzate in
aree come l’analisi delle strutture delle frasi, per prevedere la parola
successiva in una frase.

Reti generative avversarie


Le reti generative avversarie (GAN, Generative Adversarial
Network) sono rivolte alla generazione di dati sintetici. Sono state
create nel 2014 da Ian Goodfellow e dai suoi colleghi. Possono essere
utilizzate per generare sinteticamente fotografie di persone mai esistite.
Ancora più importante, vengono utilizzate per generare dati sintetici
per estendere i dataset di training.

Trasferimento dell’apprendimento
Nel corso degli anni, molte organizzazioni, gruppi di ricerca e
membri della comunità open source hanno perfezionato alcuni modelli
complessi, addestrati utilizzando enormi quantità di dati per casi d’uso
generici. In alcuni casi, hanno investito anni di sforzi
nell’ottimizzazione di questi modelli. Alcuni di questi modelli open
source possono essere utilizzati per le seguenti applicazioni:
rilevamento di oggetti nei video;
rilevamento di oggetti nelle immagini;
trascrizione dell’audio;
analisi del sentiment dei testi.
Ogni volta che iniziamo ad addestrare un nuovo modello di machine
learning, la domanda che ci poniamo è: invece di partire da zero, non
potremmo semplicemente personalizzare un modello già preaddestrato
e ben consolidato? In altre parole, possiamo trasferire l’apprendimento
dei modelli esistenti al nostro modello personalizzato, in modo da
poter rispondere alla nostra domanda? Se riusciamo a farlo, avremo tre
vantaggi:
i nostri sforzi di training del modello riceveranno una notevole
spinta in avanti;
utilizzando un modello ben collaudato e consolidato, è probabile
che la qualità complessiva del nostro modello migliori;
Se non disponiamo di dati sufficienti per il problema su cui
stiamo lavorando, un modello preaddestrato può essere di grande
aiuto.
Esaminiamo due esempi reali.
Quando si addestra un robot, potremmo prima usare un gioco di
simulazione per addestrare un modello a rete neurale. In quella
simulazione, potremmo creare tutti quegli eventi rari che
sarebbero molto difficili da ricreare nel mondo reale. Una volta
addestrata la rete neurale, potremmo trasferire l’apprendimento
per addestrare il modello fisico.
Supponiamo di voler addestrare un modello in grado di
classificare i laptop Apple e Windows da un feed video. Esistono
già modelli consolidati per il rilevamento di oggetti, disponibili
come open source e in grado di classificare con precisione vari
oggetti in un feed video. Possiamo usare questi modelli come
punto di partenza per addestrare un nostro modello a identificare
gli oggetti laptop. Una volta identificati gli oggetti come laptop,
possiamo addestrare ulteriormente il modello per distinguere fra
laptop Apple e Windows.
Nel prossimo paragrafo, applicheremo i concetti trattati in questo
capitolo alla costruzione di una rete neurale di classificazione di
documenti fraudolenti.
Caso di studio: utilizzo del deep
learning per il rilevamento delle
frodi
L’utilizzo di tecniche di machine learning per identificare documenti
fraudolenti è un campo di ricerca attivo e stimolante. I ricercatori
stanno studiando fino a che punto la capacità di riconoscimento dei
pattern delle reti neurali può essere sfruttato per questo scopo. Invece
di impiegare estrattori manuali di attributi, i pixel possono essere
utilizzati per informare diverse architetture di deep learning.

Metodologia
La tecnica presentata in questo paragrafo utilizza un’architettura a
rete neurale chiamata reti neurali siamesi: due rami condividono
architetture e parametri identici. L’uso di reti neurali siamesi per
contrassegnare i documenti fraudolenti è rappresentato nella Figura
8.16.

Figura 8.16
Quando è necessario verificare l’autenticità di un determinato
documento, prima classifichiamo il documento in base al suo layout e
tipo; poi lo confrontiamo con i modelli e i pattern previsti. Se devia
oltre una certa soglia, viene segnalato come falso; in caso contrario, è
considerato un documento autentico, veritiero. Per casi d’uso critici,
possiamo aggiungere un processo manuale nei casi in cui l’algoritmo
non riesce a classificare in modo definitivo il documento come
autentico o falso.
Per confrontare un documento con il modello previsto, utilizziamo
due reti neurali convoluzionali identiche con un’architettura siamese.
Le reti neurali convoluzionali hanno il vantaggio di individuare feature
locali invarianti ottimali e possono costruire rappresentazioni resistenti
alle distorsioni geometriche dell’immagine fornita in input. Si tratta
quindi di un algoritmo adatto a questo problema, poiché puntiamo a far
passare i documenti autentici e i documenti sotto test attraverso una
singola rete, e quindi confrontare i risultati alla ricerca di somiglianze.
Per raggiungere questo obiettivo, implementiamo i seguenti passaggi.
Supponiamo di voler sottoporre a test un documento. Per ogni classe
di documento, eseguiamo questi passaggi.
1. Leggere l’immagine archiviata del documento autentico. Lo
chiamiamo documento autentico. Il documento sotto test
dovrebbe avere l’aspetto del documento autentico.
2. Il documento autentico viene fatto passare attraverso i layer della
rete neurale per creare un vettore delle feature, che è la
rappresentazione matematica dei pattern del documento autentico.
È il vettore di feature 1 della Figura 8.16.
3. Il documento di cui dobbiamo verificare l’autenticità è chiamato
documento sotto test. Facciamo passare questo documento
attraverso una rete neurale simile a quella utilizzata per creare il
vettore delle feature del documento autentico. Il vettore delle
feature del documento sotto test è chiamato vettore delle feature
2.
4. Usiamo la distanza euclidea fra i due vettori delle feature per
calcolare la somiglianza fra il documento autentico e il
documento sotto test. Questo punteggio di somiglianza è
chiamato Measure of Similarity (MOS) ed è un numero compreso
fra 0 e 1. Un punteggio più alto rappresenta una minore distanza
fra i documenti e quindi una maggiore probabilità che i
documenti siano simili.
5. Se il punteggio di somiglianza calcolato dalla rete neurale è
inferiore a una certa soglia predefinita, contrassegniamo il
documento come fraudolento.
Vediamo come possiamo implementare in Python queste reti neurali
siamesi.
1. Per prima cosa, importiamo i pacchetti Python richiesti:
import random
import numpy as np

import tensorflow as tf

2. Successivamente, definiamo la rete neurale che verrà utilizzata


per elaborare ciascuno dei rami della rete siamese:
def createTemplate():
return tf.keras.models.Sequential([
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(128, activation = 'relu'),
tf.keras.layers.Dropout(0.15),
tf.keras.layers.Dense(128, activation = 'relu'),
tf.keras.layers.Dropout(0.15),
tf.keras.layers.Dense(64, activation = 'relu'),

])

Notate che, al fine di contenere l’overfitting, ovvero il


sovradattamento), abbiamo anche specificato un tasso Dropout pari
a 0.15.
3. Per implementare le reti siamesi, utilizzeremo le immagini
MNIST (Modified National Institute of Standards and
Technology), ideali per sottoporre a test l’efficacia del nostro
approccio. Questo prevede la preparazione dei dati in modo tale
che ogni campione abbia due immagini e un flag di somiglianza
binaria, che è un indicatore che le due immagini appartengono
alla stessa classe. Ora implementiamo la funzione prepareData(),
per preparare i dati:
def prepareData(inputs: np.ndarray, labels: np.ndarray):
classesNumbers = 10
digitalIdx = [np.where(labels == i)[0] for i in
range(classesNumbers)]
pairs = list()
labels = list()
n = min([len(digitalIdx[d]) for d in range(classesNumbers)]) – 1
for d in range(classesNumbers):
for i in range(n):
z1, z2 = digitalIdx[d][i], digitalIdx[d][i + 1]
pairs += [[inputs[z1], inputs[z2]]]
inc = random.randrange(1, classesNumbers)
dn = (d + inc) % classesNumbers
z1, z2 = digitalIdx[d][i], digitalIdx[dn][i]
pairs += [[inputs[z1], inputs[z2]]]
labels += [1, 0]

return np.array(pairs), np.array(labels, dtype = np.float32)

Notate che prepareData() produrrà un numero uguale di campioni


per tutte le cifre.
4. Prepariamo i dataset di training e di test:
(x_train, y_train), (x_test, y_test) =
tf.keras.datasets.mnist.load_data()
x_train = x_train.astype(np.float32)
x_test = x_test.astype(np.float32)
x_train /= 255
x_test /= 255
input_shape = x_train.shape[1:]
train_pairs, tr_labels = prepareData(x_train, y_train)

test_pairs, test_labels = prepareData(x_test, y_test)

5. Ora creiamo le due metà del sistema siamese:


input_a = tf.keras.layers.Input(shape =input_shape)
enconder1 = base_network(input_a)
input_b = tf.keras.layers.Input(shape =input_shape)

enconder2 = base_network(input_b)

6. Quindi implementiamo la misura di similarità che quantificherà la


distanza fra due documenti che vogliamo confrontare:
distance = tf.keras.layers.Lambda(lambda embeddings:
tf.keras.backend.abs(embeddings[0] – embeddings[1]))

([enconder1, enconder2])

measureOfSimilarity = tf.keras.layers.Dense(1, activation = 'sigmoid')


(distance)

Infine, addestriamo il modello, in 10 “epoche” (epochs ):


= 10

Notate che abbiamo raggiunto una precisione del 97,49%


utilizzando 10 epoche. Un aumento del numero di epoche migliorerà
ulteriormente il livello di precisione.

Riepilogo
In questo capitolo, abbiamo prima esaminato i dettagli delle reti
neurali. Abbiamo iniziato osservando come le reti neurali si sono
evolute nel corso degli anni. Abbiamo studiato diversi tipi di reti
neurali, esaminando i vari elementi che le costituiscono. Abbiamo
studiato in modo approfondito l’algoritmo di discesa del gradiente, che
viene normalmente utilizzato per addestrare le reti neurali. Abbiamo
poi trattato varie funzioni di attivazione e studiato la loro applicazione
in una rete neurale. Abbiamo anche accennato al concetto di
trasferimento dell’apprendimento. Infine, abbiamo esaminato un
esempio concreto di come una rete neurale può essere utilizzata per
addestrare un modello di machine learning che potrà poi essere
impiegato per contrassegnare documenti contraffatti o fraudolenti.
Nel prossimo capitolo, esamineremo come possiamo usare tali
algoritmi per l’elaborazione del linguaggio naturale. Introdurremo
anche il concetto di word embedding ed esamineremo l’uso di reti
ricorrenti per l’elaborazione del linguaggio naturale. Infine,
scopriremo come si implementa l’analisi del sentiment.
Capitolo 9

Algoritmi per l’elaborazione del


linguaggio naturale

Questo capitolo introduce gli algoritmi per l’elaborazione del


linguaggio naturale (NLP, Natural Language Processing). Passeremo
progressivamente dagli aspetti teorici a quelli pratici. In primo luogo,
esamineremo i fondamenti dell’elaborazione del linguaggio naturale,
poi passeremo agli algoritmi di base. Quindi, osserveremo una delle
reti neurali più utilizzate per progettare e implementare soluzioni per
importanti casi d’uso relativi a dati testuali. Esamineremo quindi i
limiti dell’elaborazione del linguaggio naturale, prima di apprendere
finalmente come possiamo utilizzarla per addestrare un modello di
machine learning in grado di prevedere la polarità delle recensioni dei
film.
Alla fine di questo capitolo, avrete appreso le tecniche di base
utilizzate per l’elaborazione del linguaggio naturale e sarete in grado di
capire come può essere utilizzata per risolvere alcuni interessanti
problemi concreti.
Partiamo dai concetti di base.

Introduzione all’elaborazione del


linguaggio naturale
L’elaborazione del linguaggio naturale viene utilizzata per indagare
le metodologie in grado di formalizzare e formulare le interazioni fra
computer e linguaggi umani (naturali). L’elaborazione del linguaggio
naturale è una materia assai ampia e implica l’utilizzo di algoritmi di
linguistica informatica e tecnologie e metodologie di interazione
uomo-computer, per elaborare dati complessi e non strutturati.
L’elaborazione del linguaggio naturale può essere utilizzata per una
varietà di casi, inclusi i seguenti.
Identificazione dell’argomento: per scoprire gli argomenti in un
insieme di testi e classificare i documenti in base agli argomenti
individuati.
Analisi del sentiment: per classificare i testi in base ai sentiment
positivi o negativi che contiene.
Traduzione automatica: per tradurre il testo da una lingua a
un’altra.
Sintesi vocale: per convertire le parole pronunciate in testo scritto.
Interpretazione soggettiva: interpretare in modo intelligente una
domanda e rispondere utilizzando le informazioni disponibili.
Riconoscimento di entità: per identificare le entità (una persona,
un luogo o un oggetto) di cui parla il testo.
Rilevamento di notizie false: per contrassegnare le notizie false in
base al contenuto.
Iniziamo esaminando alcuni dei termini utilizzati nell’elaborazione
del linguaggio naturale.

La terminologia dell’elaborazione del


linguaggio naturale
L’elaborazione del linguaggio naturale è un argomento molto ampio.
Nella letteratura che tratta un certo argomento, osserviamo che, a
volte, vengono usati più termini per specificare la stessa cosa.
Inizieremo esaminando alcuni dei termini di base dell’elaborazione del
linguaggio naturale. Iniziamo con la normalizzazione, che è uno dei
tipi fondamentali di elaborazione del linguaggio naturale, solitamente
eseguita sui dati di input.

Normalizzazione
La normalizzazione viene eseguita sui dati testuali di input per
migliorarne la qualità nel contesto dell’addestramento, o training, di un
modello di machine learning. La normalizzazione di solito prevede le
seguenti fasi di elaborazione:
conversione di tutto il testo in lettere maiuscole o minuscole;
rimozione della punteggiatura;
rimozione di numeri.
Notate che sebbene queste fasi di elaborazione siano in genere
necessarie, quelle effettivamente applicate dipendono dal problema da
risolvere e variano da caso a caso. Per esempio, se i numeri nel testo
rappresentassero qualcosa che potrebbe avere un certo valore nel
contesto del problema che stiamo cercando di risolvere, allora sarebbe
meglio non rimuoverli dal testo nella fase di normalizzazione.

Corpus
Il gruppo di documenti di input che stiamo utilizzando per risolvere
il problema in questione è chiamato corpus. Il corpus costituisce i dati
di input per il problema.

Tokenizzazione
Nell’elaborazione del linguaggio naturale, il primo compito è quello
di suddividere il testo in una lista di token, un processo chiamato
tokenizzazione. La granularità dei token risultanti varierà in base
all’obiettivo. Per esempio, ogni token può essere costituito da quanto
segue:
una parola;
una combinazione di parole;
una frase;
un paragrafo.

Riconoscimento delle entità denominate


Nell’elaborazione del linguaggio naturale, molto spesso occorre
identificare, fra i dati non strutturati, determinate parole e numeri come
appartenenti a determinate categorie (numeri di telefono, codici
postali, nomi, luoghi o paesi). Questo permette di fornire una struttura
a dati non strutturati. Questo processo è chiamato riconoscimento delle
entità denominate (NER, Named Entity Recognition).

Stopword
Dopo la tokenizzazione a livello delle parole, abbiamo una lista di
parole utilizzate nel testo. Alcune di queste sono parole comuni,
presenti in quasi tutti i documenti, che non forniscono vere
informazioni. Queste parole sono chiamate stopword. Solitamente
vengono rimosse nella fase di elaborazione dei dati. Alcuni esempi di
stopword inglesi sono “was”, “we” e “the”.

Analisi del sentiment


L’analisi del sentiment, o opinion mining, è il processo di estrazione
dal testo del sentiment negativo o positivo.

Stemming e lemmi
Nei dati testuali, è probabile che la maggior parte delle parole sia
presente in forme leggermente variate. Ridurre ogni parola alla sua
origine o radice in una famiglia di parole si chiama stemming. Questo
processo viene utilizzato per raggruppare le parole in base al loro
significato di fondo, per ridurre il numero totale di parole che devono
essere analizzate. In sostanza, lo stemming riduce la condizionalità
complessiva del problema.
Un esempio in inglese: {use, used, using, uss} => use.
L’algoritmo più utilizzato per lo stemming della lingua inglese è
l’algoritmo di Porter.
La stemming è un processo rozzo, che può portare a tagliare le
estremità delle parole, creando parole errate. Per molti casi d’uso, ogni
parola è solo un identificatore di un livello nello spazio del problema e
le parole errate non contano. Se sono richieste parole scritte
correttamente, allora dovrebbe essere usata la lemmatizzazione invece
dello stemming.
NOTA
Gli algoritmi non sono dotati di buon senso. Per il cervello umano, trattare allo
stesso modo parole simili è semplice. Al contrario, un algoritmo deve essere
guidato da speciali criteri di raggruppamento.

Fondamentalmente, ci sono tre modi diversi di implementare


l’elaborazione del linguaggio naturale: tre tecniche differenti in termini
di sofisticatezza.
Elaborazione del linguaggio naturale BoW-based (Bag-of-Words).
Classificatori tradizionali per l’elaborazione del linguaggio
naturale.
Utilizzo del deep learning per l’elaborazione del linguaggio
naturale.

NLTK
Il Natural Language Toolkit (NLTK) è il pacchetto più utilizzato per
la gestione delle attività di elaborazione del linguaggio naturale in
Python. NLTK è una delle librerie Python più antiche e utilizzate per
l’elaborazione del linguaggio naturale. NLTK è però ottimo, perché
fornisce un punto di partenza per la creazione di qualsiasi processo di
elaborazione del linguaggio naturale, fornendo tutti gli strumenti di
base, che potete concatenare per raggiungere l’obiettivo, senza dover
partire da zero. NLTK fornisce molti strumenti e, nel prossimo
paragrafo, scaricheremo il pacchetto ed esploreremo alcuni di essi.
Parliamo ora dell’elaborazione del linguaggio naturale BoW-based.

Elaborazione del linguaggio


naturale BoW-based
La rappresentazione del testo di input come un pacchetto di token è
chiamata elaborazione BoW-based. Lo svantaggio dell’utilizzo
dell’approccio Bag-of-Words è che scartiamo la maggior parte della
grammatica e della tokenizzazione, il che a volte porta a perdere il
contesto delle parole. Nell’approccio Bag-of-Words, quantifichiamo
innanzitutto l’importanza di ogni parola nel contesto di ciascun
documento che vogliamo analizzare.
Fondamentalmente, ci sono tre modi per quantificare l’importanza
delle parole nel contesto di ciascun documento.
Binario: una feature avrà valore 1 se la parola appare nel testo e 0
in caso contrario.
Conteggio: una feature avrà valore pari al numero di volte in cui
la parola appare nel testo e 0 se non appare.
Frequenza termine /Frequenza inversa nei documenti: il valore
della feature sarà il rapporto fra l’unicità di una parola in un
documento e l’unicità nell’intero corpus di documenti.
Ovviamente, per parole comuni come, in inglese, “the”, “in” e
così via (note come stop-word), il punteggio Frequenza termine /
Frequenza inversa nei documenti sarà basso. Per parole più
uniche, per esempio, termini specifici del dominio, il punteggio
sarà più alto.
Notate che usando l’approccio Bag-of-Words, stiamo buttando via
informazioni, vale a dire l’ordine delle parole nel nostro testo. Questo
spesso funziona, ma può ridurre la precisione.
Vediamo un esempio specifico. Addestreremo un modello in grado
di classificare le recensioni di un ristorante fra quelle negative e quelle
positive. Il file di input è un file strutturato in cui le recensioni
dovranno essere classificate come positive o negative.
Per farlo, elaboriamo innanzitutto i dati di input.
Le fasi di lavorazione sono definite nella Figura 9.1.
Figura 9.1

Implementiamo questa pipeline di elaborazione tramite i seguenti


passaggi.
1. Innanzitutto, importiamo i pacchetti di cui abbiamo bisogno:
import numpy as np

import pandas as pd

2. Quindi importiamo il dataset da un file CSV:


3. Successivamente, ripuliamo i dati:
# Pulizia dei testi
import re
import nltk
nltk.download('stopwords')
from nltk.corpus import stopwords
from nltk.stem.porter import PorterStemmer
corpus = []
for i in range(0, 1000):
review = re.sub('[^a-zA-Z]', ' ', dataset['Review'][i])
review = review.lower()
review = review.split()
ps = PorterStemmer()
review = [ps.stem(word) for word in review if not word in
set(stopwords.words('english'))]
review = ' '.join(review)

corpus.append(review)

4. Ora definiamo le feature (rappresentate da y) e le etichette


(rappresentate da X):
from sklearn.feature_extraction.text import CountVectorizer
cv = CountVectorizer(max_features = 1500)
X = cv.fit_transform(corpus).toarray()

y = dataset.iloc[:, 1].values

5. Dividiamo i dati nei dataset di training e di test:


from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size =


0.20, random_state = 0)

6. Per addestrare il modello, impieghiamo l’algoritmo naive Bayes:


from sklearn.naive_bayes import GaussianNB
classifier = GaussianNB()

classifier.fit(X_train, y_train)

7. Prevediamo i risultati del dataset di test:


y_pred = classifier.predict(X_test)
8. La matrice di confusione si presenta così:

Osservando la matrice di confusione, possiamo stimare gli errori di


classificazione.

Introduzione al word embedding


Nel paragrafo precedente, abbiamo scoperto come possiamo
eseguire l’elaborazione del linguaggio naturale utilizzando l’approccio
Bag-of-Words per l’astrazione dei dati testuali di input. Uno dei
principali progressi dell’elaborazione del linguaggio naturale è la
nostra capacità di creare una rappresentazione numerica delle parole
sotto forma di vettori densi. Questa tecnica è chiamata word
embedding. Yoshua Bengio ha introdotto per la prima volta il termine
nel suo articolo A Neural Probabilistic Language Model. Ogni parola,
in un problema di elaborazione del linguaggio naturale, può essere
considerata un oggetto categorico. Il word embedding consiste nella
mappatura di ciascuna delle parole su una lista di numeri,
rappresentata in forma vettoriale. In altre parole, si tratta di
metodologie utilizzate per convertire le parole in numeri reali. Una
caratteristica distintiva del word embedding è che utilizza un vettore
denso, invece di utilizzare approcci tradizionali che utilizzano vettori a
matrice sparsa.
Ci sono fondamentalmente due problemi con l’utilizzo
dell’approccio Bag-of-Words per l’elaborazione del linguaggio
naturale.
Perdita del contesto semantico: quando tokenizziamo i dati,
perdiamo il loro contesto. Una parola può avere significati
differenti in base a dove è usata nella frase; questo è ancora più
importante quando si devono interpretare espressioni umane
complesse, come l’umorismo o la satira.
Input sparso: quando tokenizziamo, ogni parola diventa una
feature e ciò produce strutture di dati sparse.

L’intorno di una parola


Un suggerimento chiave su come presentare i dati testuali (in
particolare, le singole parole o i lemmi) a un algoritmo viene dalla
linguistica. Nel word embedding, prestiamo attenzione all’intorno di
ogni parola e lo usiamo per determinarne il significato e l’importanza.
L’intorno di una parola è l’insieme delle parole che la circondano e che
quindi ne determinano il contesto.
Notate che nell’approccio Bag-of-Words, una parola perde il proprio
contesto, poiché perde l’intorno in cui è collocata.

Proprietà dei word embedding


I migliori word embedding presentano le seguenti quattro proprietà.
Sono densi. I word embedding sono essenzialmente modelli di
fattori. In quanto tali, ogni componente del vettore di embedding
rappresenta una quantità di una feature (latente). Di solito non
sappiamo che cosa rappresenti quella feature; tuttavia, avremo
pochissimi, o nessuno, valori a zero, che causerebbero un input
sparso.
Sono a bassa dimensionalità. Un embedding ha una
dimensionalità predefinita (scelta tramite un iperparametro).
Abbiamo visto in precedenza che nella rappresentazione Bag-of-
Words avevamo bisogno di |V| input per ogni parola, così che la
dimensione totale dell’input diventava |V| × n dove n è il numero
di parole che usiamo come input. Con il word embedding, la
nostra dimensione di input sarà d × n, dove d è tipicamente
compreso fra 50 e 300. Considerando il fatto che i corpora di testo
di grandi dimensioni sono spesso molto più grandi di 300 parole,
ciò significa che ricaviamo un grande risparmio nella dimensione
di input, che può portare a una migliore precisione per un numero
totale inferiore di istanze dei dati.
Incorporano la semantica del dominio. Wuesta proprietà è
probabilmente la più sorprendente, ma anche la più utile. Se
adeguatamente addestrati, i word embedding apprendono il
significato del loro dominio.
Sono facilmente generalizzabili. Il word embedding è in grado di
raggruppare modelli astratti generalizzati. Per esempio, possiamo
concentrare l’addestramento su (i word embedding di) gatti, cervi,
cani e così via, e il modello capirà che intendiamo considerare
animali. Notate che il modello non sarà stato addestrato per le
pecore, eppure le classificherà comunque correttamente.
Utilizzando il word embedding, possiamo aspettarci di ricevere la
risposta corretta.
Ora esploriamo l’uso delle reti neurali ricorrenti per l’elaborazione
del linguaggio naturale.

Utilizzo delle reti neurali ricorrenti


per l’elaborazione del linguaggio
naturale
Una rete neurale ricorrente è una rete tradizionale, “in avanti”, con
un feedback. Un modo semplice di immaginare una rete neurale
ricorrente è pensare a una rete neurale a stati (Figura 9.2). Le reti
neurali ricorrenti vengono utilizzate con dati di qualsiasi tipo per
generare e prevedere varie sequenze di dati. L’addestramento di un
modello a rete neurale ricorrente riguarda la formulazione di queste
sequenze di dati. Le reti neurali ricorrenti possono essere utilizzate
anche per i dati testuali, poiché le frasi sono solo sequenze di parole.
Le reti neurali ricorrenti possono essere usate nel campo
dell’elaborazione del linguaggio naturale, per i seguenti scopi:
prevedere la parola successiva durante la digitazione;
generare nuovi testi, in base allo stile già utilizzato nel testo.

Figura 9.2

Il processo di apprendimento delle reti neurali ricorrenti si basa sul


testo presente nel corpus: vengono addestrate riducendo l’errore fra la
parola successiva prevista e quella effettiva.
Utilizzo dell’elaborazione del
linguaggio naturale per l’analisi
del sentiment
L’approccio presentato in questo paragrafo si basa sul caso d’uso
della classificazione di un gran numero di tweet in un flusso di input. Il
compito è quello di estrarre il sentiment presente nei tweet su un
argomento scelto. La classificazione del sentiment quantifica in tempo
reale la polarità di ogni tweet e quindi aggrega i sentiment totali di tutti
i tweet per catturare i sentiment complessivi sull’argomento scelto.
Per affrontare le sfide poste dai contenuti e dal comportamento dei
dati in streaming di Twitter ed eseguire in modo efficiente l’analisi in
tempo reale, impieghiamo l’elaborazione del linguaggio naturale con
un classificatore addestrato. Questo verrà poi collegato al flusso di
Twitter per determinare la polarità (positiva, negativa o neutra) di
ciascun tweet. Ciò sarà seguito da una fase di aggregazione, per
determinare la polarità complessiva di tutti i tweet su un determinato
argomento. Vediamo come operare, passo dopo passo.
Per prima cosa, dobbiamo addestrare il classificatore. Per farlo,
avevamo bisogno di un dataset già preparato, che contenga dati storici
di Twitter e segua i modelli e le tendenze dei dati in tempo reale.
Pertanto, abbiamo utilizzato un dataset offerto dal sito web
http://www.sentiment140.com, contenente un corpus etichettato a mano (una

vasta raccolta di testi su cui si basa l’analisi) con oltre 1,6 milioni di
tweet. I tweet di questo dataset sono stati etichettati con una delle tre
polarità: 0 per negativo, 2 per neutro e 4 per positivo. Oltre al testo del
tweet, il corpus fornisce l’ID del tweet, la data, il flag e l’autore del
tweet. Ora vediamo ciascuna delle operazioni da eseguire sui tweet per
ottenere un classificatore addestrato.
1. I tweet vengono prima suddivisi in singoli token (tokenizzazione).
2. L’output della tokenizzazione crea un Bag-of-Words, ovvero una
collezione di parole presenti nel testo.
3. I tweet vengono ulteriormente filtrati rimuovendo numeri,
punteggiatura e stop-word. Come ormai sappiamo, le stop-word
sono parole estremamente comuni, come “is”, “am”, “are” e
“the”. Poiché non contengono informazioni aggiuntive, vanno
rimosse.
4. Inoltre, i caratteri non alfabetici, come “#”, “@” e i numeri,
vanno rimossi utilizzando il pattern matching, poiché non hanno
alcuna rilevanza per l’analisi del sentiment. Per considerare solo i
caratteri alfabetici e ignorare il resto vengono impiegate
espressioni regolari. Questo aiuta a ridurre il disordine dal flusso
di Twitter.
5. Gli esiti della fase precedente vengono portati alla fase di
stemming. In questa fase le parole derivate vengono ridotte alla
loro radice. Per esempio, la parola “fisher” ha la stessa radice di
“fishing” e “fishes”. Per questo utilizziamo la libreria standard di
elaborazione del linguaggio naturale, che fornisce vari algoritmi,
come lo stemming di Porter.
6. Una volta elaborati, i dati vengono convertiti in una matrice dei
termini del documento (TDM, Term Document Matrix). Tale
matrice rappresenta il termine e la sua frequenza nel corpus
filtrato.
7. Dalla matrice precedente, il tweet raggiunge il classificatore
addestrato (essendo addestrato, sa come elaborare i tweet), che
calcola l’importanza della polarità del sentiment (SPI, Sentiment,
Polarity Importance) di ogni parola con un valore da -5 a +5. Il
segno, negativo o positivo, specifica il tipo di emozioni
rappresentate da quella particolare parola; la grandezza
rappresenta la forza del sentiment. Ciò significa che il tweet può
essere classificato come negativo o positivo (vedi Figura 9.3).
Una volta calcolata la polarità dei singoli tweet, sommiamo il
valore SPI complessivo per trovare il sentiment aggregato: per
esempio, una polarità complessiva maggiore di 1 indica che il
sentiment aggregato dei tweet nell’arco di tempo osservato è
positivo.
NOTA
Per recuperare i tweet in tempo reale, usiamo la libreria Scala
Twitter4J, una libreria Java che offre un pacchetto per un’API di
streaming in tempo reale dei tweet. L’API richiede all’utente di
registrare un account da sviluppatore in Twitter e di compilare alcuni
parametri di autenticazione. Questa API consente poi di ottenere tweet
casuali o di filtrare i tweet utilizzando parole chiave a scelta. Per
recuperare i tweet relativi alle parole chiave scelte abbiamo utilizzato
appositi filtri.

Figura 9.3
L’analisi del sentiment ha diverse applicazioni. Può essere impiegata
per classificare il feedback dei clienti. L’analisi della polarità dei social
media può essere utilizzata dai governi per valutare l’efficacia delle
loro politiche. Può anche quantificare il successo delle campagne
pubblicitarie.

Caso di studio: analisi del


sentiment nelle recensioni di film
Usiamo l’elaborazione del linguaggio naturale per condurre
un’analisi del sentiment nelle recensioni di film. Per farlo, utilizzeremo
alcuni dati di recensioni open source disponibili su
http://www.cs.cornell.edu/people/pabo/movie-review-data:

1. Innanzitutto, importiamo le librerie:


import numpy as np

import pandas as pd

2. Ora carichiamo i dati delle recensioni dei film e mostriamo le


prime righe per osservarne la struttura:
df = pd.read_csv("moviereviews.tsv", sep = '\t')

df.head()
Tenete presente che il dataset contiene 2000 recensioni di film. Di
queste, metà sono negative e metà positive.
3. Ora iniziamo a preparare il dataset per l’addestramento del
modello. Innanzitutto, eliminiamo dai dati tutti i valori mancanti:
df.dropna(inplace = True)

4. Ora dobbiamo rimuovere anche gli spazi vuoti. Per farlo,


dobbiamo iterare su ogni riga nel DataFrame di input. Useremo
.iteruples() per accedere a ogni campo:
blanks = []
for i, lb, rv in df.itertuples():
if rv.isspace():
blanks.append(i)

df.drop(blanks, inplace = True)

Notate che abbiamo usato i, lb e rv per le colonne index, label e


review.

Dividiamo i dati nei dataset di training e di test.


1. Il primo passaggio consiste nello specificare le feature e le
etichette, quindi suddividere i dati nei dataset di training e di test.
from sklearn.model_selection import train_test_split
X = df['review']
y = df['label']

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size =


0.3, random_state = 42)

2. Dividiamo i set in train and test:


from sklearn.pipeline import Pipeline

from sklearn.feature_extraction.text import TfidfVectorizer

from sklearn.naive_bayes import MultinomialNB


# Naïve Bayes:
text_clf_nb = Pipeline([('tfidf', TfidfVectorizer()),
('clf', MultinomialNB()),

])

Notate che stiamo usando tfidf per quantificare l’importanza di


un punto dei dati in una collezione.
Quindi, addestriamo il modello utilizzando l’algoritmo naive Bayes,
e sottoponiamo a test il modello addestrato.
1. Addestriamo il modello utilizzando i dataset di training e di test
che abbiamo creato:
text_clf_nb.fit(X_train, y_train)

2. Chiediamo le previsioni e analizziamo i risultati:


# Un insieme di predizioni

predictions = text_clf_nb.predict(X_test)

Esaminiamo infine le prestazioni del nostro modello mostrando la


sua matrice di confusione. Esamineremo anche la precisione, il
richiamo, il punteggio f1 e l’accuratezza.

Queste metriche prestazionali ci danno una misura della qualità


delle previsioni. Con un’accuratezza del 78%, ora abbiamo addestrato
con successo un modello in grado di prevedere il tipo di recensione per
quel particolare film.

Riepilogo
In questo capitolo abbiamo trattato gli algoritmi di elaborazione del
linguaggio naturale. Innanzitutto, abbiamo esaminato la terminologia
relativa all’argomento. Successivamente, abbiamo esaminato la
metodologia Bag-of-Words per implementare una strategia di
elaborazione del linguaggio naturale. Poi abbiamo esaminato il
concetto di word embedding e l’uso delle reti neurali nell’elaborazione
del linguaggio naturale. Infine, abbiamo esaminato un esempio reale,
in cui abbiamo utilizzato i concetti sviluppati in questo capitolo per
prevedere il sentiment delle recensioni dei film partendo dai loro testi.
Con i concetti esposti i questo capitolo, dovreste essere in grado di
utilizzare l’elaborazione del linguaggio naturale per classificare i testi
e per svolgere analisi del sentiment.
Nel prossimo capitolo, esamineremo i motori di raccomandazione.
Studieremo diversi tipi di motori e come possono essere utilizzati per
risolvere alcuni problemi concreti.
Capitolo 10

Motori di raccomandazione

I motori di raccomandazione sono un modo per utilizzare le


informazioni disponibili sulle preferenze dell’utente e sui dettagli dei
prodotti per fornire consigli informati. L’obiettivo di un motore di
raccomandazione è quello di scoprire i pattern delle somiglianze fra un
insieme di articoli e/o di formulare le interazioni fra gli utenti e gli
articoli.
Questo capitolo si apre con la presentazione delle basi dei motori di
raccomandazione e poi discute dei vari tipi di motori di
raccomandazione. Quindi mostra come vengono utilizzati i motori di
raccomandazione per suggerire articoli e prodotti ai vari utenti e i vari
limiti dei motori di raccomandazione. Infine, impareremo a utilizzare i
motori di raccomandazione per risolvere un problema concreto.
Alla fine di questo capitolo, dovreste essere in grado di utilizzare i
motori di raccomandazione per suggerire vari articoli in base ad alcuni
criteri di preferenza.

Introduzione ai sistemi di
raccomandazione
I sistemi di raccomandazione sono metodi sviluppati dai ricercatori
per prevedere gli articoli cui è più probabile che un utente sia
interessato. La capacità dei sistemi di raccomandazione di fornire
suggerimenti personalizzati sugli articoli li rende forse la tecnologia
più importante nel contesto del mondo degli acquisti online.
Quando vengono utilizzati nelle applicazioni di e-commerce, i
motori di raccomandazione impiegano algoritmi sofisticati per
migliorare l’esperienza d’acquisto degli acquirenti e per consentire ai
fornitori di servizi di personalizzare i prodotti in base alle preferenze
degli utenti.
NOTA
Nel 2009, Netflix ha offerto un milione di dollari a chiunque fosse in grado di
fornire un algoritmo in grado di migliorare il suo motore di raccomandazione
(Cinematch) di oltre il 10%. Il premio è stato vinto dal team Pragmatic Chaos di
BellKor.

Tipi di motori di raccomandazione


In generale, esistono tre diversi tipi di motori di raccomandazione:
motori di raccomandazione basati sui contenuti;
motori di filtraggio collaborativi;
motori di raccomandazione ibridi.

Motori di raccomandazione basati sui


contenuti
L’idea di base di un motore di raccomandazione basato sui contenuti
è quella di suggerire articoli simili a quelli per i quali l’utente ha
mostrato interesse in precedenza. L’efficacia dei motori di
raccomandazione basati sui contenuti dipende dalla nostra capacità di
quantificare la somiglianza fra gli articoli.
Esaminiamo la Figura 10.1. Se l’utente ha letto il documento 1,
possiamo consigliargli il documento 2, che è simile al documento 1.
Figura 10.1

Ora, il problema diventa come determinare quali articoli sono simili


fra loro. Esaminiamo un paio di metodi per trovare le somiglianze fra
più articoli.

Trovare le somiglianze fra documenti non strutturati


Un modo per determinare le somiglianze fra documenti consiste
nell’elaborare i documenti di input. La struttura di dati risultante dopo
l’elaborazione di documenti non strutturati è denominata matrice
termine-documento (TDM, Term Document Matrix), di cui vediamo un
esempio nella Figura 10.2.
Figura 10.2

Una matrice termine-documento contiene tutto il glossario di parole


(come righe) e tutti i documenti (come colonne). Può essere utilizzata
per stabilire quali documenti sono simili a quali altri documenti in base
alla misura della distanza selezionata. Google News, per esempio,
suggerisce una notizia a un utente in base alla somiglianza con una
notizia per la quale l’utente ha già mostrato interesse.
Una volta che abbiamo una matrice termine-documento, ci sono due
modi per quantificare la somiglianza fra i documenti.
Usando il conteggio della frequenza. Ciò significa che
supponiamo che l’importanza di una parola sia direttamente
proporzionale alla sua frequenza. Questo è il modo più semplice
per calcolare l’importanza.
Usando il valore TF-IDF (abbreviazione di Term Frequency-
Inverse Document Frequency). Questo è un numero che calcola
l’importanza di ogni parola nel contesto del problema che stiamo
cercando di risolvere. È un multiplo di due termini.
- Frequenza del termine (TF): è il numero di volte in cui una
parola o un termine appare in un documento. La frequenza è
direttamente correlata all’importanza della parola.
- Inversa della frequenza dei documenti (IDF): innanzitutto, la
frequenza dei documenti (DF) è il numero di documenti
contenenti il termine che stiamo cercando. Essendo l’inversa di
DF, la IDF ci dà la misura dell’unicità di una parola e la mette in
relazione con la sua importanza.
Poiché TF e IDF quantificano entrambi l’importanza di una
parola nel contesto del problema che stiamo cercando di risolvere,
la loro combinazione, TF-IDF, misura l’importanza di ogni parola
e rappresenta un’alternativa più sofisticata all’utilizzo del
semplice conteggio della frequenza.

Utilizzo di una matrice di co-occorrenza


Questo metodo si basa sul presupposto che se due articoli vengono
acquistati insieme la maggior parte delle volte, è probabile che siano
simili o almeno appartengano a una categoria di articoli che, di solito,
vengono acquistati insieme.
Per esempio, se le persone acquistano spesso insieme schiuma da
barba e rasoi, allora ha senso suggerire a chi compra un rasoio anche la
schiuma da barba.
Analizziamo i pattern di acquisto storici dei seguenti quattro utenti.
Rasoi Mele Schiuma da barba Bici Hummus
Mike 1 1 1 0 1
Taylor 1 0 1 1 1
Elena 0 0 0 1 0
Ammina 1 0 1 0 0

Otterremo la seguente matrice di co-occorrenza.


Rasoi Mele Schiuma da barba Bici Hummus
Rasoi - 1 3 1 1
Mele 1 - 1 0 1
Schiuma da barba 3 1 - 1 2
Bici 1 0 1 - 1
Hummus 1 1 2 1 -

La matrice di co-occorrenza precedente riassume la probabilità che


due articoli vengano acquistati insieme. Vediamo come possiamo
usarla.

Motori di raccomandazione a filtri


collaborativi
Le raccomandazioni a filtri collaborativi si basano sull’analisi dei
pattern di acquisto storici degli utenti. L’assunto di base è che se due
utenti mostrano interesse per lo più per gli stessi articoli, possiamo
classificare tali utenti come simili. In altre parole, possiamo supporre
quanto segue:
se la similitudine nella cronologia degli acquisti di due utenti
supera una soglia, possiamo classificarli come utenti simili;
valutando la cronologia di utenti simili, gli articoli che non sono
simili nella cronologia degli acquisti diventano la base di future
raccomandazioni, attraverso filtri collaborativi.
Vediamo un esempio specifico. Abbiamo due utenti, Mike ed Elena
(Figura 10.3).
Figura 10.3

Sia Mike sia Elena hanno mostrato interesse per gli stessi
documenti, Doc1 e Doc2.
Sulla base dei loro pattern storici simili, possiamo classificarli
come utenti simili.
Se ora Elena legge il Doc3, possiamo suggerire il Doc3 anche a
Mike.

NOTA
Tenete presente che questa strategia di suggerire articoli agli utenti in base alla
loro cronologia non funziona sempre.

Supponiamo che Elena e Mike abbiano mostrato interesse per il


Doc1, che riguardava la fotografia (perché condividono la passione per
la fotografia). Inoltre, entrambi hanno mostrato interesse per il Doc2,
che riguardava il cloud computing, ancora una volta, perché entrambi
hanno un interesse per l’argomento. Sulla base del filtro collaborativo,
li abbiamo classificati come utenti simili. Adesso Elena inizia a
leggere il Doc3, che è una rivista di moda femminile. Se diamo retta
all’algoritmo di filtraggio collaborativo, suggeriremmo anche a Mike
di leggere il Doc3, ma potrebbe non interessargli molto.
NOTA
Nel 2012, il superstore americano Target stava sperimentando l’uso del filtro
collaborativo per consigliare i prodotti agli acquirenti. L’algoritmo ha classificato
un padre simile alla figlia adolescente in base ai loro profili. Target ha finito per
inviare al padre un buono sconto per pannolini, latte artificiale e culla: il padre
non era a conoscenza della gravidanza di sua figlia.

Notate che l’algoritmo di filtraggio collaborativo non dipende da


altre informazioni e opera autonomamente, sulla base dei
comportamenti degli utenti e delle raccomandazioni collaborative.

Motori di raccomandazione ibridi


Finora abbiamo trattato di motori di raccomandazione basati sui
contenuti o basati sul filtraggio collaborativo. I due tipi di motori di
raccomandazione possono essere combinati, per creare un motore di
raccomandazione ibrido. Per farlo, seguiamo questi passaggi:
creiamo una matrice di similarità degli articoli;
creiamo le matrici di preferenze degli utenti;
creiamo le raccomandazioni.
Esaminiamo questi passaggi uno per uno.

Generazione di una matrice di similarità degli articoli


Nei motori di raccomandazione ibridi, iniziamo creando una matrice
di similarità degli articoli utilizzando un motore basato sui contenuti.
Possiamo farlo utilizzando la matrice di co-occorrenza o utilizzando
qualsiasi misura della distanza per quantificare la somiglianza fra gli
articoli.
Supponiamo di avere cinque articoli. Utilizzando le
raccomandazioni basate sui contenuti, generiamo una matrice che
catturi la similarità fra gli articoli (Figura 10.4).

Figura 10.4

Vediamo ora come possiamo combinare questa matrice di similarità


con una matrice di preferenze, per generare raccomandazioni.

Generazione dei vettori di riferimento degli utenti


Sulla base della cronologia di ciascuno degli utenti del sistema,
produrremo un vettore delle preferenze che catturi gli interessi degli
utenti.
Supponiamo di voler generare consigli per un negozio online
chiamato KentStreetOnline, che vende 100 articoli. KentStreetOnline
ha 1 milione di clienti attivi. È importante notare che è necessario
generare solo una matrice di similarità di dimensioni 100 × 100.
Inoltre, è necessario generare un vettore delle preferenze per ciascuno
degli utenti; ciò significa che dobbiamo generare 1 milione di vettori
delle preferenze.
Ogni voce del vettore delle preferenze rappresenta una preferenza
per un articolo. Il valore della prima riga indica che il peso della
preferenza per l’articolo 1 è 4. Per esempio, il valore della seconda
riga significa che non esiste alcuna preferenza per l’articolo 2.
Ciò è mostrato graficamente nella Figura 10.5.

Figura 10.5

Ora, vediamo come possiamo generare le raccomandazioni basate


sulla matrice di similarità, S, e quelle basate sulla matrice delle
preferenze dell’utente, U.

Generazione di raccomandazioni
Per generare le raccomandazioni, possiamo moltiplicare le due
matrici. È più probabile che un utente sia interessato a un articolo che
si presenta frequentemente insieme a un articolo cui ha assegnato una
valutazione elevata:
Matrice[S] × Matrice[U] = Matrice[R]
Questo calcolo è mostrato graficamente nella Figura 10.6.
Viene generata una matrice delle raccomandazioni per ciascuno
degli utenti. I numeri nella matrice, Matrice[R], quantificano
l’interesse ipotizzato di un utente per ciascuno degli articoli. Per
esempio, nella matrice risultato, il quarto articolo ha il numero più
elevato, 58. Quindi questo articolo sarebbe altamente raccomandato
per questo specifico utente.
Ora, esaminiamo i limiti dei diversi sistemi di raccomandazione.

Figura 10.6

I limiti dei sistemi di


raccomandazione
I motori di raccomandazione utilizzano algoritmi predittivi per
offrire consigli a gruppi di utenti. È una tecnologia potente, ma
dovremmo essere consapevoli dei suoi limiti.
Il problema dell’avviamento a freddo
È ovvio che, affinché il filtro collaborativo funzioni, abbiamo
bisogno di contare su dati storici delle preferenze dell’utente. Per un
nuovo utente, potremmo non avere alcun dato, quindi il nostro
algoritmo di somiglianza dell’utente si baserà su ipotesi non accurate.
Per le raccomandazioni basate sui contenuti, potremmo non avere
subito tutti i dettagli sui nuovi articoli. Questo requisito di dover
disporre di dati su articoli e utenti per poter generare consigli di qualità
è chiamato problema dell’avviamento a freddo.

Il problema dei metadati


I metodi basati sui contenuti richiedono descrizioni esplicite degli
articoli per poter misurare la somiglianza. Tali descrizioni dettagliate
potrebbero non essere disponibili, e ciò avrà un’influenza sulla qualità
delle previsioni.

Il problema della scarsità di dati


In un numero enorme di articoli, un utente avrà valutato solo pochi
articoli, e ciò produrrà una matrice di valutazione utente/articolo molto
scarsa.
NOTA
Amazon ha circa un miliardo di utenti cui vende un miliardo di articoli. Si dice
che il motore di raccomandazione di Amazon abbia i dati più scarsi per
qualsiasi motore di raccomandazione al mondo.

Il bias dovuto all’influenza sociale


L’influenza sociale può svolgere un ruolo importante nelle
raccomandazioni. Le relazioni sociali possono essere viste come un
fattore che influenza le preferenze di un utente. Gli amici tendono ad
acquistare articoli simili e a dare valutazioni simili.

Dati limitati
Un numero limitato di recensioni rende difficile per i sistemi di
raccomandazione misurare con precisione le somiglianze fra gli utenti.

Campi di applicazione
Vediamo dove vengono impiegati i sistemi di raccomandazione
nella pratica:
due terzi dei film consigliati su Netflix;
il 35% delle vendite di Amazon;
su Google News, le raccomandazioni generano il 38% di clic in
più;
i tentativi di prevedere l’appetibilità di un articolo per un utente si
basano sulle valutazioni passate;
è possibile suggerire corsi agli studenti universitari in base alle
loro esigenze e preferenze;
è possibile abbinare i curricula ai lavori sui portali di lavoro
online.
Ora, proviamo a utilizzare un motore di raccomandazione per
risolvere un problema concreto.

Esempio pratico: creazione di un


motore di raccomandazione
Costruiamo un motore di raccomandazione in grado di consigliare
film a un gruppo di utenti. Useremo i dati raccolti dal gruppo di ricerca
GroupLens Research presso l’università del Minnesota.
1. Innanzitutto, dobbiamo importare i pacchetti necessari:
import pandas as pd

import numpy as np

2. Ora, importiamo i dataset:


df_reviews = pd.read_csv('reviews.csv')

df_movie_titles = pd.read_csv('movies.csv', index_col= False)

3. Uniamo i due DataFrame in base a movieID:


df = pd.merge(df_users, df_movie_titles, on = 'movieId')

L’intestazione del DataFrame df, dopo aver eseguito il codice


precedente, ha il seguente aspetto:

I dettagli delle colonne sono i seguenti.


- userid: l’ID univoco di ciascun utente.
- movieid: l’ID univoco di ciascun film.
- rating: le valutazioni dei film, da 1 a 5.
- timestamp: il timestamp in cui il film è stato valutato.
- title: il titolo del film.
- genres: il genere del film.
4. Per esaminare le tendenze nei dati di input, calcoliamo la media e
il conteggio delle valutazioni per film, utilizzando groupby per le
colonne title e rating:
5. Ora prepariamo i dati per il motore di raccomandazione. Per farlo,
trasformeremo il dataset in una matrice, che avrà le seguenti
caratteristiche:
- le colonne saranno i titoli dei film;
- l’indice sarà userid;
- il valore sarà rating.
Useremo la funzione pivot_table() di DataFrame:
movie_matrix = df.pivot_table(index= 'userId', columns = 'title', values
= 'rating')

Questa riga di codice genererà una matrice molto sparsa.


6. Ora usiamo la matrice di raccomandazioni che abbiamo creato per
consigliare i film. Per farlo, consideriamo un determinato utente
che ha visto il film, Avatar (2009). Innanzitutto, troviamo tutti gli
utenti che hanno mostrato interesse per Avatar (2009):
Avatar_user_rating = movie_matrix['Avatar (2009)']
Avatar_user_rating = Avatar_user_rating.dropna()

Avatar_user_rating.head()

7. Ora proviamo a suggerire i film correlati ad Avatar (2009). Per


farlo, calcoleremo la correlazione fra il DataFrame
Avatar_user_rating con movie_matrix, come segue:

similar_to_Avatar =movie_matrix.corrwith(Avatar_user_rating)

corr_Avatar = pd.DataFrame(similar_to_Avatar,

columns = ['correlation'])
corr_Avatar.dropna(inplace = True)

corr_Avatar = corr_Avatar.join(df_ratings['number_of_ratings'])

corr_Avatar.head()

Otterremo il seguente output:

Ciò significa che possiamo suggerire questi film all’utente.

Riepilogo
In questo capitolo abbiamo parlato dei motori di raccomandazione.
Abbiamo studiato la scelta del motore di raccomandazione giusto in
base al problema che stiamo cercando di risolvere. Abbiamo anche
visto come preparare i dati per i motori di raccomandazione, creando
una matrice di similarità. Abbiamo anche imparato a utilizzare i motori
di raccomandazione per risolvere problemi pratici, come suggerire film
agli utenti in base alle loro scelte passate.
Nel prossimo capitolo, ci concentreremo sugli algoritmi utilizzati
per comprendere ed elaborare i dati.
Parte III

Argomenti avanzati

Come suggerisce il nome, in questa parte del libro tratteremo alcuni


algoritmi di alto livello. La crittografia e gli algoritmi per dati su larga
scala sono gli argomenti salienti di questa parte. L’ultimo capitolo
esplora le considerazioni pratiche da tenere in considerazione durante
la loro implementazione.
Capitolo 11

Algoritmi per i dati

Questo capitolo si occupa degli algoritmi incentrati sui dati e, in


particolare, si concentra su tre aspetti: archiviazione, streaming e
compressione. Il capitolo si apre con una breve panoramica sugli
algoritmi per i dati; poi, discuteremo le varie strategie che possono
essere adottate nell’archiviazione dei dati. Successivamente, vedremo
come applicare gli algoritmi ai dati in streaming, per poi passare alle
diverse metodologie per comprimere i dati. Infine, impareremo a
utilizzare i concetti sviluppati in questo capitolo per monitorare la
velocità delle auto in transito su un’autostrada utilizzando una rete di
sensori all’avanguardia.

Introduzione agli algoritmi per i


dati
Che ce ne rendiamo conto o no, viviamo in un’era di big data.
Giusto per farsi un’idea della mole di dati che vengono costantemente
generati, basta considerare alcuni dei numeri pubblicati da Google per
il 2019. Come sappiamo, Google Photo è il repository multimediale
per l’archiviazione delle foto creato da Google. Nel 2019, ogni giorno
su Google Photo sono stati caricate, in media, 1,2 miliardi di foto e
video. Inoltre, ogni giorno su YouTube sono state caricate, in media,
400 ore di video (pari a 1 PB di dati). Possiamo tranquillamente
affermare che la quantità di dati generata è, semplicemente, esplosa.
L’attuale interesse per gli algoritmi per i dati è determinato dal fatto
che i dati contengono informazioni e modelli preziosi. Se utilizzati nel
modo giusto, i dati possono diventare la base di decisioni nel campo
della politica, del marketing, della governance e dell’analisi delle
tendenze.
Per ovvie ragioni, gli algoritmi che manipolano i dati stanno
diventando sempre più importanti e la progettazione di algoritmi in
grado di elaborare i dati è un campo di ricerca molto attivo. Non c’è
dubbio che l’obiettivo di organizzazioni, aziende e governi di tutto il
mondo sia quello di esplorare i modi migliori per utilizzare i dati così
da ottenere vantaggi quantificabili. Ma i dati, nella loro forma grezza,
raramente sono utili. Per estrarre informazioni dai dati grezzi, è
necessario elaborarli, prepararli e analizzarli.
Per farlo, dobbiamo prima conservarli da qualche parte. Le
metodologie efficienti per la memorizzazione dei dati stanno
diventando sempre più importanti. Notate che, a causa dei limiti di
archiviazione fisica dei sistemi a nodo singolo, sempre più spesso i big
data sono archiviati in spazi distribuiti, costituiti da più nodi connessi
da linee ad alta velocità. Quindi, ha senso che, per l’apprendimento
degli algoritmi per i dati, iniziamo proprio dagli algoritmi di
archiviazione dei dati.
Innanzitutto, classifichiamo i dati in varie categorie.

Classificazione dei dati


Vediamo come possiamo classificare i dati nel contesto della
progettazione di algoritmi per i dati. Come abbiamo visto nel Capitolo
2, per la classificazione può essere usata la quantificazione del volume,
della varietà e della velocità dei dati. Questa classificazione può
diventare una base per progettare algoritmi che possono essere
utilizzati per la memorizzazione ed elaborazione dei dati.
Esaminiamo queste caratteristiche una per una, nel contesto degli
algoritmi per i dati.
Il volume quantifica la quantità di dati che devono essere
archiviati ed elaborati da un algoritmo. All’aumentare del
volume, l’attività diventa a elevata intensità di dati e richiede
risorse sufficienti per memorizzare, salvare in cache ed elaborare i
dati. Big data è un termine che definisce vagamente un grande
volume di dati, che non può essere gestito da un singolo nodo.
La velocità definisce la rapidità con cui vengono generati nuovi
dati. Di solito, i dati ad alta velocità sono chiamati “dati caldi” o
“stream caldo” e i dati a bassa velocità sono chiamati “stream
freddo” o, semplicemente, “dati freddi”. In molte applicazioni, i
dati saranno un mix di stream caldi e freddi, che dovranno essere
preparati e combinati in un’unica tabella prima di poter essere
utilizzati con l’algoritmo.
La varietà si riferisce ai diversi tipi di dati strutturati e non
strutturati che devono essere combinati in un’unica tabella prima
di poter essere utilizzati dall’algoritmo.
Il prossimo paragrafo ci aiuterà a comprendere i compromessi da
considerare e presenterà varie scelte progettuali per la progettazione di
algoritmi di archiviazione.

Gli algoritmi di archiviazione dei


dati
Un archivio di dati affidabile ed efficiente è il cuore di un sistema
distribuito. Se questo archivio viene creato per scopi di analisi, viene
anche chiamato data lake. Un archivio, o repository, riunisce in
un’unica posizione i dati di più domini. Iniziamo a esaminare le
diverse problematiche relative all’archiviazione dei dati in un archivio
distribuito.

Le strategie di archiviazione dei dati


Nei primi anni dell’informatica, il modo consueto di progettare un
archivio di dati consisteva nell’utilizzare un’architettura a nodo
singolo. Poiché i dataset crescevano sempre più di dimensioni, ormai
si ricorre quasi esclusivamente all’archiviazione distribuita dei dati. La
giusta strategia per memorizzare i dati in un ambiente distribuito
dipende dal tipo di dati e dal modello di utilizzo previsto, nonché da
vari requisiti non funzionali. Per analizzare ulteriormente i requisiti di
un archivio dati distribuito, iniziamo a parlare del teorema CAP
(Consistency Availability Partition-tolerance), che fornisce le basi per
elaborare una strategia di archiviazione dati per un sistema distribuito.

Il teorema CAP
Nel 1998, Eric Brewer propose un teorema, che in seguito divenne
famoso con il nome di teorema CAP, il quale evidenzia i vari
compromessi legati alla progettazione di un sistema di archiviazione
distribuito.
Per comprendere il teorema CAP, in primo luogo, definiamo le
seguenti tre caratteristiche dei sistemi di archiviazione distribuiti: la
coerenza, la disponibilità e la tolleranza al partizionamento. CAP,
infatti, è un acronimo composto da queste tre caratteristiche:
Coerenza (Consistency): l’archivio distribuito è costituito da vari
nodi. Ciascuno di questi può essere utilizzato per leggere, scrivere
o aggiornare i record. La coerenza garantisce che a un certo
tempo, t1, indipendentemente da quale nodo usiamo per leggere i
dati, otterremo lo stesso risultato. Ogni operazione di lettura
restituisce i dati più recenti coerenti nell’archivio distribuito o
restituisce un messaggio di errore.
Disponibilità (Alailability): garantisce che qualsiasi nodo nel
sistema di archiviazione distribuita sarà in grado di gestire
immediatamente la richiesta, con o senza coerenza.
Tolleranza al partizionamento (Partition-tolerance): in un sistema
distribuito, più nodi sono collegati tramite una rete di
comunicazione. La tolleranza al partizionamento garantisce che,
in caso di un errore di comunicazione che interessi un piccolo
sottoinsieme dei nodi (uno o più), il sistema rimanga operativo.
Notate che per garantire la tolleranza al partizionamento, i dati
devono essere replicati su un numero sufficiente di nodi.
Utilizzando queste tre caratteristiche, il teorema CAP riassume
accuratamente i compromessi cui deve essere sottoposta l’architettura
e la progettazione di un sistema distribuito. Nello specifico, il teorema
CAP afferma che, in un sistema di archiviazione, possiamo avere solo
due delle seguenti caratteristiche: coerenza, disponibilità o tolleranza
al partizionamento.
Questo vincolo è rappresentato nella Figura 11.1.
In base al teorema CAP possiamo avere tre tipi di sistemi di
archiviazione distribuita:
un sistema CA (che implementa la coerenza e la disponibilità);
un sistema AP (che implementa la disponibilità e la tolleranza al
partizionamento);
un sistema CP (che implementa la coerenza e la tolleranza al
partizionamento).
Esaminiamoli uno per uno.
Figura 11.1

Sistemi CA
I sistemi tradizionali, a nodo singolo, sono sistemi CA. Questo
perché se non disponiamo di un sistema distribuito, non dobbiamo
preoccuparci della tolleranza al partizionamento. In tal caso, possiamo
avere un sistema che offre coerenza e disponibilità, cioè un sistema
CA.
I tradizionali database a nodo singolo come Oracle o MySQL sono
tutti esempi di sistemi CA.

Sistemi AP
I sistemi AP sono i sistemi di archiviazione distribuiti ottimizzati
per la disponibilità. Progettati come sistemi altamente reattivi, possono
sacrificare la coerenza, se necessario, per accettare dati ad alta
velocità. Ciò significa che si tratta di sistemi di archiviazione
distribuiti progettati per gestire immediatamente le richieste degli
utenti. Le richieste tipiche prevedono la lettura o scrittura di dati che
cambiano velocemente. I tipici sistemi AP sono utilizzati nei sistemi di
monitoraggio in tempo reale, come nel caso delle reti di sensori. I
database distribuiti ad alta velocità, come Cassandra, sono buoni
esempi di sistemi AP.
Vediamo dove può essere utilizzato un sistema AP. Se Transport
Canada desiderasse monitorare il traffico su una delle autostrade di
Ottawa attraverso la rete di sensori installati in diversi punti
dell’autostrada, dovremmo consigliarle un sistema AP per
implementare l’archiviazione distribuita dei dati.

Sistemi CP
I sistemi CP offrono sia la coerenza sia la tolleranza al
partizionamento. Ciò significa che questi sono i sistemi di
archiviazione distribuiti ottimizzati per garantire la coerenza, prima
che un processo di lettura possa recuperare un valore.
Un tipico caso d’uso per i sistemi CP è l’archiviazione di documenti
in formato JSON. I datastore di documenti come MongoDB sono
sistemi CP, ottimizzati per la coerenza in un ambiente distribuito.
L’archiviazione distribuita dei dati sta diventando sempre più
importante in una moderna infrastruttura IT e dovrebbe essere
progettata con attenzione in base alle feature presenti nel dataset e ai
requisiti del problema da risolvere. La classificazione
dell’archiviazione dei dati in sistemi CA, AP e CP ci aiuta a
comprendere i vari compromessi cui deve sottostare la progettazione di
sistemi di archiviazione dei dati.
Esaminiamo ora gli algoritmi per i dati in streaming.

Gli algoritmi per i dati in streaming


I dati possono essere limitati o illimitati. I dati limitati sono dati
inattivi e di solito vengono elaborati tramite un processo batch. Lo
streaming prevede fondamentalmente l’elaborazione di dati illimitati.
Vediamo un esempio: supponiamo di dover analizzare le transazioni
fraudolente di una banca. Se vogliamo trovare le transazioni
fraudolente avvenute sette giorni fa, dobbiamo cercare nei dati
conservati: questo è un tipico esempio di processo batch.
Al contrario, se vogliamo rilevare le frodi in tempo reale, dobbiamo
operare su dati in streaming. Gli algoritmi per i dati in streaming si
occupano dell’elaborazione di flussi di dati. L’idea fondamentale è
quella di dividere il flusso di dati di input in più lotti, che vengono poi
valutati dal nodo di elaborazione. Gli algoritmi di streaming devono
essere fault-tolerant e dovrebbero essere in grado di reggere la velocità
dei dati in ingresso. Poiché la richiesta di eseguire analisi delle
tendenze in tempo reale è in aumento, anche la richiesta di
elaborazione dati in streaming è in aumento. Tenete presente che,
affinché lo streaming funzioni, i dati devono essere elaborati
velocemente e, nella progettazione degli algoritmi, questo deve essere
sempre tenuto presente.

Applicazioni operanti su dati in streaming


Esistono molte applicazioni che devono operare su dati in
streaming. Ecco alcuni esempi:
intercettazione di una frode;
monitoraggio del sistema;
instradamento intelligente degli ordini;
dashboard live;
sensori del traffico lungo le autostrade;
transazioni con carta di credito;
mosse dell’utente in un gioco online multiutente.
Ora, vediamo come possiamo implementare lo streaming in Python.

Gli algoritmi di compressione dei


dati
Gli algoritmi di compressione dei dati hanno lo scopo di ridurre le
dimensioni dei dati. In questo capitolo, approfondiremo uno specifico
algoritmo, di compressione senza perdita di dati.

Algoritmi di compressione senza perdita


di dati
Si tratta di algoritmi in grado di comprimere i dati in modo tale da
poterli poi espandere senza alcuna perdita di informazioni. Vengono
utilizzati quando è importante recuperare esattamente i file originali
dopo l’espansione. Gli usi tipici degli algoritmi di compressione senza
perdita di dati sono i seguenti:
compressione di documenti;
compressione in pacchetti del codice sorgente e dei file eseguibili;
conversione di un gran numero di piccoli file in un piccolo
numero di file grandi.

Le tecniche di base della compressione senza perdita di


dati
La compressione dei dati si basa sul fatto che la maggior parte dei
dati utilizza più bit di quanto la sua entropia indichi come ottimale.
Entropia è un termine usato per specificare le informazioni trasportate
dai dati. È possibile adottare una rappresentazione più compatta delle
stesse informazioni. L’esplorazione e la formulazione di una
rappresentazione dei dati più efficiente sono alla base
dell’elaborazione di algoritmi di compressione. La compressione senza
perdita di dati sfrutta la ridondanza per comprimere i dati senza alcuna
perdita di informazioni. Alla fine degli anni Ottanta, Ziv e Lempel
proposero delle tecniche di compressione dei dati basate su dizionario,
che possono essere utilizzate per implementare la compressione senza
perdita di dati. Queste tecniche hanno avuto un successo immediato,
grazie alla loro velocità e al buon livello di compressione, e sono state
utilizzate per creare il popolare strumento compress di Unix. Inoltre,
l’onnipresente formato gif utilizza queste tecniche di compressione,
che si sono diffuse in quanto potevano essere utilizzate per
rappresentare le stesse informazioni con un numero inferiore di bit,
risparmiando spazio e larghezza di banda nelle comunicazioni. Queste
tecniche sono poi diventate la base per lo sviluppo del noto programma
zip e delle sue varianti. Anche lo standard di compressione, V.44,

utilizzato nei modem si basa su questo tipo di compressione.


Esaminiamo queste tecniche una per una.

Codifica Huffman
La codifica di Huffman è uno dei metodi più antichi di
compressione dei dati e si basa sulla creazione di un albero di
Huffman, utilizzato sia per codificare sia per decodificare i dati. La
codifica di Huffman può rappresentare il contenuto dei dati in una
forma più compatta sfruttando il fatto che alcuni dati (per esempio
certi caratteri) appaiono più frequentemente in un flusso di dati.
Utilizzando codifiche di lunghezza differente (più corta per i caratteri
più frequenti e più lunga per quelli meno frequenti), i dati consumano
meno spazio.
Esaminiamo la terminologia impiegata nella codifica di Huffman.
Codifica: rappresenta il metodo di rappresentazione dei dati, nel
passaggio da una forma all’altra. Vorremmo che la forma
risultante fosse più compatta dell’originale.
Codeword: è un determinato carattere in forma codificata.
Codifica a lunghezza fissa: si ha quando ogni carattere codificato,
ovvero ogni codeword, utilizza lo stesso numero di bit.
Codifica a lunghezza variabile: le codeword possono utilizzare un
numero differente di bit.
Valutazione del codice: il numero previsto di bit per codeword.
Codici senza prefisso: nessuna codeword è prefisso di un’altra
codeword.
Decodifica: un codice di lunghezza variabile deve essere libero da
qualsiasi prefisso.
Per comprendere questi termini, esaminiamo la seguente tabella.
Codifica a lunghezza Codifica a lunghezza
Carattere Frequenza
fissa variabile
L 0,45 000 0
M 0,13 001 101
N 0,12 010 100
X 0,16 011 111
Y 0,09 100 1101
Z 0,05 101 1100

Possiamo dedurne quanto segue.


Codifica a lunghezza fissa: la lunghezza della codifica a
lunghezza fissa per questa tabella è 3.
Codice a lunghezza variabile: la lunghezza della codifica a
lunghezza variabile per questa tabella è 0,45 × 1 + 0,13 × 3 + 0,12
× 3 + 0,16 × 3 + 0,09 × 4 + 0,05 × 4 = 2,24.
La Figura 11.2 mostra l’albero di Huffman creato dall’esempio
precedente:
Notate che la codifica di Huffman riguarda la conversione dei dati in
un albero di Huffman che consente la compressione. La decodifica o
l’espansione riporta i dati al formato originale.

Esempio pratico: analisi del


sentiment dei tweet in tempo reale
Si dice che su Twitter vadano quasi 7.000 tweet al secondo su
un’ampia varietà di argomenti. Proviamo a costruire un analizzatore
del sentiment, in grado di catturare in tempo reale le emozioni delle
notizie provenienti da diverse fonti.
1. Importiamo i pacchetti necessari:
import tweepy, json, time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer

analyzer = SentimentIntensityAnalyzer()
Figura 11.2

Notate che stiamo usando i seguenti due pacchetti:


- VADER (Valence Aware Dictionary and Sentiment Reasoner) è
uno dei noti strumenti di analisi del sentiment; si basa su regole
ed è stato sviluppato per i social media. Se non l’avete mai usato,
dovrete prima installarlo:
pip install vaderSentiment

- Tweepy è un’API basata su Python per accedere a Twitter. Di


nuovo, se non l’avete mai usato, dovrete prima installarlo:
pip install Tweepy

2. Il passaggio successivo è un po’ complicato. Dovete creare un


account da sviluppatore in Twitter e ottenere così l’accesso allo
stream live dei tweet. Una volta che avrete le chiavi, potete
specificarle con le seguenti variabili:
twitter_access_token = <token di accesso twitter >
twitter_access_token_secret = <password del token di accesso twitter>
twitter_consumer_key = <chiave consumer di twitter>

twitter_consumer_secret = <password della chiave consumer di twitter>

3. Configuriamo ora l’autenticazione all’API Tweepy. Per farlo,


dobbiamo fornire le variabili create in precedenza:
auth = tweepy.OAuthHandler(twitter_consumer_key,
twitter_consumer_secret)
auth.set_access_token(twitter_access_token, twitter_access_token_secret)

api = tweepy.API(auth, parser =tweepy.parsers.JSONParser())

4. Ora arriva la parte interessante. Sceglieremo gli handle Twitter


delle fonti di news di cui vogliamo effettuare l’analisi del
sentiment. Per questo esempio, abbiamo scelto le seguenti fonti di
news:
news_sources = ("@BBC", "@ctvnews", "@CNN", "@FoxNews", "@dawn_com")

5. Ora creiamo il ciclo principale. Questo ciclo inizierà con un array


vuoto, array_sentiments, per contenere i dati del sentiment. Quindi,
esamineremo tutte e cinque le fonti di news e raccoglieremo cento
tweet per ciascuna. Poi valuteremo la polarità di ogni tweet:
6. Ora creiamo un grafico che mostri la polarità delle notizie da
queste singole fonti di news:
Notate che in realtà ciascuna delle fonti di news è rappresentata
da un colore differente.
7. Ora, esaminiamo le statistiche di riepilogo:

I numeri precedenti riassumono le tendenze del sentiment. Per


esempio, il sentiment di BBC risulta essere il più positivo e mentre il
canale di notizie canadese, CTVnews, sembra trasmettere le emozioni
più negative.

Riepilogo
In questo capitolo, abbiamo esaminato la progettazione di algoritmi
basati su dati. Ci siamo concentrati su tre aspetti degli algoritmi per i
dati: archiviazione, compressione e streaming.
Abbiamo esaminato come le feature presenti nel dataset possono
influenzare la progettazione del sistema di archiviazione dei dati.
Abbiamo esaminato i diversi tipi di algoritmi di compressione dei dati.
Quindi, abbiamo studiato un esempio pratico d’uso degli algoritmi per
flussi di dati.
Nel prossimo capitolo esamineremo gli algoritmi crittografici.
Impareremo a utilizzare la potenza di questi algoritmi per proteggere i
messaggi in transito e archiviati.
Capitolo 12

Crittografia

Questo capitolo presenta gli algoritmi di crittografia. Inizieremo


presentando le basi, poi discuteremo degli algoritmi di crittografia
simmetrica. Parleremo poi degli algoritmi MD5 (Message-Digest 5) e
SHA (Secure Hash Algorithm) ed esamineremo i limiti e i punti deboli
dell’implementazione degli algoritmi simmetrici. A seguire,
introdurremo gli algoritmi di crittografia asimmetrica e vedremo come
vengono utilizzati per creare certificati digitali. Infine, esamineremo
un esempio pratico che riassume tutte queste tecniche.

Introduzione alla crittografia


Le tecniche per proteggere i segreti esistono da secoli. I primi
tentativi che conosciamo di proteggere e nascondere i dati agli
avversari risalgono ad alcune antiche iscrizioni scoperte sui
monumenti in Egitto, dove veniva utilizzato un alfabeto speciale, noto
solo a poche persone fidate. Questa prima forma di sicurezza è
chiamata oscuramento ed è ancora utilizzata oggi, in forme differenti.
Affinché questo metodo funzioni, è fondamentale proteggere il
segreto, ovvero il significato dell’alfabeto. Più tardi, divenne
importante trovare modi infallibili per proteggere messaggi importanti,
soprattutto nella Prima e nella Seconda guerra mondiale. Alla fine del
XX secolo, con l’introduzione dell’elettronica e dei computer, furono
sviluppati sofisticati algoritmi per proteggere i dati, dando origine a un
campo completamente nuovo: la crittografia. Questo capitolo tratta gli
aspetti algoritmici della crittografia. Lo scopo di questi algoritmi è
quello di consentire uno scambio di dati sicuro fra due processi o
utenti. Gli algoritmi crittografici sfruttano l’utilizzo di funzioni
matematiche per garantire gli obiettivi di sicurezza che dichiarano.

L’importanza dell’anello debole


A volte, quando progettiamo la sicurezza dell’infrastruttura digitale,
ci concentriamo troppo sulla sicurezza delle singole entità, e non
prestiamo la necessaria attenzione alla sicurezza intesa in senso
globale, ovvero end-to-end. Ciò può portarci a trascurare alcune crepe
e vulnerabilità del sistema, che possono essere sfruttate dagli hacker
per accedere a dati sensibili. Il punto importante da ricordare è che
nella "catena" di un’infrastruttura digitale, nel suo insieme, è resistente
solo quanto il suo anello più debole. Per un hacker, questo anello più
debole può fornire un accesso a dati sensibili. Oltre un certo punto,
non è utile fortificare la porta d’ingresso, se poi le porte sul retro sono
“aperte”.
Mentre gli algoritmi e le tecniche per gestire l’infrastruttura digitale
si fanno sempre più sofisticati, anche gli aggressori continuano ad
aggiornare le loro tecniche. È sempre importante ricordare che uno dei
modi più semplici per violare l’infrastruttura digitale consiste nello
sfruttare queste vulnerabilità per accedere a informazioni sensibili.
NOTA
Si stima che nel 2014 un attacco informatico a un istituto di ricerca federale
canadese, il National Research Council (NRC), sia costato centinaia di milioni
di dollari. Gli aggressori sono stati in grado di trafugare decenni di dati di ricerca
e materiale di proprietà intellettuale. Per farlo hanno usato una falla nel
software Apache utilizzato sui server web e così hanno ottenuto l’accesso ai
dati sensibili.
In questo capitolo evidenzieremo le vulnerabilità di vari algoritmi di
crittografia. Ma esaminiamo, innanzitutto, la terminologia di base
utilizzata.

Terminologia di base
Esaminiamo la terminologia di base impiegata in crittografia.
Cifratura: un algoritmo che esegue una determinata funzione
crittografica.
Testo in chiaro: i dati di partenza, che possono essere un file di
testo, un video, un’immagine o una voce digitalizzata. In questo
capitolo rappresenteremo il testo in chiaro come P, da plain text.
Testo cifrato: il testo codificato che si ottiene dopo aver applicato
la crittografia al testo in chiaro. In questo capitolo lo
rappresenteremo come C.
Sistema di cifratura: un insieme di componenti software
crittografici. Quando due nodi desiderano scambiarsi messaggi
crittografati, devono innanzitutto concordare un sistema di
cifratura, per garantire che entrambi utilizzino esattamente la
stessa implementazione delle funzioni crittografiche.
Crittografia: il processo di conversione del testo in chiaro, P, in
testo cifrato, C. Matematicamente, è rappresentato da encrypt(P )
= C.
Decrittografia: il processo di riconversione del testo cifrato in
testo in chiaro. Matematicamente, è rappresentato da decrypt(C )
= P.
Criptoanalisi: i metodi utilizzati per analizzare la resistenza degli
algoritmi crittografici. L’analista cerca di recuperare il testo in
chiaro senza accedere al segreto.
Informazioni di identificazione personale: sono le informazioni
che possono essere utilizzate per tracciare l’identità di un
individuo, se utilizzate da sole o insieme ad altri dati rilevanti.
Alcuni esempi includono informazioni riservate, come il codice
fiscale, la data di nascita o il cognome da nubile della madre.

I requisiti di sicurezza
È importante innanzitutto comprendere le esatte esigenze di
sicurezza di un sistema. Questo ci aiuterà a utilizzare la tecnica
crittografica più adatta e a scoprire le potenziali falle in un sistema. Per
fare ciò, dobbiamo prima comprendere meglio le esigenze del sistema,
attraverso questi tre passaggi:
identificare le entità;
definire gli obiettivi di sicurezza;
valutare la sensibilità dei dati.
Esaminiamo questi passaggi uno per uno.

Identificare le entità
Un modo per identificare le entità consiste nel rispondere alle
seguenti quattro domande, che ci aiuteranno a comprendere le esigenze
del sistema in termini di sicurezza.
Quali applicazioni devono essere protette?
Da chi le stiamo proteggendo?
Dove dovremmo proteggerle?
Perché le proteggiamo?
Una volta compresi meglio questi requisiti, possiamo stabilire gli
obiettivi di sicurezza del nostro sistema digitale.
Definire gli obiettivi di sicurezza
Gli algoritmi crittografici vengono in genere utilizzati per soddisfare
uno o più obiettivi di sicurezza.
Autenticazione: in poche parole, è il modo in cui dimostriamo che
un utente è proprio chi afferma di essere. Attraverso il processo di
autenticazione, ci assicuriamo che l’identità di un utente sia
certificata. Il processo di autenticazione inizia facendo in modo
che l’utente presenti la propria identità. Poi deve fornire
informazioni che sono note solo all’utente e che quindi possono
essere prodotte solo da lui.
Riservatezza: i dati che devono essere protetti sono chiamati dati
sensibili. La riservatezza consiste nel far conoscere i dati sensibili
ai soli utenti autorizzati. Per tutelare la riservatezza dei dati
sensibili durante il loro transito o la loro conservazione, è
necessario renderli illeggibili agli utenti non autorizzati. Ciò si
ottiene utilizzando algoritmi di crittografia, di cui parleremo in
questo capitolo.
Integrità: è il processo per stabilire che i dati non sono stati
alterati in alcun modo durante il loro transito o mentre erano
archiviati. Per esempio, TCP/IP (Transmission Control
Protocol/Internet Protocol) utilizza algoritmi di checksum o CRC
(Cyclic Redundancy Check) per verificare l’integrità dei dati.
Non ripudiabilità: è il concetto in base al quale il mittente delle
informazioni riceve la conferma che i dati sono stati ricevuti dal
destinatario e il destinatario riceve la conferma dell’identità del
mittente. Ciò fornisce prove inconfutabili che un messaggio sia
stato inviato e poi ricevuto, fatto che può essere utilizzato in
seguito per dimostrare la ricezione dei dati ed eventuali errori
nella comunicazione.
Valutare la sensibilità dei dati
È importante classificare la natura dei dati. Dobbiamo anche pensare
a quanto gravi potrebbero essere le conseguenze se i dati venissero
compromessi. La classificazione dei dati ci aiuta a scegliere
l’algoritmo crittografico corretto. Esiste più di un modo per
classificare i dati, in base alla sensibilità delle informazioni che essi
contengono.
Dati pubblici o non classificati: tutto ciò che è disponibile per il
consumo pubblico. Per esempio, le informazioni trovate sul sito
web di un’azienda o sul portale informativo di un governo.
Dati interni o riservati: sebbene non siano destinati alla
diffusione pubblica, l’esposizione di questi dati al pubblico non
dovrebbe avere conseguenze dannose. Per esempio, se le e-mail
di un dipendente che si lamenta del proprio manager venissero
divulgate, la cosa potrebbe essere imbarazzante per l’azienda, ma
senza conseguenze dannose.
Dati sensibili o segreti: i dati che non dovrebbero essere destinati
alla diffusione pubblica e la cui esposizione al pubblico avrebbe
conseguenze dannose per un individuo o un’organizzazione. Per
esempio, divulgare i dettagli di un futuro iPhone potrebbe
danneggiare gli obiettivi aziendali di Apple e offrire un vantaggio
ai rivali, come Samsung.
Dati altamente sensibili: sono i dati top-secret. Si tratta di
informazioni che, se divulgate, sarebbero estremamente dannose
per l’organizzazione. Può trattarsi di codici fiscali dei clienti,
numeri di carte di credito o altre informazioni altamente sensibili.
I dati top-secret sono protetti da più livelli di sicurezza e
richiedono un’autorizzazione speciale per l’accesso.

NOTA
In generale, i progetti di sicurezza più sofisticati sono molto più lenti dei semplici
algoritmi. È importante trovare il giusto equilibrio fra sicurezza e prestazioni del
sistema.

Progettazione di cifrature
Progettare una cifratura significa trovare un algoritmo in grado di
criptare i dati sensibili in modo che un processo o un utente non
autorizzato non possano accedervi. Sebbene nel tempo le cifrature
siano diventate sempre più sofisticate, i principi su cui si basano
rimangono invariati.
Iniziamo esaminando alcune codifiche relativamente semplici, che
ci aiuteranno a comprendere i principi di base utilizzati nella
progettazione di algoritmi crittografici.

Cifratura a sostituzione
I cifrari a sostituzione sono in uso da centinaia di anni in varie
forme. Come indica il nome, si basano su un semplice concetto: la
sostituzione del testo in chiaro con altri caratteri, in un modo
predeterminato e organizzato.
Esaminiamo i passaggi da svolgere.
1. Innanzitutto, mappare ogni carattere su un altro carattere.
2. Quindi, codificare e convertire il testo in chiaro in testo cifrato,
sostituendo ogni carattere nel testo in chiaro con il carattere
corrispondente nel testo cifrato, utilizzando la mappa di
sostituzione.
3. Per decodificare il testo cifrato, basta applicare all’inverso la
mappa di sostituzione.
Vediamo alcuni esempi:
Cifratura di Cesare
Nei cifrari di Cesare, la mappa di sostituzione viene creata
sostituendo ogni carattere con il secondo carattere successivo,
nell’ordine alfabetico. Questa mappatura è descritta nella Figura 12.1.

Figura 12.1

Vediamo come possiamo implementare la crittografia di Cesare in


Python, con uno spostamento di tre caratteri alfabetici:
import string

rotation = 3

P = 'CALM'; C = ''

for letter in P:

C = C+ (chr(ord(letter) + rotation))

Possiamo vedere che abbiamo applicato una cifratura di Cesare al


testo in chiaro, CALM.
Mostriamo ora il testo dopo averlo cifrato con la crittografia di
Cesare:
NOTA
Si dice che i cifrari di Cesare siano stati usati da Giulio Cesare per comunicare
con i suoi consiglieri.

Rotazione 13 (ROT13)
ROT13 è un’altra crittografia a sostituzione. In ROT13, la mappa di
sostituzione viene creata sostituendo ogni carattere con il tredicesimo
carattere alla sua destra, come si può vedere nella Figura 12.2.

Figura 12.2

Ciò significa che la cifratura ROT13 funziona nel seguente modo:


import codecs

P = 'CALM'

C = ''

C = codecs.encode(P, 'rot_13')

Ora vediamo il valore codificato di C:

Criptoanalisi dei cifrari a sostituzione


I cifrari a sostituzione sono semplici da implementare e da
comprendere. Sfortunatamente, sono anche facili da decifrare. Una
semplice criptoanalisi dei cifrari a sostituzione mostra che dato un
alfabeto, allora tutto ciò che dobbiamo determinare per decifrare il
cifrario è l’entità della rotazione. Possiamo provare ogni lettera
dell’alfabeto una per una, finché non siamo in grado di decifrare il
testo. Ciò significa che ci vorranno al massimo 25 tentativi per
ricostruire il testo in chiaro.
Ora, vediamo un altro tipo di cifratura semplice: la cifratura a
trasposizione.

La cifratura a trasposizione
Nelle cifrature a trasposizione, i caratteri del testo in chiaro vengono
trasposti. Esaminiamo i passaggi.
1. Creare la matrice di trasposizione e sceglierne le dimensioni.
Dovrebbe essere abbastanza grande da contenere la stringa di
testo in chiaro.
2. Compilare la matrice scrivendo orizzontalmente tutti i caratteri
della stringa.
3. Leggere i caratteri della stringa verticalmente, nella matrice di
trasposizione.
Vediamo un esempio.
Prendiamo il testo in chiaro Ottawa Rocks (P).
Per prima cosa, codifichiamo P. Per farlo, useremo una matrice 3 ×
4 e scriveremo il testo in chiaro orizzontalmente:
O t t a
w a R o
c k s
Ora leggeremo la stringa verticalmente, e il testo cifrato sarà
OwctaktRsao.

NOTA
I tedeschi usarono una cifratura chiamata ADFGVX nella Prima guerra
mondiale, che usava cifrature a trasposizione e a sostituzione. Anni dopo, fu
decifrato da George Painvin.

Questi sono alcuni dei tipi di cifrature. Ora, esaminiamo alcune


delle tecniche crittografiche attualmente in uso.

Tipi di tecniche crittografiche


Tecniche crittografiche differenti utilizzano tipi di algoritmi
differenti e vengono utilizzate in circostanze differenti. In generale, le
tecniche crittografiche possono essere suddivise nei seguenti tre tipi:
crittografia hash;
crittografia simmetrica;
crittografia asimmetrica.

La crittografia hash
La funzione di hash crittografico impiega un algoritmo matematico
che può essere utilizzato per creare come “un’impronta digitale
univoca” di un messaggio, creando un output di dimensioni fisse, un
codice hash, derivato dal testo in chiaro.
Matematicamente, l’operazione ha il seguente aspetto:
C1 = funzioneHash(P1)
P1 è il testo in chiaro, che rappresenta i dati di input.
C1 è un codice hash di lunghezza fissa generato dalla
funzioneHash crittografica.
L’operazione è rappresentata nella Figura 12.3. I dati, di lunghezza
variabile, vengono convertiti in un codice hash di lunghezza fissa
tramite una funzione hash unidirezionale.

Figura 12.3

La funzione hash ha le seguenti cinque caratteristiche.


È deterministica, ovvero lo stesso testo in chiaro genera lo stesso
codice hash.
Stringhe di input univoche genera in output codici hash univoci.
Indipendentemente dalla lunghezza del messaggio di input,
l’output ha lunghezza fissa.
Anche piccoli cambiamenti nel testo in chiaro generano un nuovo
codice hash.
È una funzione unidirezionale, il che significa che non è possibile
generare il testo in chiaro, P1, partendo dal testo cifrato, C1.
Se abbiamo una situazione in cui due messaggi differenti generano
lo stesso codice hash abbiamo una collisione. Cioè, abbiamo due testi,
P1 e P2, tali che funzioneHash(P1) = funzioneHash(P2).
Indipendentemente dall’algoritmo di hashing utilizzato, le collisioni
sono estremamente rare, altrimenti, l’hashing non sarebbe di alcuna
utilità. Tuttavia, per alcune applicazioni, le collisioni non possono
essere tollerate. In questi casi, è necessario utilizzare un algoritmo di
hashing più complesso e meno soggetto a collisioni.

Implementazione di funzioni hash crittografiche


Le funzioni hash crittografiche possono essere implementate
utilizzando vari algoritmi. Esaminiamone un paio.

L’algoritmo MD5-tolerated
MD5 è stato sviluppato da Poul-Henning Kamp nel 1994 per
sostituire MD4 e genera un codice hash a 128 bit. MD5 è un algoritmo
relativamente semplice e vulnerabile alle collisioni. Non deve pertanto
essere utilizzato per applicazioni che non tollerano collisioni.
Vediamo un esempio. Per generare un codice hash MD5 in Python,
utilizzeremo la nota libreria open source passlib, che implementa oltre
30 algoritmi di hashing per password. Se non l’avete già installata,
utilizzate la riga seguente in un notebook Jupyter:
!pip install passlib

In Python, possiamo generare il codice hash MD5 come segue:

Notate che MD5 genera un codice hash di 128 bit.


Come abbiamo detto, possiamo usare questo codice hash come
“impronta digitale” del testo originale, myPassword. Vediamo come
possiamo farlo in Python:
Notate che il codice hash generato per la stringa myPassword
corrispondeva al codice hash originale (ha generato il valore True). Al
contrario, ha restituito False non appena il testo in chiaro è stato
leggermente modificato in myPassword2.
Ora, vediamo all’opera un altro algoritmo di hashing: SHA (Secure
Hash Algorithm).

L’algoritmo SHA
SHA è stato sviluppato dal National Institute of Standards and
Technology (NIST). Vediamo come possiamo usare Python per creare
un codice hash usando l’algoritmo SHA:
from passlib.hash import sha512_crypt

sha512_crypt.using(salt = "qIo0foX5", rounds = 5000).hash("myPassword")

Notate l’uso di un parametro, salt. La procedura aggiunge caratteri


casuali prima del codice hashing.
L’esecuzione di questo codice ci darà il seguente risultato:

Notate che quando usiamo l’algoritmo SHA, il codice hash generato


è di 512 byte.

Un’applicazione della funzione hash crittografica


Le funzioni hash vengono utilizzate per verificare l’integrità di un
file dopo averne fatto una copia. Per ottenere ciò, quando il file viene
copiato da un’origine a una destinazione (per esempio, quando viene
scaricato da un server web), viene copiato anche il suo codice hash,
horiginale, che agisce come un’impronta digitale del file originale. Dopo
aver copiato il file, generiamo nuovamente il codice hash dalla
versione copiata del file, ovvero hcopia. Se horiginale = hcopia (ovvero, se il
codice hash generato a partire dalla copia del file corrisponde al codice
hash generato a partire dal file originale), abbiamo la sicurezza che il
file non è cambiato e che il processo di download non ha perso dati.
Possiamo utilizzare qualsiasi funzione hash crittografica, come MD5 o
SHA, per generare un codice hash a questo scopo.
Ora, esaminiamo la crittografia simmetrica.

La crittografia simmetrica
In crittografia, una chiave è una combinazione di numeri utilizzata
per codificare un testo in chiaro utilizzando un algoritmo a scelta.
Nella crittografia simmetrica, utilizziamo la stessa chiave sia per la
crittografia sia per la decrittografia. Se la chiave utilizzata per la
crittografia simmetrica è K, allora vale la seguente equazione:
EK(P ) = C
P è il testo in chiaro e C è il testo cifrato.
Per la decrittografia, usiamo la stessa chiave, K, per riconvertire C
in P:
DK(C ) = P
Questo processo è rappresentato nella Figura 12.4.
Figura 12.4

Ora, vediamo come possiamo usare la crittografia simmetrica con


Python.

Programmazione della crittografia simmetrica


Per applicare la crittografia simmetrica useremo il pacchetto Python
cryptography, che implementa molti algoritmi crittografici, vari cifrari

simmetrici e diversi codificatori di messaggi. Se non lo conoscete,


dovrete prima installarlo:
!pip install cryptography

Una volta installato, possiamo utilizzare il pacchetto per


implementare la crittografia simmetrica, come segue.
1. Per prima cosa, importiamo i pacchetti di cui abbiamo bisogno:
import cryptography as crypt

from cryptography.fernet import Fernet

2. Generiamo la chiave:

3. Ora apriamo la chiave:


file = open('mykey.key', 'wb')
file.write(key)
file.close()

4. Utilizzando la chiave, proviamo a crittografare il messaggio:


file = open('mykey.key', 'rb')
key = file.read()

file.close()

5. E ora proviamo a decifrare il messaggio usando quella stessa


chiave:
from cryptography.fernet import Fernet
message = "Ottawa is really cold".encode()
f = Fernet(key)

encrypted = f.encrypt(message)

6. Decodifichiamo il messaggio e assegniamolo alla variabile


decrypted:

decrypted = f.decrypt(encrypted)

7. Stampiamo ora la variabile decrypted per verificare se siamo in


grado di ottenere lo stesso messaggio:

La crittografia simmetrica offre alcuni vantaggi.

I vantaggi della crittografia simmetrica


Sebbene le prestazioni della crittografia simmetrica dipendano
dall’algoritmo utilizzato, in generale è molto più veloce della
crittografia asimmetrica.

I difetti della crittografia simmetrica


Quando due utenti o processi pianificano di utilizzare la crittografia
simmetrica per comunicare, devono scambiarsi le chiavi utilizzando un
canale sicuro. Ciò dà luogo ai seguenti due problemi.
Protezione della chiave: come proteggere la chiave di crittografia
simmetrica.
Distribuzione delle chiavi: come condividere la chiave di
crittografia simmetrica fra l’origine e la destinazione.
Esaminiamo ora la crittografia asimmetrica.

La crittografia asimmetrica
La crittografia asimmetrica è stata ideata negli anni Settanta per
affrontare alcuni punti deboli della crittografia simmetrica, di cui
abbiamo trattato nel paragrafo precedente. Il primo passo nella
crittografia asimmetrica consiste nel generare due chiavi che sembrano
totalmente diverse ma sono correlate algoritmicamente. Una di esse
viene scelta come chiave privata, Kpr, e l’altra come chiave pubblica,
Kpu. Matematicamente, possiamo rappresentare la situazione come
segue:
EKpr(P) = C
Al solito, P è il testo in chiaro e C è il testo cifrato.
Possiamo decifrare il risultato come segue:
DKpu(C) = P
Le chiavi pubbliche possono essere distribuite liberamente, mentre
le chiavi private devono essere tenute segrete dal proprietario della
coppia di chiavi.
Il principio fondamentale è che se si crittografa un contenuto con
una delle chiavi, l’unico modo per decifrarlo consiste nell’utilizzare
l’altra chiave. Per esempio, se crittografiamo i dati utilizzando la
chiave pubblica, sarà necessario decrittografarli utilizzando l’altra
chiave, ovvero la chiave privata. Vediamo ora uno dei protocolli
fondamentali della crittografia asimmetrica, l’handshake SSL/TLS
(Secure Sockets Layer/Transport Layer Security), che si occupa di
stabilire una connessione fra due nodi utilizzando la crittografia
asimmetrica.

L’algoritmo di handshaking SSL/TLS


SSL è stato originariamente sviluppato per aggiungere sicurezza al
protocollo HTTP. Nel tempo, SSL è però stato sostituito da un
protocollo più efficiente e più sicuro, TLS. Gli handshake TLS sono
alla base del modo in cui HTTP crea una sessione di comunicazione
sicura. L’handshake TLS riguarda due entità: il client e il server.
Questo processo è rappresentato nella Figura 12.5.

Figura 12.5

Un handshake TLS stabilisce una connessione sicura fra i nodi


partecipanti. Ecco i passaggi che sono coinvolti in questo processo.
1. Il client invia al server un messaggio client . Il messaggio
hello

contiene anche quanto segue:


- la versione di TLS utilizzata;
- l’elenco delle opzioni di crittografia supportate dal client;
- un algoritmo di compressione;
- una stringa di byte casuali, identificata da byte_client.
2. Il server invia al client un messaggio server hello. Il messaggio
contiene anche quanto segue:
- un insieme di sistemi di cifratura selezionati dall’elenco fornito
dal client;
- un ID di sessione;
- una stringa di byte casuali, identificata da byte_server;
- un certificato digitale del server, identificato da cert_server,
contenente la chiave pubblica del server;
- se il server richiede un certificato digitale per l’autenticazione
del client o una richiesta di certificato per il client, la richiesta del
server al client include anche i nomi distinti di CA (autorità di
certificazione) accettabili e i tipi di certificati supportati.
3. Il client verifica cert_server.
4. Il client genera una stringa di byte casuali, identificata da
byte_client2 e la crittografa con la chiave pubblica del server,

fornitagli tramite cert_server.


5. Il client genera una stringa di byte casuali e identifica la
crittografia con la propria chiave privata.
6. Il server verifica il certificato del client.
7. Il client invia al server il messaggio finished, crittografato con una
chiave segreta.
8. Come acknowledgement dal lato server, il server invia al client il
messaggio finished, crittografato con una chiave segreta.
9. Il server e il client hanno così stabilito un canale sicuro. Ora
possono scambiarsi messaggi crittografati simmetricamente con
la chiave segreta condivisa. L’intera metodologia è rappresentata
nella Figura 12.6.
Ora, vediamo come possiamo utilizzare la crittografia asimmetrica
per creare l’infrastruttura a chiave pubblica (Private Key
Infrastructure, PKI), per soddisfare uno o più obiettivi di sicurezza di
un’organizzazione.

Infrastruttura a chiave pubblica


La crittografia asimmetrica viene impiegata per implementare la
PKI, uno dei modi più utilizzati e affidabili per gestire le chiavi di
crittografia per un’organizzazione. Tutti i partecipanti si fidano di
un’autorità centrale di certificazione, chiamata CA (Certification
Authority). Queste autorità verificano l’identità di individui e
organizzazioni e rilasciano loro certificati digitali (un certificato
digitale contiene una copia della chiave pubblica di una persona o
organizzazione e la sua identità), i quali certificano che la chiave
pubblica associata a quell’individuo o organizzazione appartenga
effettivamente a quell’individuo o organizzazione.

Figura 12.6
L’autorità di certificazione chiede a un utente di dimostrare la
propria identità, con standard diversi per individui e organizzazioni.
Ciò potrebbe comportare semplicemente l’attestazione della proprietà
di un nome di dominio, oppure potrebbe comportare un processo più
rigoroso che prevede la prova fisica dell’identità, a seconda del tipo di
certificato digitale che l’utente sta cercando di ottenere. Se l’autorità di
certificazione si convince che l’utente sia effettivamente chi afferma di
essere, l’utente fornisce all’autorità di certificazione la propria chiave
di crittografia pubblica su un canale sicuro. L’autorità di certificazione
utilizza queste informazioni per creare un certificato digitale che
contiene informazioni sull’identità dell’utente e sulla sua chiave
pubblica. Questo certificato è firmato digitalmente dall’autorità di
certificazione. L’utente può quindi mostrare il proprio certificato a
chiunque voglia verificare la sua identità, senza doverlo inviare tramite
un canale sicuro, in quanto il certificato stesso non contiene alcuna
informazione sensibile. Chi riceve il certificato non è costretto a
verificare l’identità dell’utente: può semplicemente verificare che il
certificato sia valido, verificando la firma digitale dell’autorità di
certificazione, la quale convalida che la chiave pubblica contenuta nel
certificato appartiene alla persona o all’organizzazione indicata sul
certificato stesso.
NOTA
La chiave privata emessa dall’autorità di certificazione per un’organizzazione è
l’anello più debole della catena dell’infrastruttura PKI. Se un soggetto entra in
possesso della chiave privata di Microsoft, per esempio, può installare software
dannoso su milioni di computer in tutto il mondo, simulando un aggiornamento
di Windows.

Esempio: problemi di sicurezza


nella distribuzione di un modello di
machine learning
Nel Capitolo 6 abbiamo esaminato il ciclo di vita CRISP-DM, che
specifica le diverse fasi di addestramento e implementazione di un
modello di machine learning. Una volta che il modello è stato
addestrato e valutato, la fase finale è la distribuzione. Se si tratta di un
modello critico, vogliamo assicurarci che risponda a tutti i suoi
obiettivi di sicurezza.
Analizziamo le problematiche legate all’implementazione di un
modello come questo e vediamo come possiamo affrontare le sfide che
pongono utilizzando i concetti trattati in questo capitolo. Esamineremo
le strategie per proteggere il nostro modello addestrato dai seguenti
attacchi:
attacco Man-in-the-Middle (MITM);
attacco masquerading;
alterazione dei dati.

Attacchi Man-in-the-Middle
Una delle minacce da cui vorremmo proteggere il nostro modello
sono gli attacchi Man-in-the-Middle, che si verificano quando un
intruso tenta di intercettare una comunicazione privata relativa alla
distribuzione di un modello di machine learning addestrato.
Proviamo a capire come funzionano gli attacchi Man-in-the-Middle
utilizzando uno scenario di esempio. Supponiamo che Bob e Alice
vogliano scambiarsi messaggi usando un’infrastruttura a chiave
privata, PKI.
1. Bob sta usando {PrBob, PuBob} e Alice sta usando {PrAlice, PuAlice}.
Bob ha creato un messaggio, MBob, e Alice ha creato un
messaggio, MAlice ed entrambi vogliono scambiarsi questi
messaggi in modo sicuro.
2. Inizialmente, devono scambiarsi le chiavi pubbliche, per stabilire
una connessione sicura. Ciò significa che Bob utilizza PuAlice per
crittografare MBob prima di inviare il messaggio ad Alice.
3. Supponiamo di avere un estraneo, X, che usa {PrX, PuX}. Egli è in
grado di intercettare gli scambi di chiavi pubbliche fra Bob e
Alice e di sostituirle con il proprio certificato pubblico.
4. Bob invia MBob ad Alice, crittografandolo con PuX invece di usare
PuAlice, ritenendolo, erroneamente, il certificato pubblico di Alice.
L’estraneo X intercetta la comunicazione. Intercetta il messaggio
MBob e lo decifra utilizzando PrBob.
Questo attacco Man-in-the-Middle è rappresentato nella Figura 12.7.
Ora, vediamo come possiamo prevenire questi tipi di attacchi.

Figura 12.7

Come prevenire gli attacchi Man-in-the-Middle


Vediamo come possiamo prevenire gli attacchi Man-in-the-Middle
introducendo nel meccanismo un’autorità di certificazione. Diciamo
che il nome di questa autorità è myTrustCA. Il certificato digitale
contiene la sua chiave pubblica, denominata PumyTrustCA. myTrustCA è
responsabile della firma dei certificati per tutti gli attori coinvolti,
inclusi Alice e Bob. Ciò significa che sia Bob sia Alice hanno i loro
certificati firmati da myTrustCA. Quando emette i suoi certificati,
myTrustCA verifica che gli utenti siano effettivamente chi affermano
di essere.
Ora, con questa nuova autorità in gioco, rivediamo l’interazione
sequenziale fra Bob e Alice:
1. Bob sta usando {PrBob, PuBob} e Alice sta usando {PrAlice, PuAlice}.
Le loro chiavi pubbliche sono incorporate nei loro certificati
digitali, firmati da myTrustCA. Bob crea un messaggio, MBob, e
Alice crea un messaggio, MAlice e vogliono scambiarsi questi
messaggi in modo sicuro.
2. Scambiano i loro certificati digitali, che contengono le loro chiavi
pubbliche. Accetteranno solo chiavi pubbliche che siano
incorporate in certificati firmati dall’autorità di certificazione di
cui si fidano. Per stabilire una connessione sicura devono
scambiare le loro chiavi pubbliche. Ciò significa che Bob
utilizzerà PuAlice per crittografare il proprio messaggio MBob prima
di inviarlo ad Alice.
3. Supponiamo che un estraneo, X, stia usando {PrX, PuX} e sia in
grado di intercettare gli scambi di chiavi pubbliche fra Bob e
Alice e di sostituirli con il proprio certificato pubblico, PuX.
4. Bob rifiuta il tentativo di X, poiché il certificato digitale del
malintenzionato non è firmato dall’autorità di certificazione di cui
si fida. L’handshake sicuro viene interrotto, il tentativo di attacco
viene registrato con un timestamp e tutti i dettagli e viene
sollevata un’eccezione relativa alla sicurezza.
Quando si distribuisce un modello di machine learning addestrato, al
posto di Alice, c’è un server di distribuzione. Bob distribuisce il
modello solo dopo aver stabilito un canale sicuro, utilizzando i
passaggi menzionati in precedenza.
Vediamo come possiamo implementarlo in Python.
Per prima cosa importiamo i pacchetti necessari.
from xmlrpc.client import SafeTransport, ServerProxy

import ssl

Ora creiamo una classe che possa verificare il certificato.


class CertVerify(SafeTransport):
def __init__(self, cafile, certfile = None, keyfile = None):
SafeTransport.__init__(self)
self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
self._ssl_context.load_verify_locations(cafile)
if cert:
self._ssl_context.load_cert_chain(certfile, keyfile)
self._ssl_context.verify_mode = ssl.CERT_REQUIRED
def make_connection(self, host):
s = super().make_connection((host, {'context': self._ssl_context}))
return s
# Creail client proxy
s = ServerProxy('https://cloudanum.com:15000',

transport =VerifyCertSafeTransport('server_cert.pem'), allow_none = True)

Esaminiamo altre vulnerabilità che il nostro modello distribuito può


dover affrontare.

Attacchi masquerading
L’estraneo X finge di essere un utente autorizzato, Bob, e ottiene
l’accesso ai dati sensibili, che in questo caso è il nostro modello
addestrato. Dobbiamo proteggere il modello da eventuali modifiche
non autorizzate.
Un modo per proteggere il nostro modello addestrato da questo
attacco masquerading è crittografarlo con la chiave privata di un
utente autorizzato. Una volta crittografato, chiunque può leggere e
utilizzare il modello decifrandolo tramite la chiave pubblica dell’utente
autorizzato, che si trova nel suo certificato digitale. Nessuno, però può
apportarvi modifiche non autorizzate.

Crittografia di dati e modelli


Una volta distribuito il modello, anche i dati non etichettati prodotti
in tempo reale e forniti in input al modello possono essere manomessi.
Il modello addestrato viene utilizzato per ottenere inferenze e per
fornire un’etichetta a questi dati. Per proteggere i dati dalla
manomissione, è necessario considerare sia i dati inattivi sia quelli in
transito. Per proteggere i primi, potete usare la crittografia simmetrica.
Per trasferire in modo sicuro i dati, potete stabilire canali sicuri basati
su SSL/TLS per impiegare un tunnel sicuro, che può essere utilizzato
per trasferire la chiave simmetrica e i dati, che possono essere
decrittografati sul server prima di essere forniti al modello addestrato.
Questo è uno dei modi più efficienti e infallibili per proteggere i dati
dalla manomissione.
La crittografia simmetrica può essere utilizzata anche per proteggere
un modello addestrato, prima di distribuirlo su un server. Ciò impedirà
qualsiasi accesso non autorizzato al modello prima che venga
distribuito.
Vediamo come possiamo crittografare, utilizzando la crittografia
simmetrica, un modello addestrato e poi decrittografarlo una volta
giunto a destinazione, prima di utilizzarlo.
1. Per prima cosa addestriamo un modello utilizzando il dataset Iris:
import cryptography as crypt
from sklearn.linear_model
import LogisticRegression
from cryptography.fernet
import Fernet from sklearn.model_selection
import train_test_split
from sklearn.datasets import load_iris
iris = load_iris()
X = iris.data
y = iris.target
X_train, X_test, y_train, y_test = train_test_split(X, y)
model = LogisticRegression()

model.fit(X_train, y_train)

2. Definiamo i nomi dei file che conterranno il modello:


filename_source = 'myModel_source.sav'
filename_destination = "myModel_destination.sav"

filename_sec = "myModel_sec.sav"

Qui, filename_source è il file che memorizzerà il modello addestrato,


non crittografato, nell’origine; filename_destination è il file che
memorizzerà il modello addestrato, non crittografato, nella
destinazione; filename_sec è il modello addestrato crittografato.
3. Useremo pickle per memorizzare il modello addestrato in un file:
from pickle import dump dump(model, open(filename_source, 'wb'))

4. Definiamo una funzione write_key() che genererà una chiave


simmetrica e la memorizzerà nel file key.key:
def write_key():
key = Fernet.generate_key()
with open("key.key", "wb") as key_file:

key_file.write(key)

5. Definiamo la funzione load_key(), che può leggere la chiave


memorizzata dal file key.key:
def load_key():

return open("key.key", "rb").read()

6. Quindi, definiamo la funzione encrypt(), in grado di crittografare e


addestrare il modello e archiviarlo nel file filename_sec:
def encrypt(filename, key):
f = Fernet(key)
with open(filename_source, "rb") as file:
file_data = file.read()
encrypted_data = f.encrypt(file_data)
with open(filename_sec, "wb") as file:

file.write(encrypted_data)

7. Useremo queste funzioni per generare una chiave simmetrica e


memorizzarla in un file. Quindi, leggeremo questa chiave e la
useremo per memorizzare nel file filename_sec il nostro modello
addestrato:
write_key()

encrypt(filename_source, load_key())

Ora il modello è crittografato e verrà trasferito alla destinazione,


dove verrà utilizzato per effettuare le previsioni.
1. Innanzitutto, definiamo la funzione decrypt(), che possiamo
utilizzare per decifrare il modello da filename_sec a
filename_destination utilizzando la chiave memorizzata nel file
key.key :
def decrypt(filename, key):
f = Fernet(key)
with open(filename_sec, "rb") as file:
encrypted_data = file.read()
decrypted_data = f.decrypt(encrypted_data)
with open(filename_destination, "wb") as file:

file.write(decrypted_data)

2. Usiamo questa funzione per decifrare il modello e salvarlo nel file


filename_destination:

decrypt(filename_sec, load_key())

3. Usiamo questo file non crittografato per caricare il modello e


utilizzarlo per le previsioni:

Notate che per codificare il modello abbiamo usato la crittografia


simmetrica. La stessa tecnica può essere utilizzata anche per
crittografare i dati, se necessario.
Riepilogo
In questo capitolo abbiamo parlato degli algoritmi crittografici.
Abbiamo iniziato identificando gli obiettivi di sicurezza di un
problema. Abbiamo quindi trattato varie tecniche crittografiche e
abbiamo esaminato i dettagli dell’infrastruttura PKI. Infine, abbiamo
esaminato i diversi modi per proteggere dagli attacchi un modello di
machine learning addestrato. Ora dovreste essere in grado di
comprendere i fondamenti degli algoritmi di sicurezza utilizzati per
proteggere le moderne infrastrutture IT.
Nel prossimo capitolo, esamineremo la progettazione di algoritmi
per dati su larga scala. Studieremo le problematiche e i compromessi
coinvolti nella progettazione e selezione di questi tipi di algoritmi.
Vedremo anche l’uso delle GPU e dei cluster per risolvere problemi
complessi.
Capitolo 13

Algoritmi per dati su larga


scala

Questi algoritmi sono progettati per risolvere problemi complessi e


di grandi dimensioni. La caratteristica specifica degli algoritmi per dati
su larga scala è la loro necessità di avere più motori di esecuzione, a
causa della larga scala dei loro dati e dei requisiti di elaborazione.
Questo capitolo inizia discutendo i tipi di algoritmi più adatti per
essere eseguiti in parallelo. Quindi, si occupa dei problemi relativi alla
parallelizzazione degli algoritmi. Successivamente, presenta
l’architettura CUDA (Compute Unified Device Architecture) e spiega
come utilizzare una singola unità di elaborazione grafica (GPU) o più
GPU per accelerare gli algoritmi. Discute anche delle modifiche da
apportare all’algoritmo per sfruttare efficacemente la potenza della
GPU. Infine, il capitolo tratta il cluster computing e il modo in cui
Apache Spark può creare dataset resilienti distribuiti (RDD) per creare
un’implementazione parallela estremamente veloce degli algoritmi
standard.

Introduzione agli algoritmi per dati


su larga scala
Gli esseri umani amano le sfide. Per secoli, varie innovazioni umane
ci hanno permesso di risolvere problemi davvero complessi in modi
differenti. Dalla previsione della prossima area colpita da un attacco di
locuste al calcolo del numero primo più grande, i metodi per fornire
risposte a problemi complessi intorno a noi hanno continuato a
evolversi. Con l’avvento dei computer, abbiamo trovato un nuovo e
potente modo per risolvere gli algoritmi complessi.

Che cos’è un algoritmo su larga scala


ben progettato
Un algoritmo su larga scala ben progettato ha le seguenti due
caratteristiche:
è progettato per gestire un’enorme quantità di dati e requisiti di
elaborazione, utilizzando in modo ottimale le risorse disponibili;
è scalabile, ovvero a mano a mano che il problema diventa più
complesso, può gestire l’aumentata complessità impiegando
semplicemente più risorse.
Uno dei modi più pratici per implementare algoritmi per dati su
larga scala consiste nell’utilizzare la strategia divide et impera, ovvero
dividere il grosso problema in problemi più piccoli, che possano essere
risolti indipendentemente con maggiore facilità.

Terminologia
Esaminiamo alcuni dei termini che possono essere utilizzati per
quantificare la qualità degli algoritmi per dati su larga scala.

Latenza
La latenza è il tempo end-to-end impiegato per eseguire un singolo
calcolo. Se Compute1 rappresenta un singolo calcolo che inizia in t1 e
termina in t2, allora possiamo dire quanto segue:
Latenza = t2 – t1

Throughput (produttività)
Nel contesto del calcolo parallelo, il throughput è il numero di
singoli calcoli che possono essere eseguiti contemporaneamente. Per
esempio, se nel tempo t1 possiamo eseguire quattro calcoli simultanei,
C1, C2, C3 e C4, allora il throughput è 4.

Larghezza di banda di bisezione della rete


La larghezza di banda possibile fra due parti uguali di una rete è
detta larghezza di banda di bisezione della rete. Affinché il calcolo
distribuito sia efficiente, questo è il parametro più importante da
considerare. Se non disponiamo di una sufficiente larghezza di banda
di bisezione della rete, i vantaggi offerti dalla disponibilità di più
motori di esecuzione del calcolo distribuito saranno frenati dalla
lentezza dei collegamenti.

Elasticità
La capacità dell’infrastruttura di reagire a un improvviso aumento
dei requisiti di elaborazione, fornendo più risorse.
NOTA
I tre colossi del cloud computing, Google, Amazon e Microsoft, possono fornire
infrastrutture altamente elastiche. A causa delle dimensioni gigantesche dei loro
pool di risorse condivise, sono pochissime le aziende che hanno la possibilità di
eguagliare l’elasticità delle infrastrutture offerte da queste tre società.

Se l’infrastruttura è elastica, è in grado di creare una soluzione


scalabile al problema.

Progettazione di algoritmi paralleli


È importante notare che gli algoritmi paralleli non sono una
soluzione tuttofare. Anche le migliori architetture parallele progettate
potrebbero non fornire le prestazioni che potremmo aspettarci. Una
regola ampiamente utilizzata per progettare algoritmi paralleli è la
legge di Amdahl.

Legge di Amdahl
Gene Amdahl è stato uno dei primi a studiare l’elaborazione
parallela negli anni Sessanta, quando sviluppò la legge di Amdahl, che
ancora oggi è applicabile e può fungere da base per comprendere i vari
compromessi legati alla progettazione di una soluzione di calcolo
parallelo. La legge di Amdahl può essere spiegata come segue.
Si basa sul concetto che in qualsiasi processo informatico, non tutti i
processi possono essere eseguiti in parallelo. Ci sarà sempre una parte
sequenziale del processo che non può essere parallelizzata.
Vediamo un esempio: supponiamo di voler leggere un numero
elevato di file archiviati su un computer e di voler addestrare un
modello di machine learning utilizzando i dati trovati in questi file.
L’intero processo è chiamato P. È ovvio che P può essere suddiviso
nei due sottoprocessi seguenti:
P1 scansiona i file nella directory, crea una lista di nomi di file che
corrispondono al file di input e li passa avanti;
P2 legge i file, crea la pipeline di elaborazione dati, elabora i file e
addestra il modello.

Condurre l’analisi di processi sequenziali


Il tempo per eseguire P è rappresentato da Tseq(P ). I tempi per
eseguire P1 e P2 sono rappresentati da Tseq(P1) e Tseq(P2). È ovvio che,
quando disponiamo di un solo nodo, possiamo osservare due cose:
P2 non può iniziare a funzionare prima che P1 sia completo.
Questo è rappresentato da P1 --> P2;
Tseq(P ) = Tseq(P1) + Tseq(P2)
Supponiamo che P impieghi complessivamente 11 minuti per essere
eseguito su un singolo nodo. Di questi 11 minuti, P1 impiega 2 minuti
e P2 impiega 9 minuti. Ciò è rappresentato nella Figura 13.1.

Figura 13.1

Ora la cosa importante da notare è che P1 ha una natura sequenziale.


Non possiamo far sì che sia più veloce rendendolo parallelo. Al
contrario, P2 può essere facilmente suddiviso in più attività che
possono essere eseguite in parallelo. Quindi, possiamo accelerare
questo processo dell’algoritmo eseguendolo in parallelo.
NOTA
Il principale vantaggio dell’utilizzo del cloud computing è la disponibilità di un
ampio pool di risorse e dal loro utilizzo in parallelo. Il piano per utilizzare queste
risorse per risolvere un determinato problema è chiamato piano di esecuzione.
La legge di Amdahl viene utilizzata per identificare i colli di bottiglia dato un
problema e un pool di risorse per risolverlo.

Condurre analisi di esecuzione parallela


Se vogliamo utilizzare più nodi per accelerare P, ciò influenzerà
solo P2 di un fattore s > 1:
L’accelerazione del processo P può essere facilmente calcolata come
segue:

Il rapporto fra la parte parallelizzabile di un processo e il suo totale è


rappresentato da b e si calcola come segue:

Per esempio, nello scenario precedente, b = 9 / 11 ≃ 0,8.


Semplificando queste equazioni otterremo la legge di Amdahl:

Abbiamo quanto segue:


P è il processo complessivo;
b è il rapporto fra la porzione parallelizzabile di P;
s è la velocità raggiunta nella porzione parallelizzabile di P.
Supponiamo di voler eseguire il processo P su tre nodi paralleli.
P1 è la porzione sequenziale e non può essere ridotta utilizzando
nodi paralleli. Rimarrà a 2 secondi.
P2 ora impiega 3 secondi invece di 9. Quindi, il tempo totale
impiegato dal processo P si riduce a 5 secondi, come mostrato
nella Figura 13.2.
Figura 13.2

Nell’esempio precedente, possiamo calcolare quanto segue:


np è il numero di processori = 3;
b è la porzione parallela = 9 / 11 = 81,81%;
s è l’accelerazione ottenuta = 3.
La Figura 13.3 mostra un grafico tipico, che spiega la legge di
Amdahl e che traccia il grafico fra s e np per i diversi valori di b.

Granularità delle attività


Quando parallelizziamo un algoritmo, un lavoro più grande viene
diviso in più attività parallele. Non sempre è facile trovare il numero
ottimale di attività parallele in cui suddividere un lavoro: troppe poche
attività e non sfrutteremo appieno i benefici dal calcolo parallelo;
troppe attività e avremo un sovraccarico eccessivo. Questa è una sfida
di granularità delle attività.
Figura 13.3

Bilanciamento del carico


Nel calcolo parallelo, vi è uno scheduler responsabile della
selezione delle risorse per eseguire le varie attività. Il bilanciamento
ottimale del carico è una cosa difficile da raggiungere, ma in sua
assenza le risorse non verranno utilizzate appieno.

Localizzazione dei dati


Nell’elaborazione parallela, lo spostamento dei dati dovrebbe essere
scoraggiato. Quando possibile, i dati, invece di essere spostati,
dovrebbero essere elaborati localmente, sul nodo in cui risiedono, in
quanto ogni spostamento ridurrebbe la qualità della parallelizzazione.

Elaborazione concorrente in Python


Il modo più semplice per abilitare l’elaborazione parallela in Python
consiste nel clonare un processo, che avvierà un nuovo processo
concorrente, chiamato processo figlio.
NOTA
I programmatori Python hanno denominato il processo “clonazione”. Proprio
come una pecora clonata, il processo clone è una copia esatta del processo
originale.

Strategia di elaborazione multi-


risorsa
Inizialmente, sono stati utilizzati algoritmi per dati su larga scala,
operanti su enormi macchine, chiamate supercomputer. Questi
supercomputer avevano un unico spazio di memoria. Le loro risorse
erano tutte locali, fisicamente collocate nella stessa macchina. Questo
significa che le comunicazioni fra i vari processori erano molto veloci
e una variabile era condivisa in quanto si trovava nello stesso spazio di
memoria, comune. A mano a mano che i sistemi si evolvevano e
cresceva la necessità di eseguire algoritmi per dati su larga scala, i
supercomputer si sono evoluti in DSM (Distributed Shared Memory)
in cui ogni nodo di elaborazione possedeva una parte della memoria
fisica. Alla fine, sono stati sviluppati i cluster, che sono meno
accoppiati e si basano sullo scambio di messaggi fra i nodi di
elaborazione. Per algoritmi per dati su larga scala, per risolvere un
problema complesso (Figura 13.4) abbiamo bisogno di poter contare
su più motori di esecuzione in esecuzione in parallelo.
Figura 13.4

Ci sono tre strategie per gestire più motori di esecuzione.


Look within: sfrutta le risorse già presenti sul computer. Usa le
centinaia di core della GPU per eseguire un algoritmo su larga
scala.
Look outside: usa il calcolo distribuito per trovare più risorse di
elaborazione da utilizzare collettivamente per risolvere il
problema su larga scala in questione.
Strategia ibrida: utilizza il calcolo distribuito e, su ciascuno dei
nodi, utilizza la GPU o una serie di GPU per accelerare
l’esecuzione dell’algoritmo.

Che cos’è CUDA


Le GPU sono state progettate originariamente per l’elaborazione
grafica, per soddisfare le esigenze di gestione ottimale dei dati
multimediali su un computer. Per fare ciò, hanno sviluppato alcune
caratteristiche che le differenziano dalle CPU. Per esempio, offrono
migliaia di core rispetto al piccolo numero di core della CPU. La loro
velocità di clock è molto più lenta di una CPU. Una GPU ha una sua
DRAM: per esempio, la scheda RTX 2080 di Nvidia ha 8 GB di RAM.
Tenete presente che le GPU sono dispositivi di elaborazione
specializzati e non dispongono di alcune funzioni tipiche delle unità di
elaborazione, fra cui gli interrupt, o di mezzi per connettersi a
dispositivi, come una tastiera o un mouse. La Figura 13.5 mostra la
tipica architettura delle GPU.

Figura 13.5

Poco dopo la diffusione delle GPU, i data scientist hanno iniziato a


esplorarne le potenzialità per eseguire in modo efficiente le operazioni
parallele. Poiché una tipica GPU contiene migliaia di unità aritmetico-
logiche (ALU), può potenzialmente generare migliaia di processi
concorrenti. Ciò rende le GPU l’architettura migliore per il calcolo
parallelo dei dati. Pertanto, gli algoritmi in grado di eseguire calcoli
paralleli sono particolarmente adatti alle GPU. Per esempio, è noto che
una ricerca di oggetti in un video diviene almeno 20 volte più veloce
se eseguita su una GPU e non su una CPU. Gli algoritmi per grafi,
trattati nel Capitolo 5, sono noti per essere eseguiti molto più
velocemente su GPU che su CPU.
Per realizzare il sogno dei data scientist di poter sfruttare appieno le
GPU per gli algoritmi, nel 2007 Nvidia ha creato un framework open
source chiamato CUDA (Compute Unified Device Architecture).
CUDA astrae il funzionamento della CPU e della GPU,
considerandole rispettivamente host e dispositivo. L’host, ovvero la
CPU, è responsabile della gestione del dispositivo, che è la GPU.
L’architettura CUDA offre vari livelli di astrazione, rappresentati nella
Figura 13.6.
Notate che CUDA funziona su GPU Nvidia e ha bisogno del
supporto nel kernel del sistema operativo. CUDA ha offerto
inizialmente il supporto nel kernel Linux, ma più recentemente ha
offerto il supporto per Windows. Quindi, abbiamo l’API del driver
CUDA, che funge da ponte fra l’API del linguaggio di
programmazione e il driver CUDA. A livello superiore, abbiamo il
supporto per C, C+ e Python.
Figura 13.6

Progettare algoritmi paralleli per CUDA


Esaminiamo più da vicino il modo in cui una GPU accelera
determinate operazioni di elaborazione. Come sappiamo, le CPU sono
progettate per l’elaborazione sequenziale dei dati, che si traduce in
tempi di esecuzione significativi per determinate classi di applicazioni.
Esaminiamo l’esempio dell’elaborazione di un’immagine di
dimensioni 1.920 × 1.080 pixel. Si tratta di oltre 2 milioni di pixel da
elaborare. Se l’elaborazione è sequenziale, ci vorrà molto tempo per
elaborarli su una CPU tradizionale. Le nuove GPU, come Tesla di
Nvidia, sono in grado di generare questa incredibile quantità di 2
milioni di thread paralleli per elaborare i pixel. Per la maggior parte
delle applicazioni multimediali, i pixel possono essere elaborati
indipendentemente l’uno dall’altro e ciò si tradurrà in una notevole
accelerazione. Se mappiamo ogni pixel a un thread, tutti i pixel
potranno essere elaborati in un tempo costante O(1).
Ma l’elaborazione delle immagini non è l’unica applicazione in cui
possiamo sfruttare il parallelismo per accelerare il processo. Il
parallelismo può essere utilizzato nella preparazione dei dati per le
librerie di machine learning. In effetti, la GPU può ridurre
enormemente il tempo di esecuzione degli algoritmi parallelizzabili,
fra cui i seguenti:
mining di bitcoin;
simulazioni su larga scala;
analisi del DNA;
analisi di video e foto.
Le GPU non sono progettate per attività SPMD (Single Program,
Multiple Data). Per esempio, se vogliamo calcolare il codice hash di
un blocco di dati, dovrà occuparsene un singolo programma, che non
può essere eseguito in parallelo. Le GPU finirebbero per essere più
lente in tali scenari.
NOTA
Il codice che vogliamo eseguire sulla GPU è contrassegnato da speciali parole
chiave CUDA chiamate kernel. Questi kernel vengono utilizzati per
contrassegnare le funzioni che intendiamo eseguire sulle GPU per
l’elaborazione parallela. In base ai kernel, il compilatore GPU separa il codice
che deve essere eseguito su GPU da quello che deve essere eseguito su CPU.

Utilizzo delle GPU per l’elaborazione dei dati in Python


Le GPU sono ottime per l’elaborazione di dati collocati in una
struttura di dati multidimensionale, ovvero strutture intrinsecamente
parallelizzabili. Vediamo come possiamo utilizzare la GPU per
l’elaborazione di dati multidimensionali in Python.
1. Per prima cosa, importiamo i pacchetti Python necessari:
import numpy as np
import cupy as cp
import time

2. Useremo un array multidimensionale in NumPy, che è un


pacchetto Python tradizionale che utilizza la CPU.
3. Creiamo un array multidimensionale utilizzando un array CuPy,
che utilizza la GPU. Poi confronteremo i tempi di esecuzione:
### Esecuzione su CPU con Numpy
start_time = time.time()
myvar_cpu = np.ones((800, 800, 800))
end_time = time.time()
print(end_time – start_time)
### Esecuzione su GPU con CuPy
start_time = time.time()
myvar_gpu = cp.ones((800, 800, 800))
cp.cuda.Stream.null.synchronize()
end_time = time.time()

print(end_time – start_time)

Otterremo il seguente output:

Notate che, per creare questo array, NumPy ha impiegato circa 1,13
secondi e CuPy circa 0,012 secondi: l’inizializzazione di questo array è
92 volte più veloce su GPU.

Cluster computing
Il cluster computing è uno dei modi per implementare l’elaborazione
parallela di algoritmi per dati su larga scala. Nel cluster computing,
abbiamo più nodi collegati tramite una rete ad altissima velocità. Gli
algoritmi per dati su larga scala vengono inviati come job. Ogni job è
suddiviso in più task e ciascun task viene eseguito su un nodo.
Apache Spark è uno dei modi più diffusi per implementare il cluster
computing. In Apache Spark, i dati vengono convertiti in dataset fault-
tolerant distribuiti, chiamati dataset resilienti distribuiti (RDD), che
sono l’astrazione principale di Apache Spark. Sono raccolte
immutabili di elementi che possono essere gestite in parallelo. Questi
dataset sono divisi in partizioni e distribuiti fra i nodi, come vediamo
nella Figura 13.7). Attraverso questa struttura di dati parallela,
possiamo eseguire gli algoritmi in parallelo.

Figura 13.7

Implementazione dell’elaborazione dei dati in Apache Spark


Vediamo come possiamo creare un dataset resiliente distribuito in
Apache Spark ed elaborarlo in modo distribuito sul cluster.
1. In primo luogo, dobbiamo creare una nuova sessione Spark:
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName('cloudanum').getOrCreate()

2. Una volta creata la sessione Spark, utilizziamo un file CSV per


l’origine del dataset resiliente distribuito. Quindi, eseguiremo la
seguente funzione, che creerà un dataset resiliente distribuito
astratto con un DataFrame chiamato df. La possibilità di astrarre
un dataset resiliente distribuito come un DataFrame è stata
aggiunta in Spark 2.0 e questo semplifica molto l’elaborazione
dei dati:
df = spark.read.csv('taxi2.csv', inferSchema = True, header = True)

Esaminiamo le colonne del dataframe:

3. Successivamente, possiamo creare una tabella temporanea dal


DataFrame, come segue:
df.createOrReplaceTempView("main")

4. Una volta creata la tabella temporanea, possiamo eseguire le


istruzioni SQL necessarie per elaborare i dati:
Un concetto importante da notare è che sebbene sembri un normale
DataFrame, questa è solo una struttura di dati di alto livello. Sotto il
cofano, è il dataset resiliente distribuito a diffondere i dati attraverso il
cluster. Allo stesso modo, quando eseguiamo funzioni SQL, queste
vengono convertite in trasformatori e riduttori paralleli, e utilizzano
appieno la potenza del cluster per elaborare il codice.

Strategia ibrida
Il cloud computing si sta diffondendo sempre più per eseguire
algoritmi per dati su larga scala. Questo ci offre l’opportunità di
combinare le strategie look outside e look within. Possiamo farlo
impiegando una o più GPU in più macchine virtuali (Figura 13.8).
Sfruttare al meglio l’architettura ibrida è un compito non banale.
Occorre suddividere i dati in più partizioni. Le attività a elevata
intensità di calcolo che richiedono meno dati vengono parallelizzate
all’interno di ciascun nodo tramite le GPU.
Figura 13.8

Riepilogo
In questo capitolo, abbiamo esaminato la progettazione di algoritmi
paralleli e le problematiche di progettazione di algoritmi per dati su
larga scala e abbiamo esaminato l’utilizzo del calcolo parallelo e delle
GPU per implementarli. Abbiamo anche esaminato come utilizzare i
cluster Spark per implementare questo genere di algoritmi.
Abbiamo accennato anche alle problematiche degli algoritmi per
dati su larga scala, esaminando i problemi relativi alla
parallelizzazione degli algoritmi e i potenziali colli di bottiglia che si
possono formare.
Nel prossimo capitolo esamineremo alcuni aspetti pratici
dell’implementazione di algoritmi.
Capitolo 14

Considerazioni pratiche

Questo libro ha presentato molti algoritmi che possono essere usati


per risolvere problemi concreti. Quest’ultimo capitolo contiene alcune
considerazioni pratiche su tali algoritmi.
Dopo un’introduzione, esamineremo l’importante argomento della
spiegabilità di un algoritmo, che è il grado in cui i meccanismi di un
algoritmo possono essere spiegati in termini comprensibili. Quindi,
esamineremo l’etica dell’utilizzo di un algoritmo e la possibilità di
creare implementazioni distorte, affette da bias. Successivamente,
discuteremo le tecniche per la gestione dei problemi NP-difficili.
Infine, esamineremo i fattori da considerare prima di scegliere un
algoritmo.

Introduzione
Oltre a progettare, sviluppare e infine sottoporre a test un algoritmo,
in molti casi è importante considerare alcuni aspetti pratici del fatto di
iniziare a fare affidamento su una macchina per risolvere un problema
concreto. Per alcuni algoritmi, potrebbe essere necessario prendere in
considerazione dei modi per incorporare in modo affidabile anche le
nuove informazioni importanti che dovessero cambiare dopo aver
implementato l’algoritmo. L’integrazione di queste nuove informazioni
cambierà in qualche modo la qualità del nostro algoritmo, pur ben
collaudato? Se è così, come possiamo occuparcene in termini
progettuali? E poi, per alcuni algoritmi che utilizzano modelli globali,
potrebbe essere necessario monitorare i parametri in tempo reale che
catturano i cambiamenti nella situazione geopolitica globale. Inoltre, in
alcuni casi d’uso, potrebbe essere necessario considerare le politiche
legislative del caso, affinché la soluzione sia realmente impiegabile.
NOTA
Quando utilizziamo degli algoritmi per risolvere un problema concreto, in un
certo senso ci affidiamo a una macchina per la risoluzione di un problema.
Anche gli algoritmi più sofisticati si basano su semplificazioni e presupposti e
faticano a gestire le situazioni che, per un motivo o per l’altro, sono “speciali”.
Non siamo ancora nemmeno vicini a poterci affidare completamente agli
algoritmi nei processi decisionali critici.

Per esempio, gli algoritmi del motore di raccomandazione progettati


da Google hanno recentemente affrontato le restrizioni normative
dell’Unione Europea a causa di problemi di privacy. Questi algoritmi
sono forse fra i più avanzati nel loro campo. Ma, se vengono banditi,
potrebbero rivelarsi del tutto inutili, in quanto non potrebbero essere
utilizzati per risolvere i problemi per i quali sono stati progettati.
La verità è che, purtroppo, le considerazioni pratiche di un
algoritmo sono ripensamenti a posteriori, che di solito non vengono
presi in considerazione nella fase di progettazione. Per molti casi
d’uso, una volta implementato un algoritmo e terminata l’euforia a
breve termine di aver fornito una soluzione, gli aspetti pratici e le
implicazioni dell’utilizzo di quell’algoritmo verranno scoperti nel
tempo e definiranno il successo o il fallimento del progetto.
Vediamo un esempio pratico in cui, non prestando attenzione alle
considerazioni pratiche, è fallito un progetto di alto profilo, sviluppato
da una delle migliori aziende IT del mondo.

La triste storia di un bot di intelligenza


artificiale per Twitter
Presentiamo l’esempio di Tay, che è stato presentato da Microsoft
nel 2016 come il primo bot di intelligenza artificiale per Twitter.
Essendo gestito da un algoritmo di intelligenza artificiale, Tay avrebbe
dovuto imparare dall’ambiente e continuare a migliorarsi.
Sfortunatamente, dopo aver bazzicato nel cyberspazio per un paio di
giorni, Tay ha iniziato a imparare anche il razzismo e la maleducazione
dai tweet. Così, ben presto ha iniziato a scrivere tweet offensivi.
Sebbene mostrasse intelligenza e imparasse rapidamente a creare tweet
in tempo reale personalizzati sulla base degli eventi, allo stesso tempo
offendeva seriamente le persone. Microsoft lo ha messo offline e ha
provato a rielaborarlo, ma senza successo. Alla fine, Microsoft ha
dovuto abortire il progetto. Quella fu la triste fine di un progetto
ambizioso.
Notate che sebbene l’intelligenza incorporata da Microsoft fosse
impressionante, l’azienda ha ignorato le implicazioni pratiche
dell’implementazione di un bot Twitter ad apprendimento automatico.
Gli algoritmi di elaborazione del linguaggio naturale e di machine
learning possono anche essere i migliori mai sviluppati, ma se hanno
gravi difetti possono rivelarsi del tutto inutili. Oggi, Tay è un esempio
da manuale di un fallimento dovuto al fatto di aver ignorato le
implicazioni pratiche di consentire agli algoritmi di apprendere al volo.
Le lezioni apprese dal fallimento di Tay hanno sicuramente influenzato
i progetti di intelligenza artificiale negli anni successivi. I data scientist
hanno anche iniziato a prestare maggiore attenzione alla trasparenza
degli algoritmi. Questo ci porta al prossimo argomento, che esplora la
necessità di rendere trasparenti gli algoritmi e i modi per farlo.

La spiegabilità di un algoritmo
In un algoritmo “black-box” la logica di funzionamento non è
facilmente interpretabile a causa della sua complessità o perché il suo
funzionamento è “contorto”. Un algoritmo, invece, dovrebbe avere un
comportamento spiegabile e comprensibile. In altre parole, la
spiegabilità aiuta a capire perché un algoritmo sta dando un
determinato risultato. Il grado di spiegabilità misura la comprensibilità
di un algoritmo. Molti algoritmi, soprattutto quelli di machine
learning, sono classificati vere “black-box”. Se tali algoritmi vengono
impiegati per prendere decisioni critiche, può essere importante
comprendere le ragioni alla base dei risultati da esso prodotti. La
possibilità di estrarre gli algoritmi da questa “scatola nera” fornisce
anche una migliore comprensione del funzionamento interno del
modello. Un algoritmo spiegabile aiuterà i medici a capire quali
elementi sono stati utilizzati per classificare i pazienti come malati o
meno. Se il medico avesse dei dubbi sui risultati, potrebbe così tornare
sui propri passi e ricontrollare quegli specifici elementi, per verificarne
l’accuratezza.

Algoritmi di machine learning e


spiegabilità
La spiegabilità di un algoritmo ha un’importanza particolare per gli
algoritmi di machine learning. In molte applicazioni di machine
learning, agli utenti viene chiesto di fidarsi di un modello, che li aiuta a
prendere decisioni. La spiegabilità dona la trasparenza necessaria in
tali casi d’uso.
Esaminiamo un esempio specifico. Supponiamo di voler utilizzare il
machine learning per prevedere i prezzi delle case nell’area di Boston
in base alle loro caratteristiche. Supponiamo, inoltre, che le normative
locali della città ci consentano di utilizzare algoritmi di machine
learning solo se siamo in grado di giustificare, con informazioni
dettagliate, eventuali previsioni. Queste informazioni sono necessarie,
ai fini dell’audit, per assicurarsi che alcuni segmenti del mercato
immobiliare non vengano manipolati artificialmente. Rendendo
spiegabile il nostro modello addestrato potremo fornire queste
informazioni aggiuntive.
Esaminiamo le diverse opzioni disponibili per implementare la
spiegabilità in un modello addestrato.

Strategie per la spiegabilità


Nel campo del machine learning, abbiamo fondamentalmente due
strategie per donare spiegabilità agli algoritmi:
una strategia di spiegabilità globale fornisce i dettagli relativi
alla formulazione di un modello, nel suo insieme;
una strategia di spiegabilità locale serve a fornire la motivazione
per una o più previsioni fatte dal modello addestrato.
Per offrire una spiegabilità globale, disponiamo di tecniche come
TCAV (Testing with Concept Activation Vectors), che viene utilizzata
per dotare di spiegabilità i modelli di classificazione delle immagini. Il
TCAV dipende dal calcolo delle derivate direzionali per quantificare il
grado di relazione fra il concetto definito dall’utente e la
classificazione delle immagini. Per esempio, quantificherà quanto sia
sensibile la previsione nel classificare una persona come un maschio
dalla presenza di peluria sul viso. Esistono altre strategie di
spiegabilità globale, come i grafi delle dipendenze parziali e il calcolo
dell’importanza della permutazione, che possono aiutare a spiegare le
conclusioni cui è giunto il nostro modello addestrato. Le strategie di
spiegabilità globale e locale possono essere specifiche o indipendenti
dal modello. Le strategie specifiche del modello si applicano a
determinati tipi di modelli, mentre quelle indipendenti dal modello
possono essere applicate a un’ampia varietà di modelli.
La Figura 14.1 riassume le diverse strategie disponibili per la
spiegabilità del machine learning: Ora, vediamo come possiamo
implementare la spiegabilità utilizzando una di queste strategie.

Figura 14.1

Implementare la spiegabilità
LIME (Local Interpretable Model-Agnostic Explanations) è un
approccio indipendente dal modello che può spiegare le singole
previsioni fatte da un modello addestrato. Essendo indipendente dal
modello, può spiegare le previsioni della maggior parte dei modelli di
machine learning addestrati.
LIME spiega le decisioni introducendo piccole modifiche all’input
per ogni istanza e studiando gli effetti causati sul confine decisionale
locale di quell’istanza, iterando un ciclo per fornire i dettagli per ogni
variabile. Osservando l’output, possiamo vedere quale variabile ha la
maggiore influenza su quell’istanza.
Vediamo come possiamo usare LIME per rendere spiegabili le
singole previsioni del modello dei prezzi delle case.
1. Se non avete mai usato LIME, dovrete prima installare il
pacchetto:
!pip install lime

2. A questo punto, importiamo i pacchetti Python di cui abbiamo


bisogno:
import sklearn as sk
import numpy as np

from lime.lime_tabular import LimeTabularExplainer as ex

3. Addestreremo un modello in grado di prevedere i prezzi delle


case in una determinata città. Per questo importeremo prima il
dataset, che è memorizzato nel file housing.pkl. Poi, esploreremo le
feature che contiene:

Sulla base di queste feature dei dati dobbiamo prevedere il prezzo


di una casa.
4. Addestriamo il modello, usando un regressore a foresta casuale.
Prima suddividiamo i dati nelle partizioni di training e di test e
poi li useremo per addestrare il modello:
from sklearn.ensemble import RandomForestRegressor
X_train, X_test, y_train, y_test =
sklearn.model_selection.train_test_split(housing.data, housing.target)
regressor = RandomForestRegressor()

regressor.fit(X_train, y_train)

5. Identifichiamo le colonne delle categorie:


cat_col = [i for i, col in enumerate(housing.data.T)

if np.unique(col).size < 10]


6. Istanziamo l’explainer LIME con i parametri di configurazione
previsti. Notate che specifichiamo che la nostra etichetta è 'price',
che rappresenta i prezzi delle case a Boston:
myexplainer = ex(X_train,
feature_names =housing.feature_names,
class_names = ['price'],
categorical_features = cat_col,

mode = 'regression')

7. Proviamo ora a osservare i dettagli delle previsioni. Importiamo


innanzitutto pyplot per eseguire i tracciamenti con matplotlib:
exp = myexplainer.explain_instance(X_test[25], regressor.predict,
num_features = 10)
exp.as_pyplot_figure()
from matplotlib import pyplot as plt

plt.tight_layout()

8. Poiché l’explainer LIME opera sulle singole previsioni,


dobbiamo scegliere quelle che vogliamo analizzare. Abbiamo
chiesto all’explainer la sua giustificazione delle previsioni
indicizzate con 1 e 35:
Proviamo ad analizzare questa spiegabilità offerta da LIME, che ci
dice quanto segue.
L’elenco delle feature utilizzate nelle singole previsioni: sono
indicate sull’asse y della figura precedente.
L’importanza relativa delle feature nel determinare la decisione:
più lunga è la barra, maggiore è l’importanza. Il valore del
numero è riportato sull’asse x.
L’influenza positiva o negativa di ciascuna delle feature di input
sull’etichetta: le barre in rosso mostrano un’influenza negativa e
le barre in verde mostrano l’influenza positiva di una determinata
feature.

Algoritmi ed etica
La formulazione di modelli attraverso algoritmi può comportare,
direttamente o indirettamente, un processo decisionale non etico.
Durante la progettazione di algoritmi, è difficile prevedere l’intera
portata delle potenziali implicazioni etiche, in particolare per gli
algoritmi per dati su larga scala, in cui il progetto può coinvolgere più
utenti. Ciò rende ancora più difficile analizzare gli effetti della
soggettività umana.
NOTA
Sempre più aziende stanno inserendo l’analisi etica di un algoritmo fra le fasi
della sua progettazione. Ma la verità è che i problemi potrebbero non
manifestarsi finché non troveremo un caso problematico.

Problemi con gli algoritmi ad


apprendimento
Gli algoritmi in grado di autoregolarsi in base ai modelli di dati
mutevoli sono chiamati algoritmi ad apprendimento. Operano in
modalità di apprendimento in tempo reale, ma questa loro capacità può
avere implicazioni etiche. Questo crea la possibilità che il loro
apprendimento possa tradursi in decisioni che possono avere gravi
implicazioni etiche. Essendo creati per essere in costante fase
evolutiva, è quasi impossibile eseguirne un’analisi etica costante.
NOTA
A mano a mano che cresce la complessità degli algoritmi, diventa sempre più
difficile comprendere appieno le loro implicazioni a lungo termine per i singoli
individui e i gruppi all’interno della società.

Le implicazioni etiche
Le soluzioni algoritmiche sono formulazioni matematiche senza
anima. È responsabilità delle persone che le sviluppano garantire che
siano conformi alle implicazioni etiche legate al problema che stiamo
cercando di risolvere. Queste implicazioni etiche degli algoritmi
dipendono dal tipo di algoritmo.
Per esempio, esaminiamo i seguenti algoritmi e le loro implicazioni
etiche. Alcuni esempi di potenti algoritmi per i quali sono necessarie
attente considerazioni etiche sono i seguenti:
gli algoritmi di classificazione, quando applicati alla società,
determinano il modo in cui vengono individuati e gestiti gli
individui e i gruppi;
gli algoritmi utilizzati nei motori di raccomandazione possono
abbinare i curricula alle offerte di lavoro, operando con dinamiche
individuali e di gruppo;
gli algoritmi di data mining vengono utilizzati per estrarre
informazioni dagli utenti e i loro risultati vengono forniti a
decisori privati e pubblici;
gli algoritmi di machine learning stanno iniziando a essere
utilizzati dai governi per concedere o negare i visti ai richiedenti.
Quindi, le considerazioni etiche sugli algoritmi dipendono dal caso
d’uso e dalle entità che essi influenzano, direttamente o indirettamente.
È necessaria un’attenta analisi da un punto di vista etico prima di
iniziare a impiegare un algoritmo per i processi decisionali aventi
implicazioni etiche. Nei prossimi paragrafi vedremo i fattori che
dovremmo tenere in considerazione durante un’attenta analisi degli
algoritmi.

Prove inconcludenti
I dati utilizzati per addestrare un algoritmo di machine learning
potrebbero non offrire prove conclusive. Per esempio, negli studi
clinici, l’efficacia di un farmaco potrebbe non essere dimostrata a
causa delle limitate prove disponibili. Allo stesso modo, potrebbero
esserci prove inconcludenti e limitate che un determinato codice
postale di una determinata città possa avere maggiori probabilità di
essere coinvolto in una frode. Dovremmo stare attenti quando
giudichiamo il nostro processo decisionale in base ai modelli
matematici trovati dagli algoritmi che utilizzano dati così limitati.
NOTA
Le decisioni basate su prove inconcludenti sono inclini a portare ad azioni
ingiustificate.

Tracciabilità
La separazione netta fra la fase di training e la fase di test negli
algoritmi di machine learning fa sì che se un algoritmo causa qualche
danno, è molto difficile da tracciare il problema ed eseguirne il debug.
Inoltre, quando viene rilevato un problema in un algoritmo, è difficile
determinare effettivamente chi ne ha subito le conseguenze.

Evidenze fuorvianti
Gli algoritmi sono formulazioni guidate dai dati. In base al principio
GIGO (Garbage-in, Garbage-out) i risultati degli algoritmi sono
affidabili solo quanto lo sono i dati su cui si basano. Se vi sono
distorsioni nei dati, queste si rifletteranno anche negli algoritmi.
Risultati ingiusti
L’uso di algoritmi può danneggiare comunità e gruppi vulnerabili,
già svantaggiati. Inoltre, in più di un’occasione è stato dimostrato che
l’uso di algoritmi per distribuire i finanziamenti alla ricerca è risultato
sbilanciato verso la popolazione maschile. Gli algoritmi utilizzati per
garantire l’immigrazione sono talvolta involontariamente sbilanciati
verso gruppi vulnerabili.
Nonostante l’utilizzo di dati di alta qualità e le complesse
formulazioni matematiche, se il risultato è ingiusto, l’intero sforzo può
portare più danni che benefici.

Ridurre i pregiudizi nei modelli


Nel mondo attuale, agiscono pregiudizi ben noti e documentati
basati sul genere, sul colore della pelle e sull’orientamento sessuale.
Ciò significa che ci aspettiamo che i dati raccolti contengano tracce di
quei pregiudizi, a meno che non abbiamo a che fare con un ambiente
in cui è stato fatto uno sforzo attivo per rimuovere tali pregiudizi prima
di raccogliere i dati.
Tutti i pregiudizi entrati negli algoritmi sono dovuti, direttamente o
indirettamente, a pregiudizi umani. Questo pregiudizio umano può
riflettersi sia nei dati utilizzati dall’algoritmo sia nella formulazione
dell’algoritmo stesso. Per un tipico progetto di machine learning che
segue il ciclo di vita CRISP-DM, spiegato nel Capitolo 5, il
pregiudizio, o bias, si presenta come nella Figura 14.2.
Figura 14.2

La parte più difficile della riduzione dei pregiudizi consiste


nell’identificare e individuare i pregiudizi inconsci.

Affrontare i problemi NP-difficili


I problemi NP-difficili sono stati ampiamente discussi nel Capitolo
4. Alcuni problemi NP-difficili sono importanti e dobbiamo progettare
algoritmi per risolverli.
Se la soluzione di un problema NP-difficile sembra fuori portata a
causa della sua complessità o dei limiti delle risorse disponibili,
possiamo adottare uno dei seguenti approcci:
semplificare il problema;
adattare una soluzione nota a un problema simile;
usare un metodo probabilistico.
Esaminiamoli uno per uno.

Semplificare il problema
Possiamo semplificare il problema sulla base di alcune ipotesi. Il
problema risolto fornisce una soluzione, che non è perfetta ma è
comunque informata e utile. Affinché ciò funzioni, le ipotesi scelte
dovrebbero essere le meno restrittive possibili.

Esempio
È raro che la relazione fra feature ed etichette nei problemi di
regressione sia perfettamente lineare. Ma potrebbe essere lineare
all’interno del nostro intervallo operativo tipico. L’approssimazione
della relazione alla linearità semplifica notevolmente l’algoritmo ed è
ampiamente utilizzata, ma introduce approssimazioni che riducono
l’accuratezza dell’algoritmo. Il compromesso fra approssimazioni e
accuratezza dovrebbe essere studiato attentamente e dovrebbe essere
scelto il giusto equilibrio, adatto agli stakeholder.

Adattare una soluzione nota a un


problema simile
Se si conosce una soluzione a un problema simile, tale soluzione
può essere utilizzata come punto di partenza e può essere adattata per
risolvere il problema in questione. Il concetto di Transfer Learning
(TL) nel machine learning si basa proprio su questo principio. L’idea è
quella di utilizzare come punto di partenza per l’addestramento
dell’algoritmo l’inferenza di modelli già pre-addestrati.
Esempio
Supponiamo di voler addestrare un classificatore binario in grado di
distinguere fra laptop Apple e Windows in base a feed video in tempo
reale utilizzando la visione artificiale durante la formazione aziendale.
Dal feed video, la prima fase dello sviluppo del modello sarebbe quella
di rilevare oggetti differenti e identificare quali di questi sono laptop.
Poi possiamo passare alla seconda fase, di formulazione delle regole
per distinguere fra laptop Apple e Windows.
Ora, esistono già modelli open source ben addestrati e ben collaudati
che possono affrontare la prima fase di questo training del modello.
Perché non usarli come punto di partenza e utilizzare l’inferenza verso
la seconda fase, quella di differenziazione fra laptop Windows e
Apple? Questo ci porterà un passo in avanti e la soluzione sarà meno
soggetta a errori, poiché la Fase 1 è già ben testata.

Usare un metodo probabilistico


Usiamo un metodo probabilistico per ottenere una soluzione
ragionevolmente buona e praticabile, ma non ottimale. Quando
abbiamo usato gli algoritmi ad albero decisionale nel Capitolo 7 per
risolvere un problema, la soluzione si basava sul metodo
probabilistico. Non abbiamo dimostrato che si tratta di una soluzione
ottimale, ma era comunque una soluzione ragionevolmente buona in
grado di dare una risposta utile al problema che stavamo cercando di
risolvere, entro i vincoli definiti nei requisiti.

Esempio
Molti algoritmi di machine learning partono da una soluzione
casuale e poi la migliorano in modo iterativo. La soluzione finale
potrebbe anche essere efficiente, ma non possiamo dimostrare che sia
la migliore. Questo metodo viene utilizzato in problemi complessi, per
poterli risolvere in un lasso di tempo ragionevole. Ecco perché, per
molti algoritmi di machine learning, l’unico modo per ottenere risultati
ripetibili consiste nell’utilizzare la stessa sequenza di numeri casuali
utilizzando lo stesso seme.

Quando usare gli algoritmi


Gli algoritmi sono come gli strumenti nella cassetta degli attrezzi di
un professionista. Innanzitutto, dobbiamo capire quale strumento è il
migliore in determinate circostanze. A volte, dobbiamo chiederci se
abbiamo davvero una soluzione per il problema che stiamo cercando di
risolvere e quando è il momento giusto per implementare la nostra
soluzione. Dobbiamo determinare se l’uso di un algoritmo può fornire
una soluzione effettivamente utile a un problema reale, migliore
rispetto alle alternative. Dobbiamo analizzare l’effetto dell’utilizzo
dell’algoritmo in termini di tre aspetti.
Costi: l’uso può giustificare il costo dello sforzo di
implementazione dell’algoritmo?
Tempo: la nostra soluzione rende il processo complessivo più
efficiente rispetto alle alternative più semplici?
Precisione: la nostra soluzione produce risultati più accurati
rispetto ad alternative più semplici?
Per scegliere l’algoritmo giusto, dobbiamo trovare le risposte alle
seguenti domande:
Possiamo semplificare il problema facendo delle ipotesi?
Come valuteremo il nostro algoritmo? Quali sono le metriche
chiave?
Come verrà distribuito e utilizzato?
Deve essere spiegabile?
Abbiamo considerato i tre fondamentali requisiti non funzionali:
sicurezza, prestazioni e disponibilità?
C’è qualche scadenza prevista?

Un esempio pratico: eventi cigno nero


Gli algoritmi prendono dei dati, li elaborano e li sfruttano per
risolvere un problema. E se i dati raccolti riguardassero un evento di
grande impatto e molto raro? Come possiamo utilizzare algoritmi
contenenti i dati generati da quell’evento e da ciò che ha portato a quel
Big Bang? Approfondiamo questo aspetto.
Tali eventi estremamente rari sono rappresentati dalla metafora degli
eventi cigno nero da Nassim Taleb nel suo libro, Fooled by
Randomness, nel 2001.
NOTA
Prima che i cigni neri venissero scoperti in natura, per secoli, sono stati usati
per rappresentare qualcosa che non può accadere. Dopo la loro scoperta, il
termine è rimasto in uso, ma è cambiato ciò che rappresenta. Ora rappresenta
qualcosa di così raro da non poter essere previsto.

Taleb ha fornito questi quattro criteri per classificare un evento


come un “cigno nero”.

Quattro criteri per classificare un evento come “cigno nero”


È un po’ complicato decidere se un evento raro debba essere
classificato come evento cigno nero o meno. In generale, per essere
classificato come un cigno nero, un evento dovrebbe soddisfare i
seguenti quattro criteri.
1. Innanzitutto, una volta che l’evento si è verificato, per gli
osservatori deve essere una sorpresa strabiliante, per esempio, il
lancio della bomba atomica su Hiroshima.
2. L’evento dovrebbe essere eccezionale: un elemento di disturbo
grave e importante, come lo scoppio dell’influenza spagnola.
3. Una volta che l’evento si è verificato e la polvere si è calmata, i
data scientist che facevano parte del gruppo di osservatori
dovrebbero rendersi conto che in realtà non è stata una sorpresa
così grande. Semplicemente gli osservatori non hanno prestato
attenzione ad alcuni indizi importanti. Se avessero avuto la
capacità e l’iniziativa, l’evento cigno nero avrebbe potuto essere
previsto. Per esempio, l’epidemia di influenza spagnola ha avuto
alcuni indizi che erano noti ma sono stati ignorati prima che
diventasse un’epidemia globale. Il Progetto Manhattan è stato
condotto per anni prima che la bomba atomica venisse
effettivamente sganciata su Hiroshima. I membri del gruppo di
osservatori semplicemente non sono riusciti a “collegare i
puntini”.
4. Quando si è verificato, mentre gli osservatori dell’evento cigno
nero ne sono stati sorpresi, altre persone potrebbero non ritenerlo
affatto una sorpresa. Per esempio, per gli scienziati che hanno
lavorato per anni allo sviluppo della bomba atomica, l’uso
dell’energia atomica non è stato una sorpresa, ma un evento
atteso.

Applicazione di algoritmi agli eventi cigno nero


Ci sono importanti aspetti degli eventi cigno nero che sono legati
agli algoritmi.
Sono disponibili molti sofisticati algoritmi di previsione, ma se
speriamo di utilizzare tecniche di previsione standard come forma
di protezione, per prevedere un evento cigno nero, non
funzionerà. L’utilizzo di tali algoritmi di previsione offrirà solo
false sicurezze.
Una volta che l’evento cigno nero si è verificato, di solito non è
possibile prevedere le sue esatte implicazioni su aree più ampie
della società, fra cui l’economia, il pubblico e le questioni
governative. Innanzitutto, essendo un evento raro, non abbiamo i
dati necessari per alimentare gli algoritmi e non abbiamo una
conoscenza della correlazione e delle interazioni fra le varie aree
della società che forse non abbiamo mai esplorato e compreso
appieno.
Una cosa importante da notare è che gli eventi cigno nero non
sono casuali. Semplicemente non avevamo la capacità di prestare
attenzione agli eventi complessi che alla fine hanno portato a
questi eventi. Questa è un’area in cui gli algoritmi possono
svolgere un ruolo importante. In futuro dovremmo assicurarci di
avere una strategia per prevedere e rilevare questi piccoli eventi,
che si sono combinati nel tempo per generare l’evento cigno nero.

NOTA
Lo scoppio dell’epidemia di COVID-19 a inizio del 2020 è il miglior esempio di
un evento cigno nero dei nostri tempi.

L’esempio precedente mostra quanto sia importante considerare e


comprendere i dettagli del problema che stiamo cercando di risolvere e
poi individuare le aree in cui possiamo contribuire a una soluzione
implementando una soluzione basata su algoritmi. Senza un’analisi
completa, come abbiamo visto, l’uso di algoritmi può risolvere solo
una parte di un problema complesso, deludendo le aspettative.

Riepilogo
In questo capitolo, abbiamo appreso gli aspetti pratici che
dovrebbero essere considerati durante la progettazione di algoritmi.
Abbiamo esplorato il concetto di spiegabilità algoritmica e i vari modi
in cui possiamo fornirla a diversi livelli. Abbiamo anche esaminato i
potenziali problemi etici negli algoritmi. Infine, abbiamo descritto
quali fattori considerare durante la scelta di un algoritmo.
Gli algoritmi sono i motori del nuovo mondo automatizzato in cui
viviamo. È importante conoscere, sperimentare e comprendere le
implicazioni dell’uso degli algoritmi. Comprendere i loro punti di
forza, i loro limiti e le implicazioni etiche dell’uso degli algoritmi farà
molto per rendere questo mondo un luogo migliore in cui vivere.
Questo libro è un tentativo per raggiungere questo importante obiettivo
in questo mondo in continua evoluzione.
Indice

Introduzione
A chi è rivolto questo libro
Argomenti trattati
Requisiti
File degli esempi
Convenzioni utilizzate
L’autore
Il revisore tecnico
Parte I - Fondamenti e algoritmi di base
Capitolo 1 - Panoramica sugli algoritmi
Che cos’è un algoritmo?
La logica di un algoritmo
Introduzione ai pacchetti Python
Tecniche di progettazione degli algoritmi
Analisi delle prestazioni
Convalida di un algoritmo
Riepilogo
Capitolo 2 - Strutture di dati utilizzate negli algoritmi
Le strutture di dati in Python
I tipi di dati astratti in Python
Riepilogo
Capitolo 3 - Algoritmi di ordinamento e ricerca
Introduzione agli algoritmi di ordinamento
Introduzione agli algoritmi di ricerca
Applicazioni pratiche
Riepilogo
Capitolo 4 - Progettazione di algoritmi
Introduzione ai concetti di base della progettazione di un algoritmo
Strategie per la progettazione di algoritmi
Applicazione pratica: risoluzione del problema del commesso
viaggiatore
L’algoritmo PageRank
La programmazione lineare
Applicazione pratica: pianificazione della capacità con la
programmazione lineare
Riepilogo
Capitolo 5 - Algoritmi per grafi
Rappresentazione dei grafi
Introduzione all’analisi delle reti
Attraversamento dei grafi
Caso di studio: analisi delle frodi
Riepilogo
Parte II - Algoritmi di machine learning
Capitolo 6 - Algoritmi di machine learning senza supervisione
Introduzione all’apprendimento senza supervisione
Gli algoritmi di clustering
Riduzione della dimensionalità
Mining delle regole associative
Applicazione pratica: clustering di tweet simili
Algoritmi per il rilevamento delle anomalie
Riepilogo
Capitolo 7 - Algoritmi classici di machine learning con supervisione
Il machine learning con supervisione
Gli algoritmi di classificazione
Gli algoritmi di regressione
Esempio pratico: previsioni del tempo
Riepilogo
Capitolo 8 - Algoritmi a rete neurale
Le reti neurali
Evoluzione delle reti neurali
Addestramento di una rete neurale
Strumenti e framework
I vari tipi di reti neurali
Trasferimento dell’apprendimento
Caso di studio: utilizzo del deep learning per il rilevamento delle
frodi
Riepilogo
Capitolo 9 - Algoritmi per l’elaborazione del linguaggio naturale
Introduzione all’elaborazione del linguaggio naturale
Elaborazione del linguaggio naturale BoW-based
Introduzione al word embedding
Utilizzo delle reti neurali ricorrenti per l’elaborazione del linguaggio
naturale
Utilizzo dell’elaborazione del linguaggio naturale per l’analisi del
sentiment
Caso di studio: analisi del sentiment nelle recensioni di film
Riepilogo
Capitolo 10 - Motori di raccomandazione
Introduzione ai sistemi di raccomandazione
Tipi di motori di raccomandazione
I limiti dei sistemi di raccomandazione
Campi di applicazione
Esempio pratico: creazione di un motore di raccomandazione
Riepilogo
Parte III - Argomenti avanzati
Capitolo 11 - Algoritmi per i dati
Introduzione agli algoritmi per i dati
Gli algoritmi di archiviazione dei dati
Gli algoritmi per i dati in streaming
Gli algoritmi di compressione dei dati
Esempio pratico: analisi del sentiment dei tweet in tempo reale
Riepilogo
Capitolo 12 - Crittografia
Introduzione alla crittografia
Terminologia di base
I requisiti di sicurezza
Progettazione di cifrature
Cifratura a sostituzione
Tipi di tecniche crittografiche
Esempio: problemi di sicurezza nella distribuzione di un modello di
machine learning
Riepilogo
Capitolo 13 - Algoritmi per dati su larga scala
Introduzione agli algoritmi per dati su larga scala
Progettazione di algoritmi paralleli
Strategia di elaborazione multi-risorsa
Riepilogo
Capitolo 14 - Considerazioni pratiche
Introduzione
La spiegabilità di un algoritmo
Algoritmi ed etica
Ridurre i pregiudizi nei modelli
Affrontare i problemi NP-difficili
Quando usare gli algoritmi
Riepilogo

Potrebbero piacerti anche