Problème

Commencez par télécharger sur la page web du cours les jeux de données sur la page web du cours : http://www.di.ens.fr/appstat/TP/classification_data.zip

Les fichiers sont constitués de trois colonnes. Les deux premières donnent les coordonnées de points d’entrée $x_i ∈ ℝ$.

La troisième est la donnée de sortie correspondante $y_i ∈ \{0, 1\}$. A partir des données d’entraînement des fichiers .train, on veut construire des classifieurs qui seront efficaces sur les données des fichiers .test.

Génération des données

Le premier jeu de données est issu d’un modèle où les points de chaque classe sont générés suivant des loi Gaussiennes de variance commune entre classe mais de moyennes différentes, i.e, $P(X\mid Y = i)$ est une distribution Gaussienne de moyenne $µ_i$ et de matrice de covariance commune Σ.

Pour le deuxième jeu de données, le modèle de génération est le même mais cette fois les matrices de covariances aussi sont différentes. Concernant, le troisième jeu de données, une des classes est le mélange de deux Gaussiennes tandis que l’autre correspond à une seule Gaussienne.

Modèles de régression.

In [1]:
# /!\ Dans ce TP, j'utilise Python3 

import plotly
import numpy as np
import plotly.plotly as py
import plotly.graph_objs as go

import matplotlib.pyplot as plt
import scipy.io as sio
from scipy import stats
%matplotlib inline

plotly.offline.init_notebook_mode(connected=True)
from IPython.core.display import display, HTML
# The polling here is to ensure that plotly.js has already been loaded before
# setting display alignment in order to avoid a race condition.
display(HTML(
    '<script>'
        'var waitForPlotly = setInterval( function() {'
            'if( typeof(window.Plotly) !== "undefined" ){'
                'MathJax.Hub.Config({ SVG: { font: "STIX-Web" }, displayAlign: "center" });'
                'MathJax.Hub.Queue(["setRenderer", MathJax.Hub, "SVG"]);'
                'clearInterval(waitForPlotly);'
            '}}, 250 );'
    '</script>'
))

Commandes MathJax

$$ \newcommand{\e}{\mathop{\rm e}\nolimits} \newcommand{\Tr}{\mathop{\rm Tr}\nolimits} \newcommand{\GL}{\mathop{\rm GL}\nolimits} \newcommand{\Ker}{\mathop{\rm Ker}\nolimits} \newcommand{\I}{\mathop{\rm I}\nolimits} \newcommand{\Nor}{\mathop{\rm Nor}\nolimits} \newcommand{\Cen}{\mathop{\rm C}\nolimits} \newcommand{\Gr}{\mathop{\rm Gr}\nolimits} \newcommand{\ll}{\left[\!\!\left[} \newcommand{\rr}{\right]\!\!\right]} $$

1) Pour chacun des ensembles d’apprentissages A, B et C, représentez les données sous forme d’un nuage bicolore de points en 2D.

In [2]:
letters = ['A', 'B', 'C']
ranges = {}
data = {}
trace = {}
layout = {}

for letter in letters:
    
    data[letter] = {}
    trace[letter] = {}
    ranges[letter] = {}
    
    # Récupération des données d'entraînement et de test sous forme d'array
    for train_test in ['train', 'test']:
        
        with open('classification{}{}'.format(letter, train_test)) as f:
            lines = []
            
            for line in f:
                lines.append(list(map(float, line.split())))
            
            lines = np.array(lines).reshape([-1, 3])

            X, Y = lines[:, :2], lines[:, 2]
            
            data[letter][train_test] = {}
            data[letter][train_test]['X'] = X
            data[letter][train_test]['Y'] = Y
    
    X_1 = np.concatenate((data[letter]['train']['X'][:,0], data[letter]['test']['X'][:,0]))
    X_2 = np.concatenate((data[letter]['train']['X'][:,1], data[letter]['test']['X'][:,1]))
    
    ranges[letter]['x_1'] = [int(np.min(X_1)-1), int(np.max(X_1)+1)]
    ranges[letter]['x_2'] = [int(np.min(X_2)-2), int(np.max(X_2)+2)]
    
    trace[letter]['data'] = go.Scatter(
        x = X_1,
        y = X_2,
        mode = 'markers',
        marker = dict(
            size = 10,
            color = np.concatenate((
                2*data[letter]['train']['Y'], 
                2*data[letter]['test']['Y']+1
            )),
            colorbar = dict(
                title = 'y',
                titleside = 'top',
                tickmode = 'array',
                tickvals = [0, 1, 2, 3],
                ticktext = ['0 - entraînement','0 - test', '1 - entraînement', '1 - test'],
                ticks = 'outside'
            ),
            showscale = True,
            line = dict(
                width = 2,
                color = 'rgb(0, 0, 0)'
            ),
            colorscale = [
                [0, 'rgb(145,191,219)'], [0.25, 'rgb(145,191,219)'],
                [0.25, 'rgb(69,117,180)'], [0.5, 'rgb(69,117,180)'],
                [0.5, 'rgb(252,141,89)'], [0.75, 'rgb(252,141,89)'],
                [0.75, 'rgb(215,48,39)'], [1, 'rgb(215,48,39)']
            ]
        ),
        showlegend = False
    )

    layout[letter] = dict(
        title = 'Ensemble d\'apprentissage {}'.format(letter),
        xaxis = dict(
            title = '$x_1$',
            range = ranges[letter]['x_1'],
            ticklen = 5,
            zeroline = False,
            gridwidth = 2,
            ),
        yaxis = dict(
            title = '$x_2$',
            range = ranges[letter]['x_2'],
            ticklen = 5,
            gridwidth = 2,
            ),
        legend = dict(
            orientation = 'h',
            y = -0.2
        )
    )

    fig = dict(data=[trace[letter]['data']], layout=layout[letter])
    plotly.offline.iplot(fig)

2) Calculez le classifieur obtenu avec la régression linéaire.

In [3]:
classifiers = {}

classifiers['linear'] = {}

# Régression linéaire

def linear_regression(X, Y):
    # matrice de design 
    X = np.hstack((X, np.ones(len(X)).reshape([-1, 1])))

    # Estimateur des moindres carrés ordinaires (OLS): 
    # W = (X^T X)^{-1} X^T Y
    return np.linalg.solve(X.T.dot(X), X.T.dot(Y))

for letter in letters:

    X, Y = data[letter]['train']['X'], data[letter]['train']['Y']
    
    W = linear_regression(X, Y)
    
    # Stocker le classifier pour plus tard
    classifiers['linear'][letter] = W
    
    # Nombre d'erreurs sur l'ensemble de test
    
    a, b, c = W

    # Prédiction de la régression linéaire
    
    range_values = np.linspace(ranges[letter]['x_1'][0], ranges[letter]['x_1'][1], 20)

    trace[letter]['linear'] = go.Scatter(
        x = range_values,
        # a x_1 + b x_2 + c = 0.5 -> x_2 = (-a x_1 - c + 0.5)/b
        # ici: x = x_1, y = x_2 
        y = (-a*range_values - c + 0.5)/b,
        mode = 'lines',
        line = dict(
            color = ('rgb(7,142,145)'),
            width = 4,
            dash = 'dot'),
        name = 'Régression linéaire'
    )
    
    fig = dict(data=[trace[letter]['data'], trace[letter]['linear']], layout=layout[letter])
    plotly.offline.iplot(fig)

3) Implémentez le classifieur obtenu par régression logistique en utilisant soit une méthode de descente de gradient, soit l’algorithme des moindres carrés repondérés.

In [4]:
classifiers['logistic'] = {}

# cf. le cours http://www.di.ens.fr/appstat/notes/cours2-regression.pdf (pages 10-12)

def logistic_loss(y_hat, y):
    return y * np.log(1 + np.exp(-y_hat)) + (1 - y) * np.log(1 + np.exp(y_hat))

logistic_loss = np.vectorize(logistic_loss)

def sigmoid(x):
    return 1/(1 + np.exp(-x))

sigmoid = np.vectorize(sigmoid)

def risk(W, X, Y):
    return np.mean(logistic_loss(X.dot(W), Y))

def gradient_risk(W, X, Y, n):
    return (X.T.dot(sigmoid(X.dot(W)) - Y))/n

    
def logistic_regression(X, Y, display_risk=False):
    # matrice de design 
    X = np.hstack((X, np.ones(len(X)).reshape([-1, 1])))
    n = len(X)

    ################## Descente de gradient: début

    # Initialisation
    W = np.zeros(3)
    n_iter_max = 300
    eta = 10 # paramètre d'apprentissage
    eps = 10**(-4)  # critère d'arrêt de l'algorithme
    risks = [] # liste des risques calculés

    for i in range(n_iter_max):

        grad_W = gradient_risk(W, X, Y, n)

        # Mise à jour de W: W_{i+1} = W_i - eta * grad_W
        W -= eta * grad_W

        # Mise à jour du risque
        risks.append(risk(W, X, Y))

        if np.linalg.norm(grad_W) < eps:
            break

    if display_risk:
        # Affichage du risque lors de la descente de gradient 
        trace_risk = go.Scatter(
            x = list(range(1, len(risks)+1)),
            y = risks,
            mode = 'lines',
            name = 'Risque logistique'
        )

        layout_risk = go.Layout(
            title= 'Ensemble {} : Évolution du risque lors de la descente de gradient'.format(letter),
            hovermode= 'closest',
            xaxis= dict(
                title= 'Itérations',
                ticklen= 5,
                zeroline= False,
                gridwidth= 2,
            ),
            yaxis=dict(
                title= 'Risque',
                type='log',
                ticklen= 5,
                gridwidth= 2,
            ),
            showlegend= True
        )

        plotly.offline.iplot(go.Figure(data=[trace_risk], layout=layout_risk))

    ############# Descente de gradient: fin

    return W

for letter in letters:

    X, Y = data[letter]['train']['X'], data[letter]['train']['Y']

    W = logistic_regression(X, Y, display_risk=True)
    
    # Stocker le classifieur pour plus tard
    classifiers['logistic'][letter] = W

    a, b, c = W

    # Prédiction de la régression logistique
    
    range_values = np.linspace(ranges[letter]['x_1'][0], ranges[letter]['x_1'][1], 20)

    trace[letter]['logistic'] = go.Scatter(
        x = range_values,
        # sigmoid(a x_1 + b x_2 + c) = 0.5 -> x_2 = (-a x_1 - c)/b
        # ici: x = x_1, y = x_2 
        y = (-a*range_values - c)/b,
        mode = 'lines',
        line = dict(
            color = ('rgb(236,215,4)'),
            width = 4,
            dash = 'dash'),
        name = 'Régression logistique'
    )
    
    fig = dict(data=[trace[letter]['data'], trace[letter]['linear'], trace[letter]['logistic']],\
               layout=layout[letter])
    plotly.offline.iplot(fig)

Discriminant de Fisher

On propose un autre modèle de classification dit génératif. On modélise les données de chaque classe comme un nuage Gaussien de distribution $𝒩(µ_1, Σ)$ pour la classe $1$ (i.e., $Y = 1$) et $𝒩(µ_0, Σ)$ pour la classe $0$ (i.e., $Y = 0$), où la matrice de covariance $Σ$ est supposée la même pour les deux classes.

On pose aussi $π_1 = ℙ(Y = 1)$, la probabilité pour un exemple d’avoir le label $1$. Ce modèle s’appelle le modèle de l’analyse discriminante linéaire (LDA en anglais).

NB: Comme $π$ apparaît dans l'expression des Gaussiennes, j'ai changé l'énoncé et ai posé $$π_1 ≝ ℙ(Y = 1)$$

4)

a) Quel est le modèle conditionnel de $ℙ(Y |X = x)$ induit par ce modèle génératif ?

Pour tout $k∈ \{0, 1\}$, par la règle de Bayes :

$$ \begin{align} P(Y=k \mid X=x) &= \frac{P(Y=k) \,P(X=x \mid Y=k)}{P(X=x)} \\ &= \frac{P(Y=k) \, P(X=x \mid Y=k)}{\displaystyle\sum_{i=0}^1 P(Y=i)\, P(X=x \mid Y=i)} \\ &= \frac{1}{1 + \displaystyle\frac{P(Y = 1-k)\, P(X=x \mid Y = 1-k)}{P(Y = k)\, P(X=x \mid Y = k)}} \\ &= \frac{1}{1 + \exp\left(-\ln\left(\displaystyle\frac{P(Y = k)\, P(X=x \mid Y = k)}{P(Y = 1-k)\, P(X=x \mid Y = 1-k)}\right)\right)} \end{align} $$

$$ \begin{align} \ln\left(\displaystyle\frac{P(Y = k)\, P(X=x \mid Y = k)}{P(Y = 1-k)\, P(X=x \mid Y = 1-k)}\right) &= \ln\left(\displaystyle\frac{P(Y = k)}{P(Y = 1-k)}\right) + \ln\left(\frac{ \frac{1}{2π \sqrt{\det Σ}} \,\exp \left(- \frac 1 2 (x - μ_k)^T Σ^{-1} (x - μ_k) \right) }{ \frac{1}{2π \sqrt{\det Σ}} \,\exp \left(- \frac 1 2 (x - μ_{1-k})^T Σ^{-1} (x - μ_{1-k}) \right) }\right)\\ &= \ln\left(\displaystyle\frac{π_1^k (1-π_1)^{1-k}}{π_1^{1-k} (1-π_1)^k}\right) + \ln\left(\exp \left(-\frac 1 2 \Big((x - μ_k)^T Σ^{-1} (x - μ_k) - (x - μ_{1-k})^T Σ^{-1} (x - μ_{1-k})\Big) \right)\right) \\ &= \ln\left(\displaystyle\frac{π_1^k (1-π_1)^{1-k}}{π_1^{1-k} (1-π_1)^k}\right) -\frac 1 2 \Big( x^T Σ^{-1} x + μ_k^T Σ^{-1} μ_k \underbrace{- x^T Σ^{-1} μ_k - μ_k^T Σ^{-1} x}_{= \; - 2 x^T Σ^{-1} μ_k} - (x - μ_{1-k})^T Σ^{-1} (x - μ_{1-k})\Big) \\ &= \ln\left(\displaystyle\frac{π_1^k (1-π_1)^{1-k}}{π_1^{1-k} (1-π_1)^k}\right) -\frac 1 2 \Big(μ_k^T Σ^{-1} μ_k - μ_{1-k}^T Σ^{-1} μ_{1-k} - 2 x^T Σ^{-1} (μ_k - μ_{1-k}) \Big) \\ &= \underbrace{\ln\left(\displaystyle\frac{π_1^k (1-π_1)^{1-k}}{π_1^{1-k} (1-π_1)^k}\right) + \frac 1 2 \big(μ_{1-k}^T Σ^{-1} μ_{1-k} - μ_{k}^T Σ^{-1} μ_{k}\big)}_{≝ \; α_k} + x^T Σ^{-1} (μ_k - μ_{1-k}) \\ \end{align} $$

Donc

$$P(Y=k \mid X=x) = \frac{1}{1 + \e^{-\left(α_k + x^T Σ^{-1} (μ_k - μ_{1-k})\right)}}$$

où $$α_k ≝ \ln\left(\displaystyle\frac{π_1^k (1-π_1)^{1-k}}{π_1^{1-k} (1-π_1)^k}\right) + \frac 1 2 \big(μ_{1-k}^T Σ^{-1} μ_{1-k} - μ_{k}^T Σ^{-1} μ_{k}\big)$$

b) Montrez qu’il correspond à un modèle probabiliste de classification linéaire de la même forme que la régression logistique.

En posant $\tilde{x} = \begin{pmatrix}x \\ 1 \\ \end{pmatrix}$, il vient, avec $k=1$, que

$$P(Y=1 \mid X=x) = \frac{1}{1 + \exp\Big(-\big(\underbrace{α_1 + x^T Σ^{-1} (μ_1 - μ_0)}_{≝ \; f(\tilde{x})}\big)\Big)} = σ(f(\tilde{x}))$$

  • $$f(\tilde{x}) ≝ \tilde{x}^T \underbrace{\begin{pmatrix} Σ^{-1} (μ_1 - μ_0) \\ α_1 \\ \end{pmatrix}}_{≝ \; β}$$

  • $$α_1 ≝ \ln\left(\displaystyle\frac{π_1}{1-π_1}\right) + \frac 1 2 \big(μ_0^T Σ^{-1} μ_0 - μ_1^T Σ^{-1} μ_1\big)$$

$P(Y=1 \mid X=x)$ s'écrit donc sous la forme $σ(f(\tilde{x}))$, où $f(\tilde{x}) ≝ \tilde{x}^T β$ : l'analyse discriminante linéaire et la régression logistique utilisent le même modèle de classification linéaire sous-jacent.

c) Appliquez le modèle aux données en utilisant le principe du maximum de vraisemblance pour apprendre les paramètres du modèle génératif.

On procède de manière analogue à ce qu'on a vu dans le cours:

Principe du maximum de vraisemblance

  • $μ ≝ \begin{pmatrix} μ_0 \\ μ_1 \end{pmatrix} ∈ ℝ^2$
  • $Σ ∈ 𝒮_2(ℝ)$ est symmétrique définie positive

Dans la suite, on note

  • $x_i ≝ \begin{pmatrix} x_i^{(1)} \\ x_i^{(2)} \end{pmatrix}$ les points d'entrée
  • $\vec{x} ≝ (x_1, \ldots, x_n)$
  • $\vec{y} ≝ (y_1, \ldots, y_n)$

La log-vraissemblance s'écrit :

$$\begin{align*}\ln L(\vec{x}, \vec{y}, μ, σ^2) &= -\sum\limits_{ i=1 }^n \ln \underbrace{P(X=x_i, Y=y_i)}_{=\; P(X=x_i \mid Y=y_i) P(Y=y_i)}\\ &= -\sum\limits_{ i=1 }^n \ln \Big(\big(π_1 P(X=x_i \mid Y=1)\big)^{y_i}\big((1-π_1) P(X=x_i \mid Y=0)\big)^{1-y_i}\Big)\\ & = \underbrace{- \sum\limits_{ i=1 }^n y_i \ln π_1 + (1-y_i) \ln(1-π_1) + y_i \ln\left(\frac{1}{2π \sqrt{\det Σ}} \exp \left(- \frac 1 2 (x_i - μ_1)^T Σ^{-1} (x_i - μ_1) \right)\right) + (1-y_i) \ln\left(\frac{1}{2π \sqrt{\det Σ}} \exp \left(- \frac 1 2 (x_i - μ_0)^T Σ^{-1} (x_i - μ_0) \right)\right)}_{≝ \; l(π_1, μ, Σ)}\end{align*}$$

Estimation de $π_1$

Vue comme une fonction de la variable $π_1$ seulement, $l$ est convexe et s'écrit sous la forme :

$$-\sum\limits_{ i=1 }^n y_i \ln π_1 + (1-y_i) \ln(1-π_1) + cste$$

L'annulation de la dérivée par rapport à $π_1$ donne :

$$ \begin{align*} & \sum\limits_{ i=1 }^n \frac{y_i}{π_1} - \frac{1-y_i}{1-π_1} = 0 \\ ⟺ \quad& π_1 = \frac{\sum\limits_{ i=1 }^n y_i}{\sum\limits_{ i=1 }^n y_i + (1-y_i)} \\ ⟺ \quad& π_1 = \frac 1 n \sum\limits_{ i=1 }^n y_i \end{align*} $$

$π_1$ est égal à la proportion de $x_i$ dont le label $y_i$ vaut $1$ (dans l'ensemble d'entraînement)

Estimation de $μ_1$

Vue comme une fonction de $μ_1$ seulement, $l$ est convexe et s'écrit sous la forme :

$$\sum\limits_{ i=1 }^n \frac{y_i}{2} (x_i - μ_1)^T Σ^{-1} (x_i - μ_1) + cste$$

L'annulation de la dérivée par rapport à $μ_1$ donne :

$$ \begin{align*} & \sum\limits_{ i=1 }^n y_i Σ^{-1} (x_i - μ_1) = 0 \\ ⟺ \quad& μ_1 = \frac{\sum\limits_{ i=1 }^n y_i x_i}{\sum\limits_{ i=1 }^n y_i} \end{align*} $$

De même :

$$μ_0 = \frac{\sum\limits_{ i=1 }^n (1-y_i) x_i}{\sum\limits_{ i=1 }^n (1-y_i)}$$

$μ_1$ (resp. $μ_0$) est égal à la moyenne des $x_i$ dont le label $y_i$ vaut $1$ (resp. $0$) (dans l'ensemble d'entraînement).

Estimation de $Σ$

Vue comme une fonction de $Λ ≝ Σ^{-1}$ seulement, $l$ est convexe et s'écrit sous la forme :

$$\sum\limits_{ i=1 }^n -\frac{1}{2} \ln(\vert Λ \vert) + \frac{y_i}{2} \underbrace{\underbrace{(x_i - μ_1)^T}_{1 × 2} \underbrace{Λ}_{2 × 2} \underbrace{(x_i - μ_1)}_{2 × 1}}_{\Tr((x_i - μ_1)^T Λ (x_i - μ_1))} + \frac{1-y_i}{2} \underbrace{\underbrace{(x_i - μ_0)^T}_{1 × 2} \underbrace{Λ}_{2 × 2} \underbrace{(x_i - μ_0)}_{2 × 1}}_{=\, \Tr((x_i - μ_0)^T Λ (x_i - μ_0))} + cste$$

L'annulation de la dérivée par rapport à $Λ$ donne :

$$ \begin{align*} & \sum\limits_{ i=1 }^n - Λ^{-1} + y_i(x_i - μ_1) (x_i - μ_1)^T + (1-y_i)(x_i - μ_0) (x_i - μ_0)^T = 0 \\ ⟺ \quad& Σ = Λ^{-1} = \frac 1 n \sum\limits_{ i=1 }^n y_i(x_i - μ_1) (x_i - μ_1)^T + (1-y_i)(x_i - μ_0) (x_i - μ_0)^T \end{align*} $$


Frontière de classification

On prédit $y=1$ si, et seulement si :

$$ \begin{align*} & P(X=x, Y=1) ≥ P(X=x, Y=0) \\ ⟺ \quad& \frac{P(X=x, Y=1)}{P(X=x, Y=0)} ≥ 1 \\ ⟺ \quad& \ln \left(\frac{P(X=x, Y=1)}{P(X=x, Y=0)}\right) ≥ 0 \\ ⟺ \quad& \ln \left(\frac{P(X=x \mid Y=1) P(Y=1)}{P(X=x \mid Y=0) P(Y=0)}\right) ≥ 0 \\ ⟺ \quad& \underbrace{\ln\left(\displaystyle\frac{π_1}{1-π_1}\right) + \frac 1 2 \big(μ_0^T Σ^{-1} μ_0 - μ_1^T Σ^{-1} μ_1\big)}_{≝ \; α_1} + x^T Σ^{-1} (μ_1 - μ_0) ≥ 0 &&\text{(cf. question précédente)}\\ ⟺ \quad& \tilde{x}^T \underbrace{\begin{pmatrix} Σ^{-1} (μ_1 - μ_0) \\ α_1 \\ \end{pmatrix}}_{≝ \; β} ≥ 0 &&\text{(avec les notations de la question précédente)} \end{align*} $$

In [5]:
classifiers['LDA'] = {}

# LDA : 
# classification binaire : les données sont catégorisées en 2 classes :
# 1. les x_i dont le label y_i vaut c
# 2. les autres x_i
# Ici : c=1 (l'autre classe étant 0)
# NB: fait de procéder ainsi facilitera les choses plus tard, pour MNIST

def LDA(X, Y, c=1):
    n = len(X)
    Y = Y.flatten()
    
    # pi_1 : proportion de x_i dont le label y_i vaut c
    pi_1 = np.mean(Y == c)
    
    # mu_1 (resp. mu_0) : vecteur moyen des x_i dont le label y_i vaut c (resp. non c) 
    mu_1 = np.mean(X[(Y == c), :], axis=0)
    mu_0 = np.mean(X[~(Y == c), :], axis=0)
    
    # Sigma : 
    Sigma = np.sum((x-mu_1).reshape([-1, 1]).dot((x-mu_1).reshape([1, -1])) 
                        if y == c else
                   (x-mu_0).reshape([-1, 1]).dot((x-mu_0).reshape([1, -1])) 
                        for x, y in zip(X, Y))/n
    
    # alpha_1, beta
    SigmaInv = np.linalg.pinv(Sigma) # Sigma^{-1}
    
    alpha_1 = np.log(pi_1/(1-pi_1)) \
            + mu_0.dot(SigmaInv.dot(mu_0))/2 - mu_1.dot(SigmaInv.dot(mu_1))/2
    
    beta = np.concatenate((SigmaInv.dot(mu_1 - mu_0), np.array([alpha_1])))
    
    return beta


for letter in letters:

    X, Y = data[letter]['train']['X'], data[letter]['train']['Y']
    
    beta = LDA(X, Y)
    
    # matrice de design 
    X = np.hstack((X, np.ones(len(X)).reshape([-1, 1])))
    
    # Stocker le classifieur pour plus tard
    classifiers['LDA'][letter] = beta

    a, b, c = beta

    # Prédiction de l'analyse discriminante linéaire
    
    range_values = np.linspace(ranges[letter]['x_1'][0], ranges[letter]['x_1'][1], 20)

    trace[letter]['LDA'] = go.Scatter(
        x = range_values,
        # a x_1 + b x_2 + c = 0 -> x_2 = (-a x_1 - c)/b
        # ici: x = x_1, y = x_2 
        y = (-a*range_values - c)/b,
        mode = 'lines',
        line = dict(
            color = ('rgb(221,52,151)'),
            width = 1),
        name = 'LDA'
    )
    
    fig = dict(data=[trace[letter]['data'], trace[letter]['linear'], trace[letter]['logistic'],\
                     trace[letter]['LDA']], layout=layout[letter])
    plotly.offline.iplot(fig)

d) Quelles frontières de classification obtient-on si l’on autorise les matrices de covariances à être différentes pour les deux classes ? On appelle cette méthode QDA (Quadratic Discriminant Analysis). Appliquez le modèle aux données en utilisant le principe du maximum de vraisemblance pour apprendre les paramètres du modèle génératif.

Si on autorise les matrices de covariances à être différentes pour les deux classes (on les note $Σ_0$ et $Σ_1$), il vient, en reprenant les calculs, que :

  • $$Σ_0 = \frac{\sum\limits_{ i=1 }^n (1-y_i)(x_i - μ_0) (x_i - μ_0)^T}{\sum\limits_{ i=1 }^n (1-y_i)}$$
  • $$Σ_1 = \frac{\sum\limits_{ i=1 }^n y_i(x_i - μ_1) (x_i - μ_1)^T}{\sum\limits_{ i=1 }^n y_i}$$

Frontière de classification

On prédit $y=1$ si, et seulement si :

$$ \begin{align*} & \underbrace{\ln\left(\displaystyle\frac{π_1}{1-π_1}\right) + \frac 1 2 \big(μ_0^T Σ_0^{-1} μ_0 - μ_1^T Σ_1^{-1} μ_1\big)}_{≝ \; α'_1} + x^T (Σ_1^{-1} μ_1 - Σ_0^{-1} μ_0) + x^T (Σ_0^{-1} - Σ_1^{-1}) x ≥ 0 \\ ⟺ \quad& \tilde{x}^T \underbrace{\begin{pmatrix} Σ_1^{-1} μ_1 - Σ_0^{-1} μ_0 \\ α'_1 \\ \end{pmatrix}}_{≝ \; β'} + x^T (Σ_0^{-1} - Σ_1^{-1}) x ≥ 0 &&\text{(avec les notations des questions précédentes)} \end{align*} $$

D'où l'adjectif "quadratique" : la frontière de classification a un terme d'ordre $2$.

In [6]:
classifiers['QDA'] = {}

def QDA(X, Y, c=1):
    Y = Y.flatten()
    
    # pi_1 : proportion de x_i dont le label y_i vaut c
    pi_1 = np.mean(Y == c)
    
    # mu_1 (resp. mu_0) : vecteur moyen des x_i dont le label y_i vaut c (resp. non c) 
    mu_1 = np.mean(X[(Y == c), :], axis=0)
    mu_0 = np.mean(X[~(Y == c), :], axis=0)
    
    # Sigma_k :
    
    Sigma_1 = np.sum((x-mu_1).reshape([-1, 1]).dot((x-mu_1).reshape([1, -1]))\
                     for x, y in zip(X, Y) if y == c)/np.sum(Y == c)

    Sigma_0 = np.sum((x-mu_0).reshape([-1, 1]).dot((x-mu_0).reshape([1, -1]))\
                     for x, y in zip(X, Y) if y != c)/np.sum(~(Y == c))
    
    # alpha_prime_1, beta_prime
    Sigma1Inv, Sigma0Inv = np.linalg.pinv(Sigma_1), np.linalg.pinv(Sigma_0) # Sigma_k^{-1}
    
    alpha_prime_1 = np.log(pi_1/(1-pi_1)) \
            + mu_0.dot(Sigma0Inv.dot(mu_0))/2 - mu_1.dot(Sigma1Inv.dot(mu_1))/2
    
    beta_prime = np.concatenate((Sigma1Inv.dot(mu_1) - Sigma0Inv.dot(mu_0), np.array([alpha_prime_1])))
    
    # pour le terme d'ordre 2 : Sigma_0^{-1} - Sigma_1^{-1}
    SigmaInvMinus = Sigma0Inv - Sigma1Inv
    
    return beta_prime, SigmaInvMinus

for letter in letters:

    X, Y = data[letter]['train']['X'], data[letter]['train']['Y']
    
    beta_prime, SigmaInvMinus = QDA(X, Y)
        
    # matrice de design 
    X = np.hstack((X, np.ones(len(X)).reshape([-1, 1])))
    
    # Stocker le classifieur pour plus tard
    classifiers['QDA'][letter] = beta_prime, SigmaInvMinus

    a, b, c = beta_prime
    d, e, f, g = SigmaInvMinus.flatten()

    # Prédiction de l'analyse discriminante quadratique
    
    x1_range_values = np.linspace(ranges[letter]['x_1'][0], ranges[letter]['x_1'][1], 100)
    x2_range_values = np.linspace(ranges[letter]['x_2'][0], ranges[letter]['x_2'][1], 100)
    
    x_1, x_2 = np.meshgrid(x1_range_values, x2_range_values)
    
    # a x_1 + b x_2 + c + x^T (Sigma_0^{-1} - Sigma_1^{-1}) x = 0
    contour_lines = a*x_1 + b*x_2 + c + x_1*(d*x_1+e*x_2) + x_2*(f*x_1+g*x_2)

    trace[letter]['QDA'] = go.Contour(
        x = x1_range_values,
        y = x2_range_values,
        z = contour_lines,
        contours=dict(
            start = 0,
            end = 0,
        ),
        colorbar = dict(
            title = 'QDA',
            x = -0.17,
            tickmode = 'array',
            tickvals = [0],
            ticktext = ['y prédit'],
            tickangle = -90,
            ticks = 'inside',
        ),
        colorscale = [[0, 'rgba(69,117,180,.5)'], [1, 'rgba(215,48,39,.5)']]
    )
    
    fig = dict(data=[trace[letter]['data'], trace[letter]['QDA']], layout=layout[letter])
    plotly.offline.iplot(fig)

5) Pour chacun de ces quatre classifieurs (régression linéaire, régression logistique, LDA et QDA) et pour chacun des ensembles d’entraînement, représentez sur une figure le nuage de points des données et la frontière de séparation entre les deux classes correspondant au classifieur appris.

In [7]:
for letter in letters:
    fig = dict(data=[trace[letter]['data'], trace[letter]['linear'], trace[letter]['logistic'],\
                     trace[letter]['LDA'], trace[letter]['QDA']], layout=layout[letter])
    plotly.offline.iplot(fig)

6) Calculez le taux d’erreur en classification, c’est-à-dire le risque empirique pour la perte $0 − 1$, sur les données d’entraînement d’une part et sur les données de test d'autre part pour ces quatre classifieurs.

In [8]:
from IPython.display import HTML

# Taux d'erreur
error_rate = {}

for letter in letters:
    error_rate[letter] = {}

def compute_error_rates(letters, list_classifiers, error_rate=error_rate):
    for letter in letters:
        #### Pour chaque classifieur 
        for classifier in list_classifiers:
            error_rate[letter][classifier] = {}

            # Taux d'erreur sur l'ensemble d'entraînement et de test
            for train_test in ['train', 'test']:
                X, Y = data[letter][train_test]['X'], data[letter][train_test]['Y']
                Y = Y.flatten()
                
                # matrice de design 
                X = np.hstack((X, np.ones(len(X)).reshape([-1, 1])))

                W = classifiers[classifier][letter]

                # taux d'erreur
                if classifier == 'linear':
                    error_rate[letter][classifier][train_test] = np.mean(((X.dot(W) >= 0.5) != Y))
                elif classifier == 'logistic':
                    error_rate[letter][classifier][train_test] = np.mean(((sigmoid(X.dot(W)) >= 0.5) != Y))
                elif classifier == 'LDA':
                    if letter != 'mnist':
                        error_rate[letter][classifier][train_test] = np.mean((X.dot(W) >= 0.) != Y)
                    else:
                        # Pour plus tard, avec MNIST :
                        # pour tout x_j :
                        # le W[i] qui maximise x_j W[i] détermine la classe (le chiffre) prédite de x_j
                        
                        error_rate[letter][classifier][train_test] = 0
                        
                        for ind, x in enumerate(X):
                            maximized_class = np.argmax(np.array([x.dot(w) for w in W]))
                            if maximized_class != Y[ind]:
                                error_rate[letter][classifier][train_test] += 1
                        
                        error_rate[letter][classifier][train_test] /= len(X)
                        
                elif classifier == 'QDA':
                    if letter != 'mnist':
                        frontier = np.array([x.dot(W[0]) + x[:-1].dot(W[1].dot(x[:-1])) >= 0 for x in X])
                        error_rate[letter][classifier][train_test] = np.mean(frontier != Y)
                    else:
                        # Pour plus tard, avec MNIST :
                        error_rate[letter][classifier][train_test] = 0
                        
                        for ind, x in enumerate(X):
                            frontier = np.array([x.dot(w[0]) + x[:-1].dot(w[1].dot(x[:-1])) for w in W])
                            maximized_class = np.argmax(frontier)
                            
                            if maximized_class != Y[ind]:
                                error_rate[letter][classifier][train_test] += 1
                        
                        error_rate[letter][classifier][train_test] /= len(X)
    
    return error_rate

error_rate = compute_error_rates(letters, ['linear', 'logistic', 'LDA', 'QDA'])

# Affichage du tableau des taux d'erreurs

s = """<table>
<tr>
<th style='text-align:center'>A</th>
<th style='text-align:center'>B</th>
<th style='text-align:center'>C</th>
</tr>
<tr>
"""

for letter in letters:
    s += """<td>
    <table>
    <tr>
        <th></th>
        <th style='text-align:center'>train</th>
        <th style='text-align:center'>test</th>
    </tr>
    """
    
    for classifier in classifiers:
        s += """<tr>
        <th>{}</th>
        <td>{}</td>
        <td>{}</td>
        """.format(classifier, error_rate[letter][classifier]['train'],\
                   error_rate[letter][classifier]['test'])
    
    s += """</tr>
    </table>
    </td>
    """

s += """</tr>
</table>
"""

HTML(s)
Out[8]:
A B C
train test
linear 0.016 0.02666666666666667
logistic 0.017333333333333333 0.02666666666666667
LDA 0.017333333333333333 0.02666666666666667
QDA 0.016666666666666666 0.02
train test
linear 0.0415 0.03
logistic 0.0375 0.023333333333333334
LDA 0.0415 0.03
QDA 0.037 0.03
train test
linear 0.043 0.0575
logistic 0.025 0.0425
LDA 0.042333333333333334 0.055
QDA 0.05266666666666667 0.07

7) Comparez les taux d’erreurs sur les données d’entraînement et sur les données de test. Comparer les performances relatives des différents algorithmes de classification en fonction de la structure des données A, B et C.

In [9]:
# Diagramme en bâtons
trace_bar = {}

def bar_chart_error(letters, list_classifiers, list_colors, trace_bar = trace_bar):
    learning_sets = ['{} {}'.format(letter, train_test) 
                     for letter in letters for train_test in ['train', 'test']]

    for classifier, color in zip(list_classifiers, list_colors):
        trace_bar[classifier] = go.Bar(
            x = learning_sets,
            y = [error_rate[letter][classifier][train_test] 
                 for letter in letters for train_test in ['train', 'test']],
            name = classifier,
            marker=dict(
                color=color,
            ),
            opacity=0.75
        )
    
    return trace_bar

list_classifiers = ['linear', 'logistic', 'LDA', 'QDA']
list_colors = ['rgb(7,142,145)', 'rgb(236,215,4)', 'rgb(221,52,151)', 'rgb(215,48,39)']

trace_bar = bar_chart_error(letters, list_classifiers, list_colors)

layout_bar = go.Layout(
    barmode='group',
    title = 'Taux d\'erreur : comparaison',
    xaxis = dict(
        title='Ensembles d\'apprentissage'
    ),
    yaxis = dict(
        title = 'Taux d\'erreur'
    ),
)
fig = go.Figure(data=[trace_bar[key] for key in trace_bar], layout=layout_bar)
plotly.offline.iplot(fig)

Dans les différents ensembles d'apprentissage, je parlerai de "points bleus" (resp. "points rouges") pour désigner les points dont le label est 0 (resp. 1).

  • Dans l'ensemble A : les points bleus et rouges semblent générés par deux Gaussiennes (une pour chaque couleur) dont la matrice de covariance a un valeur propre bien plus grande que l'autre, donnant une forme d'"ellispe allongée" aux amas bleus et rouge. De plus, ils se chevauchent un peu, mais semblent néanmoins séparables par une droite, d'où :

    • la très bonne performance (un peu meilleure que les autres sur l'ensemble d'entraînement) de la régression linéaire sur cet ensemble
    • le fait que l'ensemble, les classifieurs se valent à peu près sur A

    On notera tout de même que QDA est meilleur que les autres sur l'ensemble de test : la conique de séparation épouse un peu la frontière de séparation des amas bleus et rouges.

  • Dans l'ensemble B : les amas bleus et rouges semblent encore générés par deux Gaussiennes :

    • celle des points rouges étant semblable à celle de l'ensemble A
    • celle des points bleus ayant une matrice de covariance avec des valeurs propres de taille similaire, donnant une forme de disque à l'amas bleu

    Il est bien plus dur de séparer linéairement les deux amas, puisque l'amas rouge "plonge" (s'enfonce) dans l'amas bleu, d'où un taux d'erreur des classifieurs plus élevé que précédemment.

    • la régression logistique et QDA se démarque en étant meilleurs sur l'ensemble d'entraînement (la régression logisitique étant aussi meilleure que les autres sur l'ensemble test):

      • la régression logistique dresse une frontière de séparation linéaire un peu plus serrée contre l'amas bleu (du fait de la descente de gradient, qui "abaisse" la frontière de séparation de la régression linéaire et LDA (avec un nombre d'itérations inférieurs, régressions logistique, linéaire et LDA se superposent presque)
      • la conique de QDA "enveloppe" l'amas rouge plongeant dans l'amas bleu
  • L'ensemble C est très similaire à B, à l'exception d'un deuxième amas circulaire de points rouges isolés de l'amas principal. Cet amas d'"outliers" fait baisser la performance de

    • QDA (qui devient le pire) en forçant la conique à prendre une forme d'hyperbole, englobant plus de points bleus dans la zone rouge
    • la régression linéaire et LDA, qui ont une frontière linéaire dont la pente a changé, pour éviter cet amas, englobant par là même plus de points bleus dans la zone estimée rouge

    Par contre, la frontière de la régression logistique passe en-dessous de l'amas : elle ne change donc pas, et reste plus performante que les autres.

8) Reprenez les modèles génératifs de la partie sur le discriminant de Fisher et appliquez-les aux données MNIST déjà utilisées lors des précédents TPs (disponibles sur la page du cours). Comparez avec les méthodes de plus proches voisins vues au TP 2. En comparant les erreurs de test, discutez des performances relatives des modèles génératifs et discriminatifs sur cette tâche de classification.

In [10]:
from scipy.io import loadmat

mnist = loadmat('mnist_digits.mat') # données sous forme de dictionnaire

data['mnist'] = {}

######### Génération des données : début

nbr_img = 6000 # nombre d'images à sélectionner au hasard
ind_train = range(0, int(1/3 * nbr_img))
ind_test = range(int(1/3 * nbr_img), nbr_img)

ind_rand = np.random.randint(len(mnist['x']), size=nbr_img) # indices des images sélectionnées


data['mnist']['train'], data['mnist']['test'] = {}, {}

X_mnist, Y_mnist = mnist['x'][ind_rand, :], mnist['y'][ind_rand] # images, labels

data['mnist']['train']['X'], data['mnist']['train']['Y'] = X_mnist[ind_train, :], Y_mnist[ind_train]
data['mnist']['test']['X'], data['mnist']['test']['Y'] = X_mnist[ind_test, :], Y_mnist[ind_test]

# En considérant l'expression de la/les matrice(s) Sigma(_k) de la LDA (QDA),
# on se rend compte que : s'il existe un chiffre k tel qu'il existe une paire de
# pixels qui sont d'une couleur c fixée dans toutes les images représentant k, 
# alors Sigma n'est pas inversible, puisqu'elle a deux colonnes liées
# Pour éviter ce cas de figure, on calcule le pseudo-inverse de 
# la matrice de covariance, dans LDA/QDA

########### Génération des données : fin


#### Généralisation de LDA, QDA à une classification à nb_classes classes : 0, ..., nb_classes-1
## : on fait nb_classes classifications binaires : entre la classe 0 <= c < nb_classes et la classe non c

def LDA_generalized(X, Y, nb_classes = 10):        
    return [LDA(X, Y, c=c) for c in range(nb_classes)]

def QDA_generalized(X, Y, nb_classes = 10):        
    return [QDA(X, Y, c=c) for c in range(nb_classes)]

list_classifiers = ['LDA', 'QDA']
classifier_functions = {'LDA': LDA_generalized, 'QDA': QDA_generalized}
list_colors = ['rgb(221,52,151)', 'rgb(215,48,39)']

#### Calcul des classifieurs LDA et QDA pour MNIST
for classifier in list_classifiers:
    classifiers[classifier]['mnist'] = classifier_functions[classifier](data['mnist']['train']['X'],\
                                                                        data['mnist']['train']['Y'])
In [11]:
error_rate['mnist'] = {}
error_rate = compute_error_rates(letters+['mnist'], list_classifiers)

trace_bar = bar_chart_error(letters+['mnist'], list_classifiers, list_colors)

fig = go.Figure(data=[trace_bar[key] for key in trace_bar], layout=layout_bar)
plotly.offline.iplot(fig)
In [12]:
############# kNN
from scipy.spatial.distance import cdist
from sklearn.metrics import confusion_matrix
from collections import Counter
from matplotlib import cm

k_max = 5
x_train, y_train = data['mnist']['train']['X'], data['mnist']['train']['Y']
x_test, y_test = data['mnist']['test']['X'], data['mnist']['test']['Y']

def confusion_kNN(k, x_train=x_train, y_train=y_train,
                     x_test = x_test, y_test=y_test):
    assert k>0
    
    # fonction auxiliaire: prédiction du label (par vote majoritaire)
    # en fonction des indices des plus proches voisins
    def predicted_label(ind_neighbors):
        # Conversion de l'array y_train[ind_neighbors] (contenant les
        # labels des voisins d'entraînement) en tuple 
        label_neighbors = tuple(map(tuple, y_train[ind_neighbors]))
        
        # élément majoritaire dans label_neighbors
        return Counter(label_neighbors).most_common(1)[0][0]
    
    # différences des distances 2 à 2 entre les images test et les images d'entraînement
    dist_matrix = cdist(x_test, x_train, 'euclidean') 
    y_predicted = []

    for i in range(len(x_test)):
        # indices des k plus petites valeurs de dist_matrix[i, :]
        # i.e. indices des k plus petites distances entre 
        #      l'image-test d'indice i et les images d'entraînement
        # i.e. indices des k images d'entraînement les plus proches
        #      de l'image-test i (par définition de dist_matrix)
        ind_k_smallest = np.argpartition(dist_matrix[i, :], k)[:k]

        y_predicted.append(predicted_label(ind_k_smallest))
    
    return confusion_matrix(y_test, np.array(y_predicted))

# détermination de l'erreur de classification
def classification_err(k_max=k_max, x_train=x_train, y_train=y_train, x_test=x_test, y_test=y_test):
    # liste des erreurs de classification
    err = []
    len_test = len(x_test)
    for k in range(1, k_max+1):
        # matrice de confusion à laquelle on enlève la diagonale 
        # (laquelle correspond aux prédictions correctes, dont la perte 0-1 vaut zéro)
        confusion_err = confusion_kNN(k, x_train=x_train, y_train=y_train, x_test=x_test, y_test=y_test)
        confusion_err -= np.diag(np.diag(confusion_err))

        # calcul de l'erreur de prédiction
        err.append(np.sum(confusion_err) / len_test)
    
    return np.array(err)


errors_train = classification_err(x_test=x_train, y_test=y_train)
errors_test = classification_err()
In [13]:
# Diagramme en bâtons

def bar_chart_error_kNN(errors_train=errors_train, errors_test=errors_test, trace_bar = trace_bar):
    learning_sets = ['mnist train', 'mnist test']
    k_max = len(errors_train)
    
    
    # Couleur des bâtons
    cm = plt.get_cmap('Blues')
    colors = [np.array(cm(1.*i/(k_max+1))) for i in range(2, k_max+3)]
    rgb_colors = ['rgb{}'.format(tuple(255*c[:-1])) for c in colors]
    
    
    for k, color, err in zip(range(k_max+1), rgb_colors, zip(errors_train, errors_test)):
        trace_bar['{}NN'.format(k+1)] = go.Bar(
            x = learning_sets,
            y = list(err),
            name = '{}NN'.format(k+1),
            marker=dict(
                color=color,
            ),
            opacity=0.75
        )
    
    return trace_bar

trace_bar = bar_chart_error_kNN()

fig = go.Figure(data=[trace_bar[key] for key in trace_bar], layout=layout_bar)
plotly.offline.iplot(fig)

La méthode des $k$ plus proches voisins, qui est un modèle discriminatif puisqu'aucune supposition n'est faite sur la distribution des $x_i$, est plus performante sur cette tâche de classification.

En effet, les modèles génératifs QDA et LDA essayent d'estimer $P(X, Y)$ en faisant des suppostions les distributions qui générent (d'où le nom) les $x_i$, pour chaque classe (en l'occurrence, la supposition faite est qu'il s'agit de Gaussiennes).

En ne faisant pas de supposition sur la distribution des $x_i$, la méthode des $k$ plus proches voisins ne s'attache qu'au fait que plus des images sont proches (au sens de la distance euclidienne) plus elles sont susceptibles de représenter le même chiffre (même classe).

On notera que les modèles génératifs ne sont pas à leur avantage dans cette tâche de classification de chiffres, puisqu'il n'y a aucune raison que les images d'une même classe soient générées par une Gaussienne : par le théorème central-limite, la génération par des Gaussiennes est une hypothèse raisonnable quand les données résultent d'une multitude de processus aléatoires suivant la même loi (comme dans la nature par exemple), ce qui n'est pas le cas ici.