Neural Network, Implementazione del Modello e Classificazione

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ù

Introduzione

Nei post precedenti, abbiamo visto come calcolare il gradiente della funzione di costo rispetto ai parametri dei pesi e dei bias, nell’output layer e nell’hidden layer. Avendo derivato le equazioni usate nella fase di backpropagation, possiamo implementare il modello di rete neurale con un singolo hidden layer, che avevamo parzialmente definito nella sezione relativa alla forward-propagation. Iniziamo con una prima implementazione che, per semplicità, utilizza la funzione di attivazione sigmoide, assieme alla funzione di costo MSE. Abbiamo già detto che questa combinazione non genera una superficie di costo convessa, come invece accade nel modello di Regressione Logistica. Dobbiamo però considerare che, a differenza della Regressione Logistica, le reti neurali non sono modelli convessi, in quanto hanno layer multipli, e composizioni di funzioni di attivazione non lineari. Anche se usassimo la funzione di costo Cross Entropy al posto di MSE, non riusciremo comunque ad avere un punto di minima globale. Quindi, per reti piccole e poco profonde, o per dataset semplici, la combinazione MSE più Sigmoide può bastare. Non è l’architettura più efficiente, ma è stabile, e facile da implementare. Successivamente, creeremo una seconda implementazione, “più corretta” dal punto di vista teorico, ma anche più complessa nel suo funzionamento.

La Classe AbstractMLPClassifier

Definiamo la classe AbstractMLPClassifier, che contiene il codice comune alle due implementazioni. Il costruttore inizializza la classe MiniBatchGenerator, le matrici dei pesi, e i vettori bias per l’output e l’hidden layer, rispettivamente di dimensione num_classes $\times$ num_hidden, e num_hidden $\times$ num_features. In particolare, l’istanza di MiniBatchGenerator viene usata dal metodo fit e compute_metrics per implementare la Discesa del Gradiente Mini-Batch, utile per sfuggire ai minimi locali della funzione di costo, non-convessa:

from typing import Tuple
from abc import ABC, abstractmethod
import numpy as np
from modules.minibatch import MiniBatchGenerator

class AbstractMLPClassifier(ABC):
    """Base, abstract class for a Multi Layer Perceptron (MLP) with a single Hidden Layer."""

    def __init__(self, num_features, num_hidden, num_classes, num_epochs,
                 minibatch_size=100, learning_rate=0.1, random_state=123):
        """
        Initialize the weight matrices and bias vectors for the hidden and output layers.

        Parameters
        ----------
        num_features: int
            the number of dataset features.
        num_hidden : int
            the number of nodes for the hidden layer.
        num_classes : int
            the number of nodes for the output layer.
        num_epochs : int
            the number of iterations needed to compute the model parameters.
        minibatch_size : int
            the size of the batches for Mini-Batch SGD.
        learning_rate : float
            the value used to scale the steps needed to reach the model parameters
            that minimize the loss function.
        random_state : int
            a seed to initialize the random generator.
        """
        self.num_classes = num_classes
        self.num_epochs = num_epochs
        self.learning_rate = learning_rate
        self.rng = np.random.default_rng(random_state)
        self.minibatch_gen = MiniBatchGenerator(minibatch_size, random_state)

        # initialize hidden layer
        self.weight_h = self.rng.normal(loc=0.0, scale=0.1, size=(num_hidden, num_features))
        self.bias_h = np.zeros(num_hidden).reshape(1, -1)

        # initialize output layer
        self.weight_o = self.rng.normal(loc=0.0, scale=0.1, size=(num_classes, num_hidden))
        self.bias_o = np.zeros(num_classes).reshape(1, -1)

Il Metodo Predict

Successivamente alla fase di training, il metodo predict, prende in input una matrice di osservazioni, e ritorna un array contenente la predizione della classe di appartenenza per ognuna di esse:

    def predict(self, X: np.ndarray) -> np.ndarray:
        """
        Classify the input examples.

        Parameters
        ----------
        X : ndarray with shape (n_examples, n_features)
            the examples to classify.

        Returns
        -------
        ndarray with shape (n_examples, )
            an array containing the target class label for each example.
        """
        # convert the one-hot encoded labels to human-readable values
        _, out = self._forward(X)       # shape: (n_examples, n_classes)
        y_hat = np.argmax(out, axis=1)  # shape: (n_examples, )
        return y_hat

Il Metodo Fit

Il metodo fit esegue il training del modello, tramite la Discesa del Gradiente Mini-Batch. Un primo ciclo itera sul range di epoch passate al costruttore, e per ogni epoch, un secondo ciclo itera sui mini-batch casuali di osserazioni, prelevate dal dataset di input. All’interno di questo, il mini-batch corrente viene usato dalla fase di Forward Propagation per calcolare i valori di uscita dell’hidden e dell’output layer, corrispondenti alle probabilità di appartenenza alla classe target di ogni osservazione del batch. Tali valori vengono poi passati alla fase di Backpropagation, implementata dal metodo _backward, che ritorna i gradienti della funzione di costo, rispetto ai parametri dell’output e dell’hindden layer. Tali valori, scalati dal campo learning_rate, vengono usati per aggiornare i parametri del modello. Alla fine di ogni epoch, il metodo _compute_metrics calcola le metriche di costo e di accuracy ritornate dal modello per l’iterazione e per il valore dei parametri correnti, e stampa a video i risultati, permettendoci di capire se la fase di addestramento sta portando buoni frutti (il valore di loss deve scendere, e quello di accuracy deve salire):

    def fit(self, X, y) -> Tuple[list[float], list[float]]:
        """
        Fit the data, computing the optimal values for the model parameters.

        Parameters
        ----------
        X : ndarray with shape (n_examples, n_features)
            the array of examples used to train the network
        y : ndarray with shape (n_examples, )
            the array of labels indicating the actual classes of the events.

        Returns
        -------
        epoch_loss : ndarray with shape (num_epochs, )
            an array containing the MSE Loss value for each epoch, for the training dataset.
        epoch_acc : ndarray with shape (num_epochs, )
            an array containing the Accuracy value for each epoch, for the training dataset.
        """
        epoch_loss = []
        epoch_acc = []
        num_labels = len(np.unique(y))

        # iterate over epochs...
        for epoch_idx in range(self.num_epochs):

            # iterate over mini-batches for the current epoch...
            minibatch = self.minibatch_gen.get(X, y)
            for X_mini, y_mini in minibatch:

                # get the class-membership probabilities returned from the hidden layer
                # and the output layer
                A_h, A_o = self._forward(X_mini)

                # compute the Loss gradients ∂L/∂w^(o), ∂L/∂b^(o), ∂L/∂w^(in) and ∂L/∂b^(in)
                dL__dw_o, dL__db_o, dL__dw_h, dL__db_h = self._backward(X_mini, y_mini, A_h, A_o)

                # update the parameters by adding the negative gradient, scaled by the learning rate:
                self.weight_h -= self.learning_rate * dL__dw_h  # w^(h) = w^(h) - η*∂L/∂w^(h)
                self.bias_h   -= self.learning_rate * dL__db_h  # b^(h) = b^(h) - η*∂L/∂b^(h)
                self.weight_o -= self.learning_rate * dL__dw_o  # w^(o) = w^(o) - η*∂L/∂w^(o)
                self.bias_o   -= self.learning_rate * dL__db_o  # b^(o) = b^(o) - η*∂L/∂b^(o)

            # compute the loss and accuracy metrics for each epoch
            loss, acc = self._compute_metrics(X, y, num_labels)
            acc = acc * 100
            epoch_acc.append(acc)
            epoch_loss.append(loss)
            print(f"Epoch: {epoch_idx + 1:03d}/{self.num_epochs:03d} | Loss: {loss:.4f} "
                  f"| Acc:  {acc:.2f}% ")

        return epoch_loss, epoch_acc

Il Metodo Compute Metrics

Il metodo _compute_metrics, chiamato dal metodo fit, calcola le metriche di costo (loss) e di accuracy per il dataset di input, per il valore dei parametri correnti:

    def _compute_metrics(self, X, y, num_labels) -> Tuple[float, float]:
        """
        Compute the Loss and Accuracy metrics for the given dataset.

        Parameters
        ----------
        X : ndarray with shape (n_examples, n_features)
            training dataset.
        y : ndarray with shape (n_examples, )
            target values for the training dataset.
        num_labels : int
            the number of output labels.

        Returns
        -------
        loss : float
            the Loss value.
        acc :  float
            the Accuracy value.
        """
        loss, correct_pred, num_examples = 0.0, 0, 0

        # for each mini-batch of examples and related labels...
        minibatch = self.minibatch_gen.get(X, y)
        for i, (X_mini, y_mini) in enumerate(minibatch):

            # to support multiclass classification using One-vs-Rest strategy,
            # we need to One-Hot encode our mini-batch label array
            Y_mini_hot = self._one_hot_encode(y_mini, num_labels=num_labels)

            # compute the forward propagation to get the class-membership probabilities
            # returned from the output layer
            _, Y_hat_mini_hot = self._forward(X_mini)

            # compute the average of the Loss function for the current batch
            loss_batch: float = self._loss(Y_mini_hot, Y_hat_mini_hot)

            # compute the correct predictions for the current batch.
            # Use np.argmax() to select the index position of the largest value,
            # which yields the predicted class label
            y_hat = np.argmax(Y_hat_mini_hot, axis=1)
            correct_pred_batch = (y_hat == y_mini).sum()

            # increment the global counters
            loss += loss_batch
            correct_pred += correct_pred_batch
            num_examples += y_mini.shape[0]

        loss = loss / (i + 1)
        acc: float = correct_pred / num_examples
        return loss, acc

I Metodi Astratti

I metodi astratti _forward e _backward devono essere implementati dalle sottoclassi di AbstractMLPClassifier per definire rispettivamente la fase di forward e backpropagation, mentre il metodo _loss deve implementare la funzione di costo:

    @abstractmethod
    def _forward(self, X) -> Tuple[np.ndarray, np.ndarray]:
        """
        Take in one or more training examples, and for each one of them,
        get the class-membership probabilities returned from the hidden and output layers.

        Parameters
        ----------
        X : ndarray with shape (n_examples, n_features)
            the examples to classify.

        Returns
        -------
        A_h : ndarray with shape (n_examples, n_hidden)
            hidden layer's activation matrix.
            For each example, it contains the class probability returned by every node of the layer.
        A_o : ndarray with shape (n_examples, n_classes)
            output layer's activation matrix.
            For each example, it contains the class probability returned by every node of the layer.
        """
        pass

    @abstractmethod
    def _backward(self, X, y, A_h, A_o) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
        """
        Implement the backpropagation algorithm, which calculates the gradients of the loss
        w.r.t. to the weight and bias parameters.
        The gradients are then used to update these parameters via Gradient Descent algorithm.

        Parameters
        ----------
        X : ndarray with shape (n_examples, n_features)
            the examples to classify.
        y : ndarray with shape (n_examples, )
            the array of labels indicating the actual classes of the events.
        A_h : ndarray with shape (n_examples, n_hidden)
            hidden layer's activation matrix.
        A_o : ndarray with shape (n_examples, n_classes)
            output layer's activation matrix.

        Returns
        -------
        dL__dw_o : ndarray with shape (n_classes, n_hidden)
            gradient ∂L/∂w^(o).
        dL__db_o : ndarray with shape (n_classes, )
            gradient ∂L/∂b^(o).
        dL__dw_h : ndarray with shape (n_hidden, n_features)
            gradient ∂L/∂w^(h).
        dL__db_h : ndarray with shape (n_hidden, )
            gradient ∂L/∂b^(h).
        """
        pass

    @abstractmethod
    def _loss(self, y, y_hat) -> float:
        """
        Calculate the Loss for this model.

        Parameters
        ----------
        y : ndarray with shape (n_examples, n_classes)
            the One-Hot encoded class labels.
        y_hat : ndarray with shape (n_examples, n_classes)
            the One-Hot encoded class labels predictions.

        Returns
        -------
        float
            the loss value.
        """
        pass

I Metodi di Utility

La classe AbstractMLPClassifier definisce alcuni metodi che verranno usati dalle relative sottoclassi. Il metodo _sigmoid utilizza la funzione sigmoide per convertire il valore logit, ritornato dalla somma pesata effettuata da ogni nodo della rete, in un valore di probabilità, definito nel range $[0, 1]$. Il metodo _sigmoid_derivative calcola la derivata prima della funzione sigmoide, mentre _one_hot_encode serve per codificare gli array di label $y$ e $\hat{y}$ nel formato One-Hot, per supportare la classificazione multiclasse utilizzando la strategia One-vs-Rest:

    def _sigmoid(self, z) -> float:
        """Convert the input logit into a probability value."""
        return 1.0 / (1.0 + np.exp(-z))

    def _sigmoid_derivative(self, out):
        """Compute the sigmoid derivative w.r.t. its input logit."""
        return out * (1.0 - out)

    def _one_hot_encode(self, y, num_labels):
        """Encode the input array of labels with One Hot Encode strategy."""
        # create a matrix with shape (len(y), num_labels) and fill it with 0
        Y_hot = np.zeros((y.shape[0], num_labels))

        # transform each integer label value into a binary vector
        for row_idx, col_idx in enumerate(y):
            Y_hot[row_idx, col_idx] = 1
        return Y_hot

La Classe MLPClassifier

La classe MLPClassifier implementa i metodi astratti _forward, _backward, e _loss:

class MLPClassifier(AbstractMLPClassifier):
    """A Multi Layer Perceptron (MLP) with a single Hidden Layer. For simplicity:
       1) We use the MSE Loss Function already used in Adaline.
       2) We use the Sigmoid Activation Function for both the hidden and output layers."""

    def __init__(self, num_features, num_hidden, num_classes, num_epochs,
                 minibatch_size=100, learning_rate=0.1, random_state=123):
        super().__init__(num_features, num_hidden, num_classes, num_epochs,
                         minibatch_size, learning_rate, random_state)

Il Metodo Forward

Il metodo _forward implementa la fase di Forward Propagation, e usa il valore corrente dei parametri del modello (i pesi e il bias associati ad ogni nodo) e le formule matematiche derivate in tale pagina per calcolare le previsioni di output dell’hidden layer, e dell’output layer. In particolare, questa implementazione usa la funzione sigmoide per convertire il valore logit, ritornato dalla somma pesata effettuata da ogni nodo della rete, sia nell’hidden layer, che nell’output layer, in un valore di probabilità, definito nel range $[0, 1]$:

    def _forward(self, X):
        # Hidden Layer:
        #   Z^(h) = X @ (W^(h))^T + b^(h)
        #   A^(h) = σ(Z^(h))
        Z_h = (X @ self.weight_h.T) + self.bias_h    # shape: (n_examples, n_hidden)
        A_h = self._sigmoid(Z_h)                     # shape: (n_examples, n_hidden)

        # Output Layer:
        #   Z^(o) = A^(h) @ (W^(o))^T + b^(o)
        #   A^(o) = σ(Z^(o))
        Z_o = (A_h @ self.weight_o.T) + self.bias_o  # shape: (n_examples, n_classes)
        A_o = self._sigmoid(Z_o)                     # shape: (n_examples, n_classes)

        return A_h, A_o

Il Metodo Backward

Il metodo _backward implementa la fase di Backpropagation per l’output layer e per l’hidden layer, usando le formule vettoriali derivate in tali pagine. Nell’output layer, il gradiente della funzione di costo MSE, rispetto all’output dei nodi $\mathbf{A}^o$, ovvero $\pdv{L}{\mathbf{A}^o}$, è calcolato dal metodo privato __mse_derivative. Il gradiente della funzione sigmoide rispetto ai suoi input, $\mathbf{Z}^o$, ovvero $\pdv{\mathbf{A}^o}{\mathbf{Z}^o}$, è calcolato dal metodo privato _sigmoid_derivative. Questo accade anche nell’hidden layer, in relazione a $\pdv{\mathbf{A}^h}{\mathbf{Z}^h}$:

    def _backward(self, X, y, A_h, A_o):
        # to support multiclass classification using One-vs-Rest strategy,
        # we need to One-Hot encode our mini-batch label array
        Y_hot = self._one_hot_encode(y, self.num_classes)  # shape: (n_examples, n_classes)

        # =================================================================================
        # Output Layer:
        #   D^(o)     = ∂L/∂A^(o) * ∂A^(o)/∂Z^(o)
        #   ∂L/∂W^(o) = (D^(o))^T @ A^(h)
        #   ∂L/∂b^(o) = (D^(o)).T @ 1_vector
        # =================================================================================

        # ∂L/∂A^(o)
        dL__dA_o = self.__mse_derivative(Y_hot, A_o)  # shape: (n_examples, n_classes)

        # ∂A^(o)/∂Z^(o)
        dA_o__dZ_o = self._sigmoid_derivative(A_o)    # shape: (n_examples, n_classes)

        # ∂L/∂A^(o) * ∂A^(o)/∂Z^(o)
        D_o = dL__dA_o * dA_o__dZ_o                   # shape: (n_examples, n_classes)

        # ∂L/∂W^(o)
        dL__dW_o = D_o.T @ A_h                        # shape: (n_classes, n_hidden)

        # ∂L/∂b^(o) = ∂L/∂A^(o) * ∂A^(o)/∂Z^(o) @ ∂Z^(o)/∂b^(h)
        #           = ∂L/∂A^(o) * ∂A^(o)/∂Z^(o) @ 1_vector
        #           = (D^(o)).T @ 1_vector
        #
        # To get ∂L/∂b^(o) with a shape of (n_classes, ), we have to write:
        #
        #   dL__db_o = D_o.T @ np.ones((n_examples, 1)).reshape(-1)
        #
        # that is equivalent to the sum of the columns of D_o. See also:
        # https://datascience.stackexchange.com/a/42308
        dL__db_o = np.sum(D_o, axis=0)                # shape: (n_classes, )

        # =================================================================================
        # Hidden Layer:
        #   D^(o)     = ∂L/∂A^(o) * ∂A^(o)/∂Z^(o)
        #   D^(h)     = D^(o) @ W^(o) * ∂A^(h)/∂Z^(h)
        #   ∂L/∂W^(h) = (D^(h)).T * X
        #   ∂L/∂b^(h) = (D^(h)).T @ 1_vector
        # =================================================================================

        # ∂A^(h)/∂Z^(h)
        dA_h__dZ_h = self._sigmoid_derivative(A_h)  # shape: (n_examples, n_hidden)

        # D^(o) @ W^(o) * ∂A^(h)/∂Z^(h)
        D_h = D_o @ self.weight_o * dA_h__dZ_h      # shape: (n_examples, num_hidden)

        # ∂L/∂W^(h)
        dL__dW_h = D_h.T @ X                        # shape: (n_hidden, n_features)

        # ∂L/∂b^(h)
        dL__db_h = np.sum(D_h, axis=0)              # shape: (n_hidden, )

        return dL__dW_o, dL__db_o, dL__dW_h, dL__db_h

I Metodi di Utility

Il metodo _loss, definito dalla superclasse, rimanda alla funzione di costo, __mse, che determina il valore di perdita, calcolando la media di tutte le differenze al quadrato tra le previsioni e i valori target, per tutti i nodi di output, e per tutte le osservazioni del batch di input. Similmente, il metodo __mse_derivative calcola la derivata di MSE, considerando tutti i nodi di output, e tutte le osservazioni del batch di input, e ritorna una matrice di dimensione n_examples $\times$ n_classes:

    def _loss(self, y_hot, y_hat_hot) -> float:
        return self.__mse(y_hot, y_hat_hot)

    def __mse(self, y_hot, y_hat_hot) -> float:
        """Compute the MSE for the input batch, averaging all squared differences
           between predictions and targets, across all output units, and all samples."""

        # MSE = [1 / (m * n_classes)] ∑(i=1,m) { ∑(j=1,n_classes) { (y_hat^(i)_j - y^(i)_j)^2 }}
        #
        # Considering a binary classification problem, where n_classes = 2:
        #   dsq^(i)_j = (y_hat^(i)_j - y^(i)_j)^2
        #   MSE = [1 / (m * n_classes)] [dsq^(1)_0 + dsq^(1)_1 + dsq^(2)_0 + dsq^(2)_1 + ... +
        #                                dsq^(m)_0 + dsq^(m)_1]
        return np.mean((y_hot - y_hat_hot) ** 2)

    def __mse_derivative(self, y_hot, y_hat_hot) -> np.ndarray:
        """Compute the MSE derivative for the input batch, across all the output units."""
        return 2.0 * (y_hat_hot - y_hot) / y_hat_hot.shape[0]  # shape: (n_examples, n_classes)

Esempi di Classificazione

Nelle sezioni precedenti abbiamo visto che un “Multi Layer Perceptron” (MLP), utilizzando un singolo hidden layer, e funzioni di attivazione non lineari, può apprendere interazioni complesse tra le feature del dataset, in modo automatico, senza la necessità di operazioni manuali di ingegnerizzazione delle feature. L’architettura MLP, con un numero sufficiente di nodi e layer, può approssimare qualsiasi funzione continua, ed è quindi in grado di gestire dataset non linearmente separabili. Di contro, un modello “Single Layer Perceptron” (SLP), come la Regressione Logistica, senza operazioni di pre-elaborazione dei dati, deve limitarsi a modellare superfici decisionali lineari. L’architettura MLP può essere estesa a reti “deep”, con più hidden layer, che garantiscono una maggiore astrazione e complessità, e sono in grado di gestire problemi di riconoscimento di immagini, e di voci parlate. Vediamo ora altri esempi di classificazione binaria su dataset non linearmente separabili, che utilizzano la classe MLPClassifier precedentemente sviluppata.

Dataset “Rettangolo”

Mostriamo a video la regione decisionale del modello, utilizzando il dataset “Rettangolo”:

from sklearn.preprocessing import StandardScaler

def get_rectangle_data(n_samples=500, x_min=-0.5, x_max=0.5, y_min=-0.5, y_max=0.5,
                       standardize=True):
    # points in [-1, 1] x [-1, 1]
    X = np.random.rand(n_samples, 2) * 2 - 1

    # labels: 1 if inside rectangle, 0 otherwise
    y = ((X[:, 0] >= x_min) & (X[:, 0] <= x_max) &
         (X[:, 1] >= y_min) & (X[:, 1] <= y_max)).astype(int)

    if standardize:
        X = StandardScaler().fit_transform(X)
    return X, y

# get data from a non-linear dataset
np.random.seed(42)
X, y = get_rectangle_data(n_samples=500, x_min=-0.5, x_max=0.5, y_min=-0.5, y_max=0.5)

# instance and train the model
mlpc = MLPClassifier(num_features=2, num_hidden=5, num_classes=2,
                     num_epochs=500, minibatch_size=10, learning_rate=5)
mlpc.fit(X, y)

# plot the decision region
plot_decision_regions_2D(X, y, [mlpc], ["Multi-Layer Perceptron"],
                         ncols=1, nrows=1, width=8, row_height=8, aspect_equal=True)
Grafico della Regione Decisionale del Modello, su dataset &#39;Rettangolo&#39;
Figura 1: Regione Decisionale del Modello, su dataset ‘Rettangolo’

Dataset “Cerchio”

Vediamo cosa accade con il dataset “Cerchio”:

def get_circle_data(n_samples=500, radius = 0.5, standardize=True):
    # datapoints uniformly distributed in [-1, 1] x [-1, 1]
    X = np.random.rand(n_samples, 2) * 2 - 1

    # create labels based on whether points are inside a circle centered at (0,0) with radius 0.5
    # Remember that the general equation of a circle with center at (x1, y1)
    # and radius r is (x - x1)² + (y - y1)² = r².
    # If the center is at the origin (0, 0), the equation simplifies to x² + y² = r²
    y = (X[:, 0] ** 2 + X[:, 1] ** 2 < radius ** 2).astype(int)

    if standardize:
        X = StandardScaler().fit_transform(X)
    return X, y

# get data from a non-linear dataset
np.random.seed(42)
X, y = get_circle_data()

# instance and train the model
mlpc = MLPClassifier(num_features=2, num_hidden=8, num_classes=2,
                     num_epochs=500, minibatch_size=10, learning_rate=0.8)
mlpc.fit(X, y)

# plot the decision region
plot_decision_regions_2D(X, y, [mlpc], ["Multi-Layer Perceptron"],
                         ncols=1, nrows=1, width=8, row_height=8, aspect_equal=True)
Grafico della Regione Decisionale del Modello, su dataset &#39;Cerchio&#39;
Figura 2: Regione Decisionale del Modello, su dataset ‘Cerchio’

Dataset “XOR”

Vediamo cosa accade con il dataset “XOR”, una base dati sintetica basata sull’operazione logica OR esclusivo ($\text{XOR}$), dove collochiamo le osservazioni in ciascuno dei quattro quadranti del piano cartesiano, e assegnamo le label di classe in base alla logica $\text{XOR}$ applicata ai segni delle feature di ogni coppia $\left(x_1, x_2\right)$. Notiamo che $\text{XOR}\left(x_1, x_2\right)$ restituisce:

  • $0$ se $\text{sign}\left(x_1\right) = \text{sign}\left(x_2\right)$
  • $1$ se $\text{sign}\left(x_1\right) \ne \text{sign}\left(x_2\right)$

Si forma così un pattern a forma di $\text{X}$, dove le classi si alternano in un layout simile a una scacchiera:

def get_xor_data(n_samples_per_quadrant=250, spread=0.2, standardize=True):
    # get points sampled from a standard normal distribution (mean 0, std 1), centered around 0.
    # Then use "spread" to scale the width of the Gaussian blob,
    # and [±1, ±1] to shift the center of the cloud of points to the target quadrant:
    # https://en.wikipedia.org/wiki/Quadrant_(plane_geometry)
    X = np.vstack([
        np.random.randn(n_samples_per_quadrant, 2) * spread + [1, 1],   # 1st quad. (upper-right)
        np.random.randn(n_samples_per_quadrant, 2) * spread + [-1, 1],  # 2nd quad. (upper-left)
        np.random.randn(n_samples_per_quadrant, 2) * spread + [-1, -1], # 3rd quad. (lower-left)
        np.random.randn(n_samples_per_quadrant, 2) * spread + [1, -1],  # 4th quad. (lower-right)
    ])

    # create the list of labels for the four quadrants,
    # by mapping signs of x1 and x2 features to binary values:
    #
    # Quad. | x1 sign | x2 sign | Interpreted as | XOR(x1, x2) | Class
    # ------+---------+---------+----------------+-------------+-------
    # 1     | +       | +       | (1, 1)         | 0           | 0
    # 2     | −       | +       | (0, 1)         | 1           | 1
    # 3     | −       | −       | (0, 0)         | 0           | 0
    # 4     | +       | −       | (1, 0)         | 1           | 1
    #
    y = np.array([0] * n_samples_per_quadrant + [1] * n_samples_per_quadrant +
                 [0] * n_samples_per_quadrant + [1] * n_samples_per_quadrant)

    if standardize:
        X = StandardScaler().fit_transform(X)
    return X, y

# get data from a non-linear dataset
np.random.seed(41)
X, y = get_xor_data(n_samples_per_quadrant=400, spread=0.3)

# instance and train the model
mlpc = MLPClassifier(num_features=2, num_hidden=6, num_classes=2,
                     num_epochs=100, minibatch_size=10, learning_rate=4)
mlpc.fit(X, y)

# plot the decision region
plot_decision_regions_2D(X, y, [mlpc], ["Multi-Layer Perceptron"],
                         ncols=1, nrows=1, width=8, row_height=8, aspect_equal=True)
Grafico della Regione Decisionale del Modello, su dataset &#39;XOR&#39;
Figura 3: Regione Decisionale del Modello, su dataset ‘XOR’