Analisi statistica

Classificazione e regressione

La maggior parte dei problemi dell'analisi statistica di dati si dividono in due grossi ambiti: classificazione e regressione.

Citando il sito MachineLearningMastery (vi rimando a questo articolo per una lettura più approfondita):

Fundamentally, classification is about predicting a label and regression is about predicting a quantity.

Ovvero: nei problemi di classificazione, noi abbiamo delle etichette (label), delle categorie in cui vogliamo suddividere i dati (malati-non malati, immagini di animali-immagini di automobili ecc). Nei problemi di regressione vogliamo prevedere una quantità, come il tasso di diffusione di un virus o la crescita di uno stock di azioni nel tempo.

Classification vs Regression Image

Il tipo di problema che andiamo ad affrontare dipende molto dal modello che abbiamo scelto: se pensiamo di poter avere solo due risultati probabilmente ci troveremo a dover risolvere un problema di classificazione, mentre se cerchiamo nel continuo, ovvero vogliamo stimare un valore che può essere qualsiasi numero reale in un intervallo, ci occuperemo di regressione. La scelta del modello è essa stessa un problema interessante, ma non ci addentreremo troppo nei dettagli. Iniziamo invece a vedere degli esempi.

Regressione

In questa prima parte della lezione ci occuperemo di problemi di regressione. Per farlo, utilizzeremo una libreria molto comoda di python: sklearn. Come al solito, proviamo ad importarla e altrimenti installiamola:

In [ ]:
!pip3 install sklearn

Iniziamo da un esempio semplice di regressione lineare. Senza entrare nei dettagli, per regressione lineare si intende la ricerca della "migliore retta" che rappresenti alcuni dati. L'esempio classico è quello della figura a destra nell'immagine sopra. Questa tecnica poi si adatta facilmente anche ad altri tipi di funzioni, e meno facilmente ad altri tipi ancora. Vediamo come fare semplicemente una regressione lineare utilizzando la libreria sklearn.

In [1]:
import numpy as np
from sklearn.linear_model import LinearRegression
import matplotlib.pyplot as plt
In [2]:
x = np.array([5, 15, 25, 35, 45, 55]).reshape((-1, 1))
y = np.array([5, 20, 14, 32, 22, 38])
print(x)
[[ 5]
 [15]
 [25]
 [35]
 [45]
 [55]]

Il comando reshape ci serve solo per dare al vettore x un formato accettato da sklearn. Ora le x sono le nostre variabili indipendenti (ad esempio, il tempo di una misurazione) e le y quelle dipendenti (il valore della misurazione). Noi supponiamo che x e y siano in relazione linearmente, ovvero y è il prodotto di x per una costante, più un'ulteriore costante: in formule y = mx + q, dove m e q sono uguali per tutte le coppie (x,y). Ovviamente, ciò non sarà esattamente vero per ogni x e y: dobbiamo trovare (se esistono) i "migliori" m e q. Questo ci consente di costruire la "migliore retta" che rappresenta i punti: il fatto che i punti non stiano su quella retta è dovuto ad errori sperimentali, o almeno noi supponiamo che lo sia.

Vediamo come sono disposti i nostri dati:

In [3]:
plt.scatter(x, y)
Out[3]:
<matplotlib.collections.PathCollection at 0x7fbb812a4510>

Ad una prima occhiata una retta che ci sembra buona può essere ad esempio quella che collega il punto in basso a sinistra a quello in alto a destra, passando in mezzo a tutti gli altri. Vediamo come far calcolare questa retta a python, senza bisogno di nessuna competenza statistica. Come prima cosa istanziamo la classe LinearRegressor:

In [4]:
model = LinearRegression()

Adesso, "fittiamo" il modello: ovvero diciamo a python di trovare il miglior "fit", il miglior aggiustamento tra i nostri valori in partenza (x) e in arrivo (y). Come farlo è molto semplice:

In [5]:
model.fit(x, y)
Out[5]:
LinearRegression(copy_X=True, fit_intercept=True, n_jobs=None, normalize=False)

Grazie a questo comando, adesso model contiene alcuni dati importanti: i nostri coefficienti m e q, secondo la miglior stima che ha fatto. Vediamo come ottenerli:

In [6]:
print('Intercetta (q):', model.intercept_)
print('Pendenza (m):', model.coef_)
Intercetta (q): 5.633333333333329
Pendenza (m): [0.54]

Notiamo che mentre q è un valore intero, m è un vettore (con un elemento); questo serve per la regressione multidimensionale, che non ci interessa ora.

Possiamo anche calcolare il coefficiente di determinazione, quello che spesso viene indicato come $R^2$, con il comando score (che prende ancora in input i nostri dati x e y):

In [7]:
print(model.score(x, y))
0.715875613747954

Otteniamo un coefficiente di 0.7, non altissimo ma vista la scarsa mole dei dati comunque accettabile. Possiamo poi predire i valori che otterremo, se il nostro modello è valido, su nuovi dati. Iniziamo a farlo con i dati precedenti:

In [8]:
y_pred = model.predict(x)
print(y_pred)
print(y)
[ 8.33333333 13.73333333 19.13333333 24.53333333 29.93333333 35.33333333]
[ 5 20 14 32 22 38]

Vedete che ciò che il nostro modello prevede sull'input x è diverso dai risultati che abbiamo ottenuto sperimentalmente in y: ci aspettavamo già prima questa cosa, dato che la nostra era un'approssimazione. Vediamo però se, almeno graficamente, la nostra regressione ha ottenuto un risultato "plausibile":

In [9]:
plt.plot(x,y_pred)
plt.scatter(x,y, c="r")
Out[9]:
<matplotlib.collections.PathCollection at 0x7fbb811d39d0>

I dati esatti sono marcati in rosso, e la nostra retta in blu: sembra credibile che tra i nostri dati ci sia una relazione di quel tipo. Prima di proseguire, osserviamo che model.predict(x) non fa altro che prendere un input x e applicargli la funzione y = mx+q, con le m e le q calcolate prima. Questa cosa ovviamente può essere fatta anche a mano:

In [10]:
y_pred = model.intercept_ + model.coef_ * x
print(y_pred)
[[ 8.33333333]
 [13.73333333]
 [19.13333333]
 [24.53333333]
 [29.93333333]
 [35.33333333]]

Siamo ora pronti a disegnare la nostra funzione di stima su un dominio più ampio: per farlo creiamo artificialmente delle x (essendo una retta ce ne bastano poche, in realtà due) e facciamo disegnare a model la nostra funzione:

In [11]:
x_new = np.arange(-20,70,5).reshape((-1, 1))
y_new = model.predict(x_new)
plt.plot(x_new, y_new)
plt.scatter(x, y, c="r")
Out[11]:
<matplotlib.collections.PathCollection at 0x7fbb811562d0>

Come vedete fare regressione (lineare) in python è semplicissimo. Lascio ora un paio di note per i più curiosi. Non è molto più complicato fare regressione polinomiale, in cui invece che due vogliamo stimare tre o più valori. Per farlo basta sistemare l'array x, inserendoci tutti i valori di cui ha bisogno: se vogliamo stimare $ax^2 + bx + c$ dobbiamo fare un array $x = [[x_1, x_1^2], [x_2, x_2^2] ]$ e così via, per poi passarlo al nostro LinearRegressor come prima. Inoltre, impostando il parametro fit_intercept=False dentro la funzione fit, possiamo stimare una retta passante per l'origine. Un'altra cosa che possiamo fare, forse ancora più semplicemente, è stimare altre funzioni che richiedono al più due parametri, anche se all'interno di funzioni più complesse. Poniamo ad esempio di voler fittare la funzione $y = e^x$. In questo caso basta applicare la funzione al vettore delle x, ottenendo un vettore con tutti i valori di $e^x$, e cercare una relazione lineare tra queste nuove x e le y. Poi, basta ricordarsi di imporre questa relazione anche tra i coefficienti trovati. Combinando queste due tecniche (modifica delle x e regressione multidimensionale), o se siete più pigri usando Taylor e la sola regressione polinomiale, potete stimare (un numero finito di parametri di) qualsiasi funzione su dei dati.

Underfitting e Overfitting

Veniamo ora ad analizzare due dei più grandi problemi che si possono incontrare nella modellizzazione dei dati: underfitting e overfitting.

Partiamo dal primo: cosa vuol dire underfitting? Essenzialmente, che stiamo cercando una relazione troppo semplice tra i dati. Possiamo incontrare undefitting abbastanza facilmente se cerchiamo sempre e solo relazioni lineari. Vediamo un semplice esempio:

In [12]:
x = np.array([-3,-2,-1,0,1,2,3]).reshape((-1, 1))
y = np.array([9,4,1,0,1,4,9])
plt.scatter(x,y)
Out[12]:
<matplotlib.collections.PathCollection at 0x7fbb8128b7d0>

Come si vede subito, non esiste una retta che rappresenti questi dati: dovrebbe prima scendere poi salire. In questo caso ci servirebbe avere due rette, o più opportunamente una parabola. Proviamo a vedere cosa succede se cerchiamo la 'retta migliore', come prima:

In [13]:
lin_model = LinearRegression()
lin_model.fit(x,y)
y_pred = lin_model.predict(x)
print("Score:",lin_model.score(x,y))
plt.plot(x, y_pred)
plt.scatter(x, y, c="r")
Score: 0.0
Out[13]:
<matplotlib.collections.PathCollection at 0x7fbb81095f90>

La nostra retta non centra niente con i dati, e anche python ci tiene a farcelo notare assegnando alla nostra previsione uno score di ben 0 (il minimo possibile). Ma ce lo aspettavamo. Vediamo ora, senza entrare nei dettagli, come stimare una relazione a due parametri:

In [14]:
x_ = np.array([ [i, i**2] for i in x ]).reshape(-1,2)
sq_model = LinearRegression()
sq_model.fit(x_, y)
y_pred = sq_model.predict(x_)
print("Score:",sq_model.score(x_,y))
plt.plot(x, y_pred)
plt.scatter(x, y, c="r")
Score: 1.0
Out[14]:
<matplotlib.collections.PathCollection at 0x7fbb81003ad0>

Come potete vedere, ora il fit è perfetto, con uno score di 1. Per evitare un underfitting dobbiamo fare attenzione a tutte le variabili in gioco nei nostri dati, e non cercare relazioni troppo semplici.

A questo punto potrebbe sembrare che alzare il numero dei parametri risolva ogni cosa. Purtroppo non è così: esiste un altro fenomeno, opposto all'underfitting, che si chiama overfitting. L'overfitting è quando cerchiamo relazioni troppo complesse tra i nostri dati. Per strane ragioni matematiche, se il numero dei parametri che usiamo per stimare dei dati è molto vicino al numero dei dati che abbiamo a disposizione, possono succedere cose brutte. Se poi il numero di parametri è superiore a quello dei dati può succedere praticamente ogni cosa: la nostra funzione passerà per tutti i dati senza stimarli (la ragione non è troppo complicata: se imponiamo il passaggio di un polinomio di grado n, quindi n+1 coefficienti, per n punti, abbiamo una matrice delle condizioni che ha rango al più n...). Vediamo anche qui un semplice esempio:

In [15]:
from sklearn.preprocessing import PolynomialFeatures 
poly = PolynomialFeatures(degree = 7) 
x = np.array([0,1,2,3,4,5]).reshape(-1,1)
y = np.array([-1,1,-1,1,-1,1])
x_poly = poly.fit_transform(x) 


model7 = LinearRegression() 
model7.fit(x_poly, y)
print("Score:",model7.score(x_poly, y))

x_pl = np.array([0.1*i for i in range(60)]).reshape(-1,1)
plt.scatter(x, y, color = 'blue') 
plt.plot(x_pl, model7.predict(poly.fit_transform(x_pl)), color = 'red') 
Score: 1.0
Out[15]:
[<matplotlib.lines.Line2D at 0x7fbb8101df90>]

La nostra funzione è chiaramente sbagliata, eppure abbiamo uno score di 1: infatti il fit passa in tutti i punti, quindi è un'approssimazione 'perfetta', ma ovviamente non può aiutarci a predire nulla. Eppure i punti sono tutti un alternarsi di 1 e -1, quindi probabilmente una retta che sia costantemente nulla (due parametri, o addirittura uno) sarebbe già una buona approssimazione per cominciare. Questo è l'overfitting.

Classificazione: il clustering

Veniamo ora ai problemi di classificazione. In questi problemi partiamo da delle 'etichette', o 'label', e vogliamo assegnare ad ogni dato una di queste etichette. Esempi semplici, con solo due etichette, possono essere malati-sani, o transazioni regolari-frodi. Non sempre però sappiamo quante e quali sono le etichette, e scoprirlo può essere a sua volta oggetto di ricerca: pensiamo ad esempio ad una situazione in cui abbiamo dei malati, e vogliamo provare in base ai loro sintomi ad individuare se ci sono e, in tal caso, quanti sono i ceppi o le differenti malattie di cui soffrono. Anche qui, seppur in modo un po' diverso, si possono avere underfitting e overfitting: è molto importante scegliere bene la quantità di label che vogliamo assegnare.

Tra i più semplici problemi di classificazione ci sono i cosiddetti problemi di clustering: abbiamo un insieme di dati, con determinate caratteristiche, e vogliamo dividerli in gruppi. I dati in partenza non sono etichettati: non abbiamo nessuna informazione sui cluster. È chiaro come la scelta del numero di cluster sia molto importante.

Come nei casi precedenti, la libreria sklearn ci fornisce comandi pronti all'uso per implementare semplici algoritmi (o anche algoritmi complessi, che però non usiamo). Quello che andremo ad utilizzare in questo caso si chiama k-means, che potremmo anche implementare senza troppe difficoltà (lascio il link a wikipedia per gli interessati). Vediamo come funziona:

In [16]:
#import numpy as np
#import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

from sklearn.cluster import KMeans
from sklearn import datasets

Le librerie numpy e matplotlib sono commentate perchè le abbiamo già importate, ma ho preferito aggiungerle per ricordarvi che le utilizzeremo. Axes3D ci servirà per i plot 3D, mentre KMeans è l'algoritmo che useremo e dataset è la libreria che ci servirà per scaricare i dataset.

In [17]:
iris = datasets.load_iris()
X = iris.data
y = iris.target
print("X: ",X[:10])
print("y: ",y)
X:  [[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]
 [5.4 3.9 1.7 0.4]
 [4.6 3.4 1.4 0.3]
 [5.  3.4 1.5 0.2]
 [4.4 2.9 1.4 0.2]
 [4.9 3.1 1.5 0.1]]
y:  [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2
 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
 2 2]

Le X sono dati misurati su dei fiori, in particolare degli iris, e rappresentano lunghezza e larghezza di petali e sepali. Le y invece sono la 'soluzione', ovvero quali sono effettivamente le specie dei fiori. Vediamo ora come usare il nostro k-means per suddividere queste iris nelle diverse specie. Iniziamo supponendo ci siano 8 specie (ovvero 8 cluster):

In [18]:
cls = KMeans(n_clusters=8)

# Fitting dei dati
cls.fit(X)

# Comandi per il plot
fig = plt.figure()
ax = Axes3D(fig, rect=[0, 0, .95, 1], elev=48, azim=134)
labels = cls.labels_
print(labels)
ax.scatter(X[:, 3], X[:, 0], X[:, 2],c=labels.astype(np.float), edgecolor='k')
ax.w_xaxis.set_ticklabels([])
ax.w_yaxis.set_ticklabels([])
ax.w_zaxis.set_ticklabels([])
ax.set_xlabel('Petal width')
ax.set_ylabel('Sepal length')
ax.set_zlabel('Petal length')
ax.set_title('8 Cluster')
ax.dist = 12
[0 4 4 4 0 0 4 0 4 4 0 4 4 4 0 0 0 0 0 0 0 0 4 0 4 4 0 0 0 4 4 0 0 0 4 4 0
 0 4 0 0 4 4 0 0 4 0 4 0 4 5 5 5 2 5 2 5 7 5 2 7 2 2 5 2 5 2 2 1 2 1 2 1 5
 5 5 5 5 5 2 2 7 2 1 2 5 5 5 2 2 2 5 2 7 2 2 2 5 7 2 6 1 3 6 6 3 2 3 6 3 6
 1 6 1 1 6 6 3 3 1 6 1 3 1 6 3 1 1 6 3 3 3 6 1 1 3 6 6 1 6 6 6 1 6 6 6 1 6
 6 1]

Come vedete, definiamo il nostro oggetto KMeans passandogli semplicemente il numero di cluster che vogliamo ottenere, e poi gli facciamo suddividere i dati in n gruppi con il comando fit. Dentro la variabile cls.labels_ abbiamo la label assegnata ad ogni dato: sono numeri da 0 a 7 perchè abbiamo usato 8 cluster. Il resto serve solo per fare uno scatter dei nostri punti in una griglia tridimensionale che abbia come assi la lunghezza e la larghezza dei petali, oltre alla lunghezza dei sepali dei nostri fiori. Il colore di ogni punto è definito dalla sua label, tramite il comando astype che semplicemente trasforma gli interi in float (6 diventa 6.0) per fornire il formato giusto alla funzione scatter. Ci rendiamo subito conto che abbiamo esagerato con il numero di cluster: il gruppo in basso a destra, che con ogni probabilità rappresenta una sola specie, è diviso in tre cluster. Questo ci da anche una prima indicazione sul fatto che potremmo avere circa il triplo dei cluster necessari. Proviamo allora a rifare il lavoro di prima con solo 3 cluster:

In [19]:
cls = KMeans(n_clusters=3)

cls.fit(X)

fig = plt.figure()
ax = Axes3D(fig, rect=[0, 0, .95, 1], elev=48, azim=134)
labels = cls.labels_
print(labels)
ax.scatter(X[:, 3], X[:, 0], X[:, 2],c=labels.astype(np.float), edgecolor='k')
ax.w_xaxis.set_ticklabels([])
ax.w_yaxis.set_ticklabels([])
ax.w_zaxis.set_ticklabels([])
ax.set_xlabel('Petal width')
ax.set_ylabel('Sepal length')
ax.set_zlabel('Petal length')
ax.set_title('3 Cluster')
ax.dist = 12
[1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
 0 0 0 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 2 2 2 2 0 2 2 2 2
 2 2 0 0 2 2 2 2 0 2 0 2 0 2 2 0 0 2 2 2 2 2 0 2 2 2 2 0 2 2 2 0 2 2 2 0 2
 2 0]

Come vedete, questo risultato è molto più confortante. Ce ne rendiamo subito conto graficamente, dove abbiamo tre gruppi distinti e abbastanza ben definiti. Inoltre, essendo questo un dataset che ci viene fornito già sistemato, possiamo realisticamente aspettarci che i dati fossero in ordine: prima gli 0, poi gli 1 e poi i 2 (ovviamente in generale non è così). Vediamo quindi che a parte qualche piccola confusione tra 1 e 2 abbiamo fatto generalmente un buon lavoro. Plottiamo ora la soluzione esatta, contenuta in iris.target:

In [20]:
y = iris.target
print(y)
fig = plt.figure()
ax = Axes3D(fig, rect=[0, 0, .95, 1], elev=48, azim=134)

for name, label in [('Setosa', 0),
                    ('Versicolour', 1),
                    ('Virginica', 2)]:
    ax.text3D(X[y == label, 3].mean(),
              X[y == label, 0].mean(),
              X[y == label, 2].mean() + 2, name,
              horizontalalignment='center',
              bbox=dict(alpha=.2, edgecolor='w', facecolor='w'))
y = np.choose(y, [1, 2, 0]).astype(np.float)
ax.scatter(X[:, 3], X[:, 0], X[:, 2], c=y, edgecolor='k')

ax.w_xaxis.set_ticklabels([])
ax.w_yaxis.set_ticklabels([])
ax.w_zaxis.set_ticklabels([])
ax.set_xlabel('Petal width')
ax.set_ylabel('Sepal length')
ax.set_zlabel('Petal length')
ax.set_title('Soluzione esatta')
ax.dist = 12
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2
 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
 2 2]

Come ci aspettavamo, il risultato è molto simile, a parte lo scambio di colori tra giallo e viola (che ovviamente non cambia nulla) e un paio di casi sbagliati nell'intersezione tra quei due gruppi. Ottenere questo risultato con la libreria sklearn non è per nulla difficile: la parte 'complicata' è stato modellizzare il problema, e decidere che quindi ci servivano 3 cluster. In certi casi questo non è un grosso problema (ad esempio se dobbiamo fare clustering su immagini di cifre o di lettere scritte a mano: le cifre avranno 10 cluster, le lettere 26 ecc...), mentre in altri lo può essere. Una cosa che si può fare è lanciare simulazioni con diversi numeri di cluster e vedere il risultato.

Per finire, ancora un paio di note per i più curiosi:

  • questo tipo di algoritmi di clustering lavora dividendo inizialmente i dati in n gruppi scelti a caso, e con alcuni conti non troppo difficili ad ogni passo affina e riordina questa divisione. In certi casi può essere importante come vengono scelti i dati all'inizio: una buona scelta aumenta la velocità di convergenza, una cattiva scelta può non convergere del tutto. Ciò che si può fare in quel caso è provare con diversi dati iniziali e vedere cosa succede (esistono anche studi su come ottimizzare questa scelta, ma qui l'argomento diventa molto complesso)
  • noi abbiamo fatto fare al nostro algoritmo una suddivisione tra i dati senza ulteriori informazioni: ad ogni passo non sapeva se la sua classificazione era giusta o sbagliata. Questo processo si chiama unsupervised learning. La tecnica opposta, chiamata supervised learning, è alla base di molti modelli di quelle che vengono dette 'intelligenze artificiali': si prendono dei dati etichettati (quindi una coppia (valori..)-cluster ) e si lancia un programma che inizialmente prevede a caso l'etichetta da assegnare ad ogni dato, e a ogni errore 'corregge' la sua previsione nella 'direzione' dell'errore. Dopo un po' di tempo, il suo modo di predire le etichette è abbastanza accurato e può essere utilizzato su dati che non ha mai visto. In questo caso il processo di learning è 'supervised', perchè nella prima fase l'algoritmo sa sempre quando sbaglia.

Esercizi

  • Riprendendo il solito database dei film, cercate una relazione lineare tra budget e valutazione
  • Provate a dividere in cluster (il numero è facilmente intuibile) i punti generati dalla funzione seguente (il vettore data è un vettore di coppie [x, y] ):
In [21]:
np.random.seed(5)
pt = [ [0, -1], [-1,1], [1,1]]
data = [ [i,j] * 1000 for i,j in pt]
data = np.array(data).reshape(-1,2)
data = data + np.random.normal(0,0.4,data.shape)
plt.scatter(data[:,0],data[:,1])
Out[21]:
<matplotlib.collections.PathCollection at 0x7fbb7e9f9d90>