10. Indici di eterogeneità#

Nel caso di variabili qualitative nominali la varianza e gli altri indici da essa derivati non si possono calcolare (infatti non sono calcolabili la media né la mediana né altri valori numerici di riferimento dai quali calcolare le distanze). È comunque necessario avere un indice che misuri la dispersione della distribuzione delle frequenze, detta eterogeneità. In particolare diremo che una variabile si distribuisce in modo eterogeneo se ogni suo valore si presenta con la stessa frequenza.

10.1. Indice di eterogeneità di Gini#

Dato un campione \(\{ a_1, \dots, a_n \}\) in cui occorrono i valori distinti \(v_1, \dots, v_s\) e indicando con \(f_i\) la frequenza relativa dell'elemento \(v_i\) per \(i = 1, \dots, s\), la quantità

\[I = 1 - \sum_{i=1}^s f_i^2\]

è detta indice di eterogeneità di Gini. Si noti che:

  • \(0 \leq I < 1\), in quanto:

    • per almeno un \(j\) si ha \(f_j^2 > 0\) e quindi \(\sum f_i^2 > 0\), il che implica \(I < 1\);

    • per ogni \(i\) si ha \(f_i^2 \leq f_i\) essendo \(0 \leq f_i \leq 1\), e dunque \(\sum f_i^2 \leq \sum f_i = 1\), il che implica \( I \geq 0\);

  • in caso di eterogeneità minima (o massima omogeneità), tutti gli elementi del campione assumono lo stesso valore, dunque esiste un solo \(j\) per cui \(f_j = 1\) e per ogni \(i \neq j\) si ha \(f_i = 0\), pertanto \(I = 1 - 1 = 0\);

  • in caso di eterogeneità massima tutte le osservazioni hanno invece la medesima frequenza \(f_i = \frac{1}{s}\), e quindi \(I = 1 - \frac{1}{s} = \frac{s-1}{s}\).

Nel caso in cui si voglia operare con un indice che assuma valori tra \(0\) e \(1\), è possibile dividere l'indice di Gini per il valore massimo \(\frac{s-1}{s}\), ottenendo il cosiddetto indice di Gini normalizzato:

\[I' = \frac{s \cdot I}{s-1}\]

Consideriamo il grafico seguente, che traccia l'andamento dell'indice di Gini nel caso di due valori distinti \(v_1\) e \(v_2\), di cui indicheremo rispettivamente con \(f\) e \(1-f\) le frequenze relative.

import numpy as np
import matplotlib.pyplot as plt

def gini_2_val(f):
    return 1 - f**2 - (1-f)**2

x = np.arange(0, 1.01, .01)
y = list(map(gini_2_val, x))

plt.plot(x, y)
plt.ylim((0, 0.55))
plt.show()
../_images/2e0053e2bdadae9a5723d94ffe2e29e90b01e8a0ab8f21201d2ba2034fa2bdc5.png

Il grafico evidenzia come non solo l'indice di Gini assuma valori minimo e massimo rispettivamente in corrispondenza delle situazioni di minima e massima eterogeneità nel campione, ma effettivamente si abbia una crescita graduale del valore dell'indice man mano che l'eterogeneità nel campione aumenta, seguita da una sua riduzione man mano che questa ritorna a diminuire. In altre parole, questo indice cattura effettivamente il concetto di eterogeneità traducendolo in una quantità numerica.

Per calcolare il valore dell'indice di Gini per i dati contenuti in una serie è necessario calcolare le corrispondenti frequenze relative tramite value_counts; per elevare queste ultime al quadrato risulta utile invocare sulla serie delle frequenze il metodo map e poi sommare i valori ottenuti e sottrarre da 1 il risultato.

def gini(series):
    return 1 - (sum(series.value_counts(normalize=True)
                    .map(lambda f: f**2)))

Possiamo quindi utilizzare la funzione gini per valutare l'eterogeneità dei valori assunti dagli attributi Publisher (limitatamente a 'Marvel Comics' e 'DC Comics') Eye color, Hair color (senza considerare in entrambi i casi i valori mancanti) e per i nomi dei supereroi (ottenuti a partire dall'indice del dataframe).

import pandas as pd

heroes = pd.read_csv('data/heroes.csv', index_col=0)

publisher = heroes[heroes['creator'].isin(['Marvel Comics',
                                             'DC Comics'])]['creator']
eye_color = heroes[pd.notnull(heroes['eye_color'])]['eye_color']
hair_color = heroes[pd.notnull(heroes['hair_color'])]['hair_color']

print(gini(publisher))
print(gini(eye_color))
print(gini(hair_color))
print(gini(heroes.index))
0.4924373099828171
0.8638992597470045
0.8602609316591339
0.9999423797176606

I risultati ottenuti ci dicono che l'attributo più omogeneo tra quelli considerati è quello relativo all'editore: ciò è dovuto al fatto che sono stati considerati solamente i due editori con il maggior numero di valori. Il nome dei supereroi risulta invece l'attributo più eterogeneo, e anche in questo caso si tratta di un risultato che ci potevamo aspettare, in quanto è ragionevole supporre che i valori assunti dal nome siano univoci. Per avere un quadro più preciso, calcoliamo anche la variante normalizzata dell'indice:

def generalized_gini(series):
    freq = series.value_counts(normalize=True)
    s = len(freq)
    return (1 - (sum(freq.map(lambda f: f**2)))) * s / (s-1)

print(generalized_gini(publisher))
print(generalized_gini(eye_color))
print(generalized_gini(hair_color))
print(generalized_gini(heroes.index))
0.9848746199656342
0.8971261543526586
0.8909845363612457
1.0

10.2. Entropia#

Dato un campione \(\{ a_1, \dots, a_n \}\) in cui occorrono i valori distinti \(v_1, \dots, v_s\) e indicando con \(f_i\) la frequenza relativa dell'elemento \(v_i\) per \(i = 1, \dots, s\), la quantità

\[H = \sum_{i=1}^s f_i \log \frac{1}{f_i} = - \sum_{i=1}^s f_i \log f_i\]

è detta indice di entropia del campione.

La funzione \(p \mapsto \log \frac{1}{p}\) è detta autoinformazione, e il suo andamento in \((0,1]\) è il seguente

x = np.arange(0.01, 1.01, 0.01)
y = list(map(lambda f: -1 * np.log2(f), x))
plt.plot(x, y)
plt.show()
../_images/275dc846b18213efff90b9cbfcd950c1c81e6112429e3996018bf255052a9331.png

In particolare la funzione vale \(0\) quando \(p=1\) e tende a infinito per \(p\) che tende a \(0\). Il grafico dell'andamento di un generico addendo dell'entropia è il seguente.

x = np.arange(0.001, 1.01, 0.01)
y = list(map(lambda f: -f * np.log2(f), x))
plt.plot(x, y)
plt.ylim(0, 0.6)
plt.xlim(0, 1)
plt.show()
../_images/a1cc5f979d931f995b4a6cbc5001a3a8f4543a729795f62821d1f2da954fe3b9.png

Il risultato ottenuto evidenzia come si possa estendere la definizione del generico addendo anche per \(f_i = 0\), ponendolo uguale a \(1\). Valgono inoltre le osservazioni che seguono.

  • Per ogni \(i\) vale \(- f_i \log f_i \geq 0\), dunque \(H \geq 0\).

  • Per ogni \(i\) vale \(- f_i \log f_i = 0\) se e solo se \(f_i = 0\) oppure \(f_i = 1\), pertanto \(H = 0\) se e solo se ci si trova in condizione di massima omogeneità (e cioè tutti gli elementi del campione assumono lo stesso valore).

  • In caso invece di massima eterogeneità si avrà \(f_i=\frac{1}{s}\) e quindi \(H = \log s\), e si può dimostrare che in tal caso l'entropia assume il valore massimo.

Si può pertanto definire l'indice di entropia normalizzato

\[H' = \frac{H}{\log s}\]

i cui valori variano tra \(0\) e \(1\). Se il logaritmo è in base \(2\) (come nel codice che abbiamo scritto) allora l'entropia si misura in bit; è però possibile usare altre basi: per esempio, il logaritmo naturale e quello in base 10 corrispondono rispettivamente a due unità chiamate nat e hartley.

Analogamente a quanto visto per l'indice di Gini, è possibile visualizzare l'andamento dell'entropia nel caso di due valori possibili, mettendo in evidenza che anche questo indice tende ad aumentare all'aumentare dell'eterogeneità e a diminuire al diminuire di quest'ultima.

def entropy_2_val(f):
    return 0 if f in (0, 1) else - f * np.log2(f) - (1-f) * np.log2(1-f)

x = np.arange(0, 1.01, .01)
y = list(map(entropy_2_val, x))
plt.plot(x, y)
plt.ylim((0, 1.1))
plt.show()
../_images/0c40d86062db4cc9910d01e9dcb5ae5736ad602ce0909d6bde73f771094a62bc.png

Calcolando il valore dell'entropia per gli attributi precedentemente considerati si verifica come non cambi il loro rapporto relativo in termini di eterogeneità.

def entropy(series):
    return sum((series.value_counts(normalize=True)
                      .map(lambda f: -f * np.log2(f))))

print(entropy(publisher))
print(entropy(eye_color))
print(entropy(hair_color))
print(entropy(heroes.index))              
0.9890616722131573
3.4106066521782026
3.382161299641198
14.083063744712826

Come nel caso precedente, calcoliamo anche la variante normalizzata dell'indice.

def entropy(series, normalized=False):
    freq = series.value_counts(normalize=True)
    e = sum((freq.map(lambda f: -f * np.log2(f))))
    if normalized:
        e /= np.log2(len(freq))
    return e

print(entropy(publisher, normalized=True))
print(entropy(eye_color, normalized=True))
print(entropy(hair_color, normalized=True))
print(entropy(heroes.index, normalized=True))
0.9890616722131573
0.7172844048626557
0.696207190401407
0.999999999999774

10.3. Alberi di decisione#

Gli indici di eterogeneità sono alla base della costruzione di un interessante classificatore chiamato albero di decisione. Un albero di decisione assegna oggetti a classi, dove un oggetto è descritto tramite un'osservazione che consiste, al solito, in un vettore di valori per degli attributi prefissati. Il procedimento di classificazione procede nel modo seguente: si considera la radice dell'albero, cioè l'unico nodo a cui non arrivano frecce (disposto di solito in alto nella rappresentazione dell'albero), e che è contrassegnato da una condizione che coinvolge i valori di uno o più attributi per l'oggetto che si vuole classificare. A seconda del valore di questa condizione, si percorre una delle due frecce partenti dalla radice. Se il nodo a cui si arriva è un nodo terminale (una foglia da cui non dipartono nuove frecce), in tale nodo è indicata la classe assegnata all'oggetto, altrimenti il nodo riporta un'altra condizione da valutare, iterando il comportamento precedente fino a che non si raggiunge una foglia e quindi si determina una classe per l'oggetto.

Consideriamo per esempio l'albero riportato qui sotto, che fa riferimento a un dataset molto semplice che in funzione delle condizioni meteorologiche permette di capire se si può uscire a giocare. La radice richiede di iniziare valutando che tempo fa (attributo Outlook): se è nuvoloso (overcast) si arriva a una foglia che dice che si può uscire (Yes); se invece dovesse essere soleggiato viene richiesto di valutare se l'umidità abbia o meno un valore inferiore a 30; nel primo caso si potrebbe uscire, altrimenti no. Il processo di classificazione funziona in modo analogo nel caso di tempo piovoso (rain).

Esempio di albero di decisione

La costruzione di un albero di ricerca richiede innanzitutto di individuare un indice di eterogeneità. Facciamo un esempio selezioniamo l'indice di Gini e lavorando solo con due classi: i supereroi e i supercattivi, che indicheremo rispettivamente con le etichette good_guy e bad_guy. Il fatto di avere solo due etichette possibili semplifica notevolmente il calcolo dell'indice: basterà infatti specificare la frequenza relativa \(f\) di good_guy, così che la frequenza relativa di bad_guy sarà \(1-f\) e l'indice di Gini assumerà il valore \(1 - f^2 - (1-f)^2\): potremo quindi riutilizzare la funzione gini_2_val precedentemente definita.

Per mantenere l'albero di decisione di dimensioni compatte selezioniamo un numero relativamente basso di supereroi e supercattivi (la scelta è stata fatta anche in modo da non avere a che fare con valori mancanti, che avrebbero complicato il processo di calcolo dell'albero).

good_guys = heroes.loc[['Wonder Woman',
                        'Aquaman',
                        'Cyborg',
                        'The Flash']]
bad_guys = heroes.loc[['Black Manta',
                       'The Penguin',
                       'Joker',
                       'Deathstroke',
                       'Magneto']]
all_guys = pd.concat([good_guys, bad_guys])

Estraiamo dal dataframe ottenuto gli attributi corrispondenti ad altezza, peso, genere, anno di prima apparizione, colore di occhi e capelli, forza e intelligenza, in quanto i rimanenti attributi sono o identici per tutti i casi selezionati o rappresentano valori univoci.

features = ['height', 'weight', 'creator', 'first_appearance',
            'hair_color', 'eye_color', 'strength', 'intelligence']
X = all_guys[features]

Il risultato, memorizzato in X, è visualizzato qui sotto.

X
height weight creator first_appearance hair_color eye_color strength intelligence
id
Wonder Woman 188.0 74.8 DC Comics NaN Black Blue 100 High
Aquaman NaN NaN DC Comics NaN Blond Black 100 Average
Cyborg NaN NaN DC Comics NaN NaN NaN 25 Average
The Flash 180.3 81.2 DC Comics 1940.0 Brown / White Blue 25 Good
Black Manta 188.0 93.0 DC Comics NaN No Hair Black 30 High
The Penguin NaN NaN DC Comics NaN NaN NaN 1 Good
Joker NaN NaN DC Comics NaN NaN NaN 6 High
Deathstroke 177.8 79.4 DC Comics NaN White Brown 15 High
Magneto 188.0 86.2 Marvel Comics 1963.0 Silver Grey 25 High

In corrispondenza di ogni osservazione in X è poi necessario indicare la relativa etichetta. Per comodità è meglio organizzare queste etichette in un dataframe separato che si può costruire facilmente: siccome i primi quattro elementi di X rappresentano supereroi mentre i rimanenti elementi rappresentano supercattivi, è sufficiente fondere insieme due dataframe dei quali il primo contiene tante copie dell'etichetta good_guy quanti sono i supereroi e il secondo è costruito in modo analogo ma considerando i supercattivi.

Y = pd.concat([pd.DataFrame(['good guy'] * len(good_guys),
                            index=good_guys.index),
               pd.DataFrame(['bad guy'] * len(bad_guys),
                            index=bad_guys.index)])

Ora che abbiamo organizzato i dati a disposizione, è necessario decidere la condizione da inserire nella radice dell'albero. La scelta viene fatta considerando una serie di possibili condizioni, valutando per ognuna il modo in cui i dati risulterebbero suddivisi nei due nodi sottostanti la radice. Consideriamo, per esempio, una condizione che valuti se l'attributo Strength assume un valore minore o uguale a 40 e visualizziamo le etichette delle osservazioni che soddisfano tale condizione.

Y[X['strength'] <= 20]
0
id
The Penguin bad guy
Joker bad guy
Deathstroke bad guy

Il risultato è molto interessante in quanto le etichette sono tutte uguali, e quindi l'eventuale nodo successivo nell'albero sarebbe una foglia che etichetta i casi come bad_guy. Le cose cambiano, sebbene poco, se consideriamo le osservazioni che non soddisfano la condizione.

Y[X['strength'] > 20]
0
id
Wonder Woman good guy
Aquaman good guy
Cyborg good guy
The Flash good guy
Black Manta bad guy
Magneto bad guy

In questo caso infatti vi è un solo caso di bad_guy e tutte le osservazioni rimanenti sono relative a good_guy. In altre parole, la condizione considerata è buona in quanto suddivide le osservazioni in due gruppi che tendono a essere omogenei rispetto all'etichetta assunta. Si può quindi utilizzare l'indice di Gini per quantificare quanto sia buona l'omogeneità: più l'indice sarà basso, più le osservazioni saranno omogenee. È dunque necessario calcolare la frequenza relativa delle due etichette nei due gruppi. Il calcolo di queste frequenze si può fare invocando value_counts sull'unica serie dei dataframe coinvolti. Una volta ottenuta la frequenza relativa di una delle due etichette è poi possibile calcolare il valore dell'indice di Gini. Per esempio, concentrandosi sul gruppo di casi per cui la forza è minore o uguale a 40 e ragionando in termini della frequenza di 'bad guy' si otterrebbe il risultato seguente.

freq = Y[X['strength'] <= 20][0].value_counts(normalize=True)
freq_bad = freq['bad guy']
gini_left = gini_2_val(freq_bad)
gini_left
np.float64(0.0)

Il valore dell'indice per il rimanente gruppo di casi si ottiene in modo analogo.

freq = Y[X['strength'] > 20][0].value_counts(normalize=True)
freq_bad = freq['bad guy']
gini_right = gini_2_val(freq_bad)
gini_right
np.float64(0.4444444444444443)

Per combinare insieme i due indici al fine di esprimere in un unico valore l'omogeneità media dei casi suddivisi nei sottogruppi si calcola una loro media, pesata in funzione della numerosità dei sottogruppi stessi.

weight_left = len(Y[X['strength'] <= 20]) / len(Y)
weight_right = len(Y[X['strength'] > 20]) / len(Y)
gini_left * weight_left + gini_right * weight_right
np.float64(0.29629629629629617)

Tirando le somme, utilizzando la domanda: "la forza è minore o uguale a 40?" come criterio per la radice dell'albero si otterrebbe una suddivisione dei dati a disposizione in due gruppi con un'eterogeneità media pari a circa 0.17. Possiamo ora mantenere fisso l'attributo e considerare valori diversi per la soglia, al fine di trovare il valore che minimizza l'indice di Gini medio (e quindi corrisponde al caso di migliore omogeneità). Attrezziamoci per effettuare questo calcolo indipendentemente dalla scelta dell'attributo, del valore di soglia e dell'indice di eterogeneità.

def split_value(attribute, value, index):
    freq = (Y[X[attribute] <= value])[0].value_counts(normalize=True)
    freq_bad = freq['bad guy'] if 'bad guy' in freq else 0
    index_left = index(freq_bad)
    weight_left = len(Y[X[attribute] <= value]) / len(Y)
    freq = (Y[X[attribute] > value])[0].value_counts(normalize=True)
    freq_bad = freq['bad guy'] if 'bad guy' in freq else 0
    index_right = index(freq_bad)
    weight_right = len(Y[X[attribute] > value]) / len(Y)
    return index_left * weight_left + index_right * weight_right

Ovviamente utilizzando la funzione split_value otteniamo lo stesso risultato se riconsideriamo una suddivisione in gruppi basata sulla soglia 40 per la forza.

split_value('strength', 20, gini_2_val)
np.float64(0.29629629629629617)

Ora siamo però in grado di effettuare in modo automatico lo stesso calcolo al variare dei possibili valori per la soglia.

x_vals = range(10, 90)

plt.plot(x_vals,
         list(map(lambda v: split_value('strength', v, gini_2_val),
                  x_vals)))
plt.show()
../_images/8743736242779c53229a36b98991e32e6e3e3281a78a98a85f1d33c247776482.png

I risultati ottenuti ci dicono che un qualsiasi valore tra 30 e 49 massimizza l'omogeneità dei due sottogruppi generati. Avremmo ottenuto un risultato simile se come indice di eterogeneità avessimo utilizzato l'entropia.

plt.plot(x_vals,
         list(map(lambda v: split_value('strength', v, entropy_2_val),
                  x_vals)))
plt.ylim(0.35, 1.1)
plt.show()
../_images/7a93e5a9dc09a6c9a291b7cb05985108f08bcf73b3f61a9048249ede9fbdb601.png

Per proseguire con la creazione dell'albero di decisione bisognerebbe applicare nuovamente il processo di ottimizzazione al gruppo che non ha omogeneità massima. In realtà esistono già delle implementazioni che si occupano di costruire gli alberi di decisione: noi faremo riferimento alla libreria sklearn. Nonostante il calcolo degli indici di eterogeneità si possa effettuare anche su attributi a valori categorici, questa libreria richiede che i dati siano espressi utilizzando esclusivamente valori numerici. È dunque necessario convertire in numeri tutte le etichette categoriche. Questo procedimento può essere fatto automaticamente utilizzando gli oggetti della classe LabelEncoder, che una volta creati generano automaticamente il mapping tra etichette e valori numerici utilizzando il metodo fit a cui viene passato l'insieme delle osservazioni da convertire.

from sklearn.preprocessing import LabelEncoder

creator_encoder = LabelEncoder()
creator_encoder.fit(all_guys['creator'])

eye_col_encoder = LabelEncoder()
eye_col_encoder.fit(all_guys['eye_color'])

hair_col_encoder = LabelEncoder()
hair_col_encoder.fit(all_guys['hair_color'])

intelligence_encoder = LabelEncoder()
_ = intelligence_encoder.fit(all_guys['intelligence'])

Una volta determinato questo mapping, il metodo transform lo utilizza per convertire la serie corrispondente.

all_guys['creator'] = creator_encoder.transform(all_guys['creator'])
all_guys['eye_color'] = eye_col_encoder.transform(all_guys['eye_color'])
all_guys['hair_color'] = hair_col_encoder.transform(all_guys['hair_color'])
all_guys['intelligence'] = intelligence_encoder.transform(all_guys['intelligence'])

Nomenclatura

La cella precedente mostra come sia possibile modificare i contenuti di una colonna in un dataframe. Utilizzando una sintassi analoga è anche possibile aggiungere a un dataframe esistente una o più colonne.

Possiamo ora visualizzare il nuovo dataframe, ora composto solo da valori numerici, limitatamente agli attributi cui siamo interessati.

X = all_guys[features]
X
height weight creator first_appearance hair_color eye_color strength intelligence
id
Wonder Woman 188.0 74.8 0 NaN 0 1 100 2
Aquaman NaN NaN 0 NaN 1 0 100 0
Cyborg NaN NaN 0 NaN 6 4 25 0
The Flash 180.3 81.2 0 1940.0 2 1 25 1
Black Manta 188.0 93.0 0 NaN 3 0 30 2
The Penguin NaN NaN 0 NaN 6 4 1 1
Joker NaN NaN 0 NaN 6 4 6 2
Deathstroke 177.8 79.4 0 NaN 5 2 15 2
Magneto 188.0 86.2 1 1963.0 4 3 25 2

Usando questo dataframe modificato è possibile utilizzare un oggetto della classe DecisionTreeClassifier per costruire l'albero di decisione, passando al metodo fit i dataframe che descrivono rispettivamente i supereroi e le loro etichette.

from sklearn import tree

clf = tree.DecisionTreeClassifier()
clf = clf.fit(X, Y)

Una volta costruito, sull'oggetto corrispondente all'albero è possibile invocare il metodo predict per verificare quale etichetta venga associata agli oggetti di partenza.

predictions = clf.predict([X.loc[name] for name in X.index])
predictions
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/sklearn/utils/validation.py:2749: UserWarning: X does not have valid feature names, but DecisionTreeClassifier was fitted with feature names
  warnings.warn(
array(['good guy', 'good guy', 'good guy', 'good guy', 'bad guy',
       'bad guy', 'bad guy', 'bad guy', 'bad guy'], dtype=object)

È inoltre possibile visualizzare l'albero di decisione in forma grafica, utilizzando la libreria graphviz.

import graphviz

dot_data = tree.export_graphviz(clf, out_file=None,
                                class_names=['bad guy', 'good guy'],
                                feature_names=features)
dot_data = dot_data.replace('digraph Tree {',
                           'digraph Tree {\nbgcolor="#eaf3f5";')
graphviz.Source(dot_data)
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/backend/execute.py:76, in run_check(cmd, input_lines, encoding, quiet, **kwargs)
     75         kwargs['stdout'] = kwargs['stderr'] = subprocess.PIPE
---> 76     proc = _run_input_lines(cmd, input_lines, kwargs=kwargs)
     77 else:

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/backend/execute.py:96, in _run_input_lines(cmd, input_lines, kwargs)
     95 def _run_input_lines(cmd, input_lines, *, kwargs):
---> 96     popen = subprocess.Popen(cmd, stdin=subprocess.PIPE, **kwargs)
     98     stdin_write = popen.stdin.write

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/subprocess.py:1026, in Popen.__init__(self, args, bufsize, executable, stdin, stdout, stderr, preexec_fn, close_fds, shell, cwd, env, universal_newlines, startupinfo, creationflags, restore_signals, start_new_session, pass_fds, user, group, extra_groups, encoding, errors, text, umask, pipesize, process_group)
   1023             self.stderr = io.TextIOWrapper(self.stderr,
   1024                     encoding=encoding, errors=errors)
-> 1026     self._execute_child(args, executable, preexec_fn, close_fds,
   1027                         pass_fds, cwd, env,
   1028                         startupinfo, creationflags, shell,
   1029                         p2cread, p2cwrite,
   1030                         c2pread, c2pwrite,
   1031                         errread, errwrite,
   1032                         restore_signals,
   1033                         gid, gids, uid, umask,
   1034                         start_new_session, process_group)
   1035 except:
   1036     # Cleanup if the child failed starting.

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/subprocess.py:1955, in Popen._execute_child(self, args, executable, preexec_fn, close_fds, pass_fds, cwd, env, startupinfo, creationflags, shell, p2cread, p2cwrite, c2pread, c2pwrite, errread, errwrite, restore_signals, gid, gids, uid, umask, start_new_session, process_group)
   1954 if err_filename is not None:
-> 1955     raise child_exception_type(errno_num, err_msg, err_filename)
   1956 else:

FileNotFoundError: [Errno 2] No such file or directory: PosixPath('dot')

The above exception was the direct cause of the following exception:

ExecutableNotFound                        Traceback (most recent call last)
File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/IPython/core/formatters.py:1036, in MimeBundleFormatter.__call__(self, obj, include, exclude)
   1033     method = get_real_method(obj, self.print_method)
   1035     if method is not None:
-> 1036         return method(include=include, exclude=exclude)
   1037     return None
   1038 else:

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/jupyter_integration.py:98, in JupyterIntegration._repr_mimebundle_(self, include, exclude, **_)
     96 include = set(include) if include is not None else {self._jupyter_mimetype}
     97 include -= set(exclude or [])
---> 98 return {mimetype: getattr(self, method_name)()
     99         for mimetype, method_name in MIME_TYPES.items()
    100         if mimetype in include}

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/jupyter_integration.py:98, in <dictcomp>(.0)
     96 include = set(include) if include is not None else {self._jupyter_mimetype}
     97 include -= set(exclude or [])
---> 98 return {mimetype: getattr(self, method_name)()
     99         for mimetype, method_name in MIME_TYPES.items()
    100         if mimetype in include}

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/jupyter_integration.py:112, in JupyterIntegration._repr_image_svg_xml(self)
    110 def _repr_image_svg_xml(self) -> str:
    111     """Return the rendered graph as SVG string."""
--> 112     return self.pipe(format='svg', encoding=SVG_ENCODING)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/piping.py:104, in Pipe.pipe(self, format, renderer, formatter, neato_no_op, quiet, engine, encoding)
     55 def pipe(self,
     56          format: typing.Optional[str] = None,
     57          renderer: typing.Optional[str] = None,
   (...)     61          engine: typing.Optional[str] = None,
     62          encoding: typing.Optional[str] = None) -> typing.Union[bytes, str]:
     63     """Return the source piped through the Graphviz layout command.
     64 
     65     Args:
   (...)    102         '<?xml version='
    103     """
--> 104     return self._pipe_legacy(format,
    105                              renderer=renderer,
    106                              formatter=formatter,
    107                              neato_no_op=neato_no_op,
    108                              quiet=quiet,
    109                              engine=engine,
    110                              encoding=encoding)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/_tools.py:185, in deprecate_positional_args.<locals>.decorator.<locals>.wrapper(*args, **kwargs)
    177     wanted = ', '.join(f'{name}={value!r}'
    178                        for name, value in deprecated.items())
    179     warnings.warn(f'The signature of {func_name} will be reduced'
    180                   f' to {supported_number} positional arg{s_}{qualification}'
    181                   f' {list(supported)}: pass {wanted} as keyword arg{s_}',
    182                   stacklevel=stacklevel,
    183                   category=category)
--> 185 return func(*args, **kwargs)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/piping.py:121, in Pipe._pipe_legacy(self, format, renderer, formatter, neato_no_op, quiet, engine, encoding)
    112 @_tools.deprecate_positional_args(supported_number=1, ignore_arg='self')
    113 def _pipe_legacy(self,
    114                  format: typing.Optional[str] = None,
   (...)    119                  engine: typing.Optional[str] = None,
    120                  encoding: typing.Optional[str] = None) -> typing.Union[bytes, str]:
--> 121     return self._pipe_future(format,
    122                              renderer=renderer,
    123                              formatter=formatter,
    124                              neato_no_op=neato_no_op,
    125                              quiet=quiet,
    126                              engine=engine,
    127                              encoding=encoding)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/piping.py:149, in Pipe._pipe_future(self, format, renderer, formatter, neato_no_op, quiet, engine, encoding)
    146 if encoding is not None:
    147     if codecs.lookup(encoding) is codecs.lookup(self.encoding):
    148         # common case: both stdin and stdout need the same encoding
--> 149         return self._pipe_lines_string(*args, encoding=encoding, **kwargs)
    150     try:
    151         raw = self._pipe_lines(*args, input_encoding=self.encoding, **kwargs)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/backend/piping.py:212, in pipe_lines_string(engine, format, input_lines, encoding, renderer, formatter, neato_no_op, quiet)
    206 cmd = dot_command.command(engine, format,
    207                           renderer=renderer,
    208                           formatter=formatter,
    209                           neato_no_op=neato_no_op)
    210 kwargs = {'input_lines': input_lines, 'encoding': encoding}
--> 212 proc = execute.run_check(cmd, capture_output=True, quiet=quiet, **kwargs)
    213 return proc.stdout

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/backend/execute.py:81, in run_check(cmd, input_lines, encoding, quiet, **kwargs)
     79 except OSError as e:
     80     if e.errno == errno.ENOENT:
---> 81         raise ExecutableNotFound(cmd) from e
     82     raise
     84 if not quiet and proc.stderr:

ExecutableNotFound: failed to execute PosixPath('dot'), make sure the Graphviz executables are on your systems' PATH
<graphviz.sources.Source at 0x7fba1ea87590>
pd.DataFrame(np.array((X.index, Y[0], predictions)))
0 1 2 3 4 5 6 7 8
0 Wonder Woman Aquaman Cyborg The Flash Black Manta The Penguin Joker Deathstroke Magneto
1 good guy good guy good guy good guy bad guy bad guy bad guy bad guy bad guy
2 good guy good guy good guy good guy bad guy bad guy bad guy bad guy bad guy

Infine, è in teoria possibile utilizzare l'albero ottenuto per ottenere una classificazione per supereroi non utilizzati per costruire l'albero stesso. Per far questo è però necessario tradurre i valori categorici in numeri, riutilizzando i LabelEncoder precedentemente preparati. Per comodità, costruiamo una funzione che si occupi di effettuare questa trasformazione.

def filter(obj):
    transformed_obj = obj[features]
    transformed_obj['creator'] = creator_encoder.transform([transformed_obj['creator']])[0]
    transformed_obj['eye_color'] = eye_col_encoder.transform([transformed_obj['eye_color']])[0]
    transformed_obj['hair_color'] = hair_col_encoder.transform([transformed_obj['hair_color']])[0]
    transformed_obj['intelligence'] = intelligence_encoder.transform([transformed_obj['intelligence']])[0]
    return transformed_obj

Possiamo quindi trasformare, per esempio, un supereroe e verificare a quale etichetta esso viene associato.

clf.predict([filter(heroes.loc['Professor X'])])
/opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/sklearn/utils/validation.py:2749: UserWarning: X does not have valid feature names, but DecisionTreeClassifier was fitted with feature names
  warnings.warn(
array(['bad guy'], dtype=object)

Il risultato ottenuto deve metterci in guardia: non necessariamente infatti un buon comportamento degli alberi di decisione sui dati utilizzati per costruirli è associato a un analogo comportamento nell'indurre etichette per dati nuovi. Le tecniche da utilizzare per valutare la cosiddetta capacità di generalizzazione a nuovi casi esulano però dallo scopo del nostro corso.

In linea di principio gli alberi di decisione si possono costruire utilizzando un qualsiasi indice che valuti l'eterogeneità: la libreria sklearn supporta la scelta di questo criterio attraverso l'argomento opzionale criterion passato al costruttore di DecisionTreeClassifier. Il valore predefinito è 'gini', corrispondente ovviamente all'indice di Gini, ma è anche possibile specificare 'entropy' nel caso in cui si volesse utilizzare l'entropia. Ovviamente l'albero ottenuto usando l'entropia non è necessariamente uguale a quello che si ottiene con l'indice di Gini. Per il nostro esempio, l'unica differenza risiede nel nodo di scelta al di sotto della radice, che sarebbe basato sull'attributo "Intelligence" invece che su "Eye color".

clf = tree.DecisionTreeClassifier(criterion='entropy')
clf = clf.fit(X, Y)

dot_data = tree.export_graphviz(clf, out_file=None,
                                class_names=['bad guy', 'good guy'],
                                feature_names=features)
dot_data = dot_data.replace('digraph Tree {',
                           'digraph Tree {\nbgcolor="#eaf3f5";')
graphviz.Source(dot_data)
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/backend/execute.py:76, in run_check(cmd, input_lines, encoding, quiet, **kwargs)
     75         kwargs['stdout'] = kwargs['stderr'] = subprocess.PIPE
---> 76     proc = _run_input_lines(cmd, input_lines, kwargs=kwargs)
     77 else:

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/backend/execute.py:96, in _run_input_lines(cmd, input_lines, kwargs)
     95 def _run_input_lines(cmd, input_lines, *, kwargs):
---> 96     popen = subprocess.Popen(cmd, stdin=subprocess.PIPE, **kwargs)
     98     stdin_write = popen.stdin.write

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/subprocess.py:1026, in Popen.__init__(self, args, bufsize, executable, stdin, stdout, stderr, preexec_fn, close_fds, shell, cwd, env, universal_newlines, startupinfo, creationflags, restore_signals, start_new_session, pass_fds, user, group, extra_groups, encoding, errors, text, umask, pipesize, process_group)
   1023             self.stderr = io.TextIOWrapper(self.stderr,
   1024                     encoding=encoding, errors=errors)
-> 1026     self._execute_child(args, executable, preexec_fn, close_fds,
   1027                         pass_fds, cwd, env,
   1028                         startupinfo, creationflags, shell,
   1029                         p2cread, p2cwrite,
   1030                         c2pread, c2pwrite,
   1031                         errread, errwrite,
   1032                         restore_signals,
   1033                         gid, gids, uid, umask,
   1034                         start_new_session, process_group)
   1035 except:
   1036     # Cleanup if the child failed starting.

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/subprocess.py:1955, in Popen._execute_child(self, args, executable, preexec_fn, close_fds, pass_fds, cwd, env, startupinfo, creationflags, shell, p2cread, p2cwrite, c2pread, c2pwrite, errread, errwrite, restore_signals, gid, gids, uid, umask, start_new_session, process_group)
   1954 if err_filename is not None:
-> 1955     raise child_exception_type(errno_num, err_msg, err_filename)
   1956 else:

FileNotFoundError: [Errno 2] No such file or directory: PosixPath('dot')

The above exception was the direct cause of the following exception:

ExecutableNotFound                        Traceback (most recent call last)
File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/IPython/core/formatters.py:1036, in MimeBundleFormatter.__call__(self, obj, include, exclude)
   1033     method = get_real_method(obj, self.print_method)
   1035     if method is not None:
-> 1036         return method(include=include, exclude=exclude)
   1037     return None
   1038 else:

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/jupyter_integration.py:98, in JupyterIntegration._repr_mimebundle_(self, include, exclude, **_)
     96 include = set(include) if include is not None else {self._jupyter_mimetype}
     97 include -= set(exclude or [])
---> 98 return {mimetype: getattr(self, method_name)()
     99         for mimetype, method_name in MIME_TYPES.items()
    100         if mimetype in include}

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/jupyter_integration.py:98, in <dictcomp>(.0)
     96 include = set(include) if include is not None else {self._jupyter_mimetype}
     97 include -= set(exclude or [])
---> 98 return {mimetype: getattr(self, method_name)()
     99         for mimetype, method_name in MIME_TYPES.items()
    100         if mimetype in include}

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/jupyter_integration.py:112, in JupyterIntegration._repr_image_svg_xml(self)
    110 def _repr_image_svg_xml(self) -> str:
    111     """Return the rendered graph as SVG string."""
--> 112     return self.pipe(format='svg', encoding=SVG_ENCODING)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/piping.py:104, in Pipe.pipe(self, format, renderer, formatter, neato_no_op, quiet, engine, encoding)
     55 def pipe(self,
     56          format: typing.Optional[str] = None,
     57          renderer: typing.Optional[str] = None,
   (...)     61          engine: typing.Optional[str] = None,
     62          encoding: typing.Optional[str] = None) -> typing.Union[bytes, str]:
     63     """Return the source piped through the Graphviz layout command.
     64 
     65     Args:
   (...)    102         '<?xml version='
    103     """
--> 104     return self._pipe_legacy(format,
    105                              renderer=renderer,
    106                              formatter=formatter,
    107                              neato_no_op=neato_no_op,
    108                              quiet=quiet,
    109                              engine=engine,
    110                              encoding=encoding)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/_tools.py:185, in deprecate_positional_args.<locals>.decorator.<locals>.wrapper(*args, **kwargs)
    177     wanted = ', '.join(f'{name}={value!r}'
    178                        for name, value in deprecated.items())
    179     warnings.warn(f'The signature of {func_name} will be reduced'
    180                   f' to {supported_number} positional arg{s_}{qualification}'
    181                   f' {list(supported)}: pass {wanted} as keyword arg{s_}',
    182                   stacklevel=stacklevel,
    183                   category=category)
--> 185 return func(*args, **kwargs)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/piping.py:121, in Pipe._pipe_legacy(self, format, renderer, formatter, neato_no_op, quiet, engine, encoding)
    112 @_tools.deprecate_positional_args(supported_number=1, ignore_arg='self')
    113 def _pipe_legacy(self,
    114                  format: typing.Optional[str] = None,
   (...)    119                  engine: typing.Optional[str] = None,
    120                  encoding: typing.Optional[str] = None) -> typing.Union[bytes, str]:
--> 121     return self._pipe_future(format,
    122                              renderer=renderer,
    123                              formatter=formatter,
    124                              neato_no_op=neato_no_op,
    125                              quiet=quiet,
    126                              engine=engine,
    127                              encoding=encoding)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/piping.py:149, in Pipe._pipe_future(self, format, renderer, formatter, neato_no_op, quiet, engine, encoding)
    146 if encoding is not None:
    147     if codecs.lookup(encoding) is codecs.lookup(self.encoding):
    148         # common case: both stdin and stdout need the same encoding
--> 149         return self._pipe_lines_string(*args, encoding=encoding, **kwargs)
    150     try:
    151         raw = self._pipe_lines(*args, input_encoding=self.encoding, **kwargs)

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/backend/piping.py:212, in pipe_lines_string(engine, format, input_lines, encoding, renderer, formatter, neato_no_op, quiet)
    206 cmd = dot_command.command(engine, format,
    207                           renderer=renderer,
    208                           formatter=formatter,
    209                           neato_no_op=neato_no_op)
    210 kwargs = {'input_lines': input_lines, 'encoding': encoding}
--> 212 proc = execute.run_check(cmd, capture_output=True, quiet=quiet, **kwargs)
    213 return proc.stdout

File /opt/hostedtoolcache/Python/3.11.16/x64/lib/python3.11/site-packages/graphviz/backend/execute.py:81, in run_check(cmd, input_lines, encoding, quiet, **kwargs)
     79 except OSError as e:
     80     if e.errno == errno.ENOENT:
---> 81         raise ExecutableNotFound(cmd) from e
     82     raise
     84 if not quiet and proc.stderr:

ExecutableNotFound: failed to execute PosixPath('dot'), make sure the Graphviz executables are on your systems' PATH
<graphviz.sources.Source at 0x7fba0ba6c110>