La Regressione Logistica, Implementazione del Modello

Parte (3/3)

Potrebbe interessarti:

Il progetto Python, che comprende gli script mostrati in questo post, e quelli relativi alla creazione dei grafici Matplotlib e Plotly, è disponibile assieme al corso Introduzione al Machine Learning.

Dimmi di Più

Implementazione del Modello

La Classe LogisticRegressionGD

Per implementare il modello di Regressione Logistica, possiamo prendere l’implementazione di Adaline e cambiare, oltre al nome della classe, l’implementazione dei seguenti metodi:

  1. __loss deve implementare la nuova funzione di costo logistic loss
  2. __activation deve implementare il calcolo della funzione Sigmoide
  3. __gradient deve cambiare il fattore di scala delle derivate parziali
  4. predict_regression viene cambiato in predict_proba
class LogisticRegressionGD:
    """Gradient descent-based Logistic Regression Classifier."""

    def __init__(self, eta=0.01, epochs=50, random_state=1):
        self.losses = None
        self.bias = None
        self.weights = None
        self.eta = eta
        self.epochs = epochs
        self.random_state = random_state

    def fit(self, X, y):
        """Fit training data. That is, compute the best values for weights and bias parameters
        that minimize the loss function associated with this model."""
        rgen = np.random.default_rng(seed=self.random_state)

        # initialize weights with a normal distribution, and the bias with 0
        self.weights = rgen.normal(loc=0.0, scale=0.01, size=X.shape[1])
        self.bias = np.float64(0.)
        self.losses = []

        # for each training epoch...
        for i in range(self.epochs):
            # compute the gradient vectors:
            #
            #   ∂L/∂w = 1/m * sum(i=1 to m) { x^(i) * (out^(i) - y^(i)) }
            #         = 1/m * (X.T @ (out - y))
            #
            #   ∂L/∂b = 1/m * sum(i=1 to m) { out^(i) - y^(i) }
            #         = 1/m * (1^T)(out - y)
            z = self.__net_input(X)                    # shape: (n_examples, )
            out = self.__activation(z)                 # shape: (n_examples, )
            error = out - y                            # shape: (n_examples, )
            grad_w = 1.0 * (X.T @ error) / X.shape[0]  # shape: (n_features, )
            grad_b = 1.0 * np.mean(error)              # float

            # calculate the steps in the direction of the w-axis and b-axis axis
            # used to reach the flat point on the loss curve for this iteration
            step_w = self.eta * grad_w                 # shape: (n_features,)
            step_b = self.eta * grad_b                 # float

            # by convention, the gradient at a certain point is an arrow that points
            # directly uphill from that point. Because we need to go into the opposite direction,
            # we have to subtract "step_w" from the current value of "w", and do the same for "b"
            self.weights -= step_w                     # shape: (n_features,)
            self.bias -= step_b                        # bias: float

            # compute the loss for the current iteration
            loss = self.__loss(X, y, out)
            self.losses.append(loss)

        return self

    def __loss(self, X, y, out):
        """Compute the Logistic Loss value for the input examples."""
        # Logistic Loss = 1/n * ∑(i=1 to n) { -y^(i)*log[out^(i)] - (1-y^(i))*log[1 - out^(i)] }
        #               = 1/n * -y*log[out] - (1-y)*log(1 - out)
        loss = (-y @ np.log(out) - (1 - y) @ np.log(1 - out)) / X.shape[0]
        return loss

    def predict_proba(self, X):
        """Predict the probability value for the input data."""
        z = self.__net_input(X)     # shape: (n_examples, )
        out = self.__activation(z)  # shape: (n_examples, )
        return out

    def predict(self, X):
        """Predict the class label for the input data."""
        out = self.predict_proba(X)    # shape: (n_examples, )
        y_hat = self.__threshold(out)  # shape: (n_examples, )
        return y_hat

    def __net_input(self, X):
        """Calculate the weighted sum, plus the bias."""
        z = X @ self.weights + self.bias
        return z

    def __activation(self, z):
        """Compute the Sigmoid Function for the given logits."""
        out = 1.0 / (1.0 + np.exp(-np.clip(z, -250, 250)))
        return out

    def __threshold(self, out):
        """Transforms a probability value into a classification value,
        taking the continuous value returned by the sigmoid activation function
        and transforming it into a categorical value, used as a classification prediction."""
        y_hat = np.where(out >= 0.5, 1, 0)
        return y_hat

Esempi di Classificazione

Il Dataset Iris

Come abbiamo già fatto con Adaline, testiamo il classificatore binario Logistic Regression con il dataset Iris, riutilizzando i metodi get_iris_data, plot_dataset_2D e plot_decision_regions:

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors

X, y = get_iris_data()
plot_dataset_2D(X, y, x_label="Sepal Length", y_label="Petal Width")
Scatterplot del dataset Iris, considerando le due feature 'Sepal length' e 'Petal width'
Figura 1: Scatterplot del dataset Iris, considerando le due feature ‘Sepal length’ e ‘Petal width’

Utilizziamo l’intero dataset per eseguire il training del modello, e per mostrare a video le regioni decisionali, ed il relativo confine decisionale:

from matplotlib.colors import ListedColormap

# fit the Logistic Regression model and plot the decision regions
log_reg = LogisticRegressionGD(eta=0.5, epochs=200, random_state=2)
log_reg.fit(X, y)
plot_decision_regions(X, y, classifier=log_reg, resolution=0.005,
                      x_label="Sepal Length", y_label="Petal Width")
Plot delle Regioni Decisionali
Figura 2: Plot delle Regioni Decisionali

Un Dataset Sintetico

from sklearn.datasets import make_classification

# get data
X, y = make_classification(
    n_samples=200,
    n_features=2,
    n_redundant=0,
    n_informative=2,
    n_clusters_per_class=1,
    class_sep=2.5,
    random_state=42
)

plot_dataset_2D(X, y)
Scatterplot di un Dataset sintetico
Figura 3: Scatterplot di un Dataset sintetico
# fit the Logistic Regression model and plot the decision regions
log_reg = LogisticRegressionGD(eta=0.5, epochs=200, random_state=2)
log_reg.fit(X, y)
plot_decision_regions(X, y, classifier=log_reg, resolution=0.005)
Plot delle Regioni Decisionali
Figura 4: Plot delle Regioni Decisionali

Il Confine Decisionale

Nel contesto della classificazione binaria, il confine decisionale della Regressione Logistica è l’insieme di tutti i punti dello spazio delle feature per i quali il modello assegna la stessa probabilità alle due classi. Formalmente, esso è definito dall’equazione:

$$ \begin{align} \sigma\left(z\right) & = p = 0.5 \label{dec_bound_1}\tag{1}\\[6pt] \end{align} $$

dove la funzione Sigmoide è definita come:

$$ \begin{align} \sigma\left(z\right) & = \frac{1}{1 + e^{(-z)}} = \frac{1}{1 + e^{(-\textbf{w}\textbf{x} + b)}}\label{sigmoid}\tag{2}\\[6pt] \end{align} $$
Plot della funzione Sigmoide (reprise)
Figura 5: Plot della funzione Sigmoide (reprise)

Il confine decisionale rappresenta quindi l’insieme delle osservazioni di input per le quali il modello è indifferente tra le due classi, ossia quando la probabilità predetta per la classe positiva è esattamente pari a $0.5$. Poiché la funzione Sigmoide restituisce il valore $0.5$ quando il suo argomento (logit) $z$ è uguale a zero, la condizione precedente equivale a imporre:

$$ \begin{align} z & = w_1x_1 + \dots + w_nx_n + b = 0\label{logit}\tag{3}\\[6pt] \end{align} $$

L’equazione $\eqref{logit}$ è un’equazione lineare nello spazio delle feature, e rappresenta un iperpiano in uno spazio di dimensione $n + 1$, dove $n$ è il numero di feature. Questa equazione descrive una superficie piana e rettilinea: per questo motivo il confine decisionale della Regressione Logistica è sempre lineare.

Dal punto di vista visivo, la funzione Sigmoide non altera la forma del confine decisionale, poiché non viene utilizzata per calcolare direttamente la funzione di ipotesi. Il suo ruolo è esclusivamente quello di trasformare il valore del logit — ossia l’output della funzione di ipotesi, o weighted sum — in una probabilità compresa tra $0$ e $1$. In questo modo, gli errori lineari (valori reali non limitati, “unbounded”) vengono convertiti in errori probabilistici, rendendo possibile sia un’interpretazione probabilistica del modello, sia l’addestramento tramite la funzione di costo Cross-Entropy.

Poiché la Sigmoide vale $0.5$ solo quando il suo argomento è $0$, il confine decisionale della Regressione Logistica coincide con l’insieme dei punti che soddisfano l’equazione $\textbf{w}\textbf{x} + b = 0$. A livello geometrico, la Sigmoide “colora” lo spazio con probabilità, ma il confine decisionale è la linea (o iperpiano) che passa esattamente nel punto in cui il colore cambia da “classe 0 più probabile” a “classe 1 più probabile”.

Ad esempio, nel caso di un vettore di feature bidimensionale $\textbf{x} = \left({x}_1, x_2\right)$ e di un termine di bias $B$, l’equazione del confine decisionale diventa:

$$ \begin{align} z & = w_1x_1 + w_2x_2 + B = 0 \label{dec_bound_2}\tag{4}\\[6pt] \end{align} $$

Risolvendo l’equazione rispetto a $x_2$, otteniamo:

$$ \begin{align} x_2 & = -\frac{w_1x_1 + B}{w_2} \label{dec_bound_3}\tag{5}\\[6pt] & = -\frac{w_1}{w_2}x_1 -\frac{B}{w_2} \label{dec_bound_4}\tag{6}\\[6pt] \end{align} $$

L’equazione $\eqref{dec_bound_4}$ può quindi essere interpretata come l’equazione della retta del confine decisionale nella sua forma esplicita:

$$ \begin{align} y & = mx + b \label{line-eq-point-slope}\tag{7}\\[6pt] \end{align} $$

dove il coefficiente angolare $m$ e il termine intercetta $b$ sono definiti come:

$$ \begin{align} m & = -\frac{w_1}{w_2}\label{slope}\tag{8}\\[6pt] b & = -\frac{B}{w_2}\label{bias}\tag{9}\\[6pt] \end{align} $$

Avendo i valori di $m$ e $b$, possiamo utilizzare l’equazione $\eqref{line-eq-point-slope}$ per tracciare la retta del confine decisionale. Pertanto, ogni osservazione situata da un lato del perimetro apparterrà alla classe “$1$”, mentre ogni osservazione situata sul lato opposto apparterrà alla classe “$0$”. Vediamo un’esempio, nel quale il metodo plot_dataset_2D prende i parametri weights e bias del classificatore di input, e li usa per disegnare tale retta:

from sklearn import datasets

def get_iris_data():
    """This data sets consists of 3 different types of irises: 'Setosa', 'Versicolour' and 'Virginica'.
    There are 150 examples, 50 examples for each class.
    The features are: 'sepal length', 'sepal width', 'petal length' and 'petal width'."""
    iris = datasets.load_iris()
    X = iris.data[:, [0, 3]]  # just consider "sepal length" and "petal width"
    y = iris.target
    X = X[0:100]              # class 0 and class 1
    y = y[0:100]              # class 0 and class 1
    return X, y

def plot_dataset_2D(X, y, classifier: LogisticRegressionGD, plot_decision_boundary=True,
                    x_label="$x_1$", y_label="$x_2$"):
    """Draw a scatter-plot of the input Iris data."""
    markers = ("s", "o")
    colors = ("red", "blue")
    plt.figure(figsize=(8, 5))

    # plot class samples
    for idx, cl in enumerate(np.unique(y)):
        plt.scatter(x=X[y == cl, 0], y=X[y == cl, 1],
                    marker=markers[idx], label=f'Class {cl}',
                    c=colors[idx], edgecolor="black", zorder=2)

    # plot decision boundary
    if plot_decision_boundary:
        xl = np.linspace(X[:, 0].min() - 1, X[:, 0].max() + 1, num=100)
        w = classifier.weights  # get classifier's weights
        B = classifier.bias     # and bias
        m = -w[0]/w[1]          # compute the m and b parameters
        b = -B/w[1]             # of the decision boundary line
        yl = m*xl + b           # then use the line equation to compute the line's y coordinates
        plt.plot(xl, yl, c="black", linestyle="--", alpha=1.0, zorder=2, label="Decision Boundary")

    plt.xlabel(x_label)
    plt.ylabel(y_label)
    plt.legend()
    plt.xlim(X[:, 0].min() - 0.1, X[:, 0].max() + 0.1)
    plt.grid(color='gray', alpha=0.3, linestyle='--')
    plt.tight_layout()
    plt.show()

# get data
X, y = get_iris_data()

# instance and train the model
log_reg = LogisticRegressionGD(eta=1, epochs=200, random_state=2)
log_reg.fit(X, y)

# plot the dataset, and the model's decision boundary
plot_dataset_2D(X=X, y=y, classifier=log_reg,
                x_label="Sepal Length", y_label="Petal Width")
Il Perimetro Decisionale del Modello
Figura 6: Il Perimetro Decisionale del Modello