import random
import numpy as np
import matplotlib.pyplot as plt
from prml import nn
from prml.linear import Perceptron, LogisticRegression
from prml.preprocessing import OneHotEncoder
from prml.datasets import generate_toy_data, load_planar_dataset, plot_2d_decision_boundary, load_mnist_dataset
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
# Set random seed to make deterministic
np.random.seed(0)
# Ignore zero divisions and computation involving NaN values.
np.seterr(divide="ignore", invalid="ignore")
# Enable higher resolution plots
%config InlineBackend.figure_format = 'retina'
# Enable autoreload all modules before executing code
%reload_ext autoreload
%autoreload 2
The linear models discussed in previous chapters are based on linear combinations of fixed (non)linear basis functions ϕj(x) and take the form,
y(x,w)=f(M∑j=1wjϕj(x))where f(⋅) is a nonlinear activation function in the case of classification and the identity in the case of regression. Although such models have useful analytical properties, they are limited by the curse of dimentionality, and they need to adapt the basis functions to the data for large-scale problems. An alternative is to use a predefined number of basis functions but allow them to be adaptive during training. Thus, an extension of the model above is making the basis functions ϕj(x) depend on parameters and then adjust them along the coefficients {wj}, during training.
Neural networks use basis functions that follow the same form, that is, each basis function is itself a nonlinear function of a linear combination of the inputs, where the coefficients are adapative parameters. Thus, the basic neural network model is described as a series of functional transformations.
where j=1,…,M, and the superscript (1) indicates the corresponding parameters of the first layer of the network. The input layer often uses the superscript (0). The quantities aj are known as activations and each of them is transformed using a differentiable nonlinear activation function h(⋅) to give
zj=h(aj)these correspond to the outputs of the basis functions, and in the context of neural networks are called hidden units. The following figure presents the two step computation of a single unit or neuron.
where k=1,…,K. This transformation corresponds to the second layer of the network. These output activations are transformed again using an appropriate activation function h to give a set of outputs yk. The following figure depicts the entire process for a 2-layer network.
It is called a 2-layer neural network because there are two layers of adaptive weights.
The output unit activation function is determined by the nature of the data and the assumed distribution of target variables. Thus, for regression problems, the activation function can be the identity, so that yk=αk, and for classification, the output uses a sigmoid, or softmax function, so that yk=σ(αk).
We may combine these stages to obtain the overall network function (using a sigmoid output unit), as follows,
yk(x,w)=σ(M∑j=1w(2)kjh(D∑i=1w(1)jixi+w(1)j0)+w(2)k0)where biases on each layer can be absorbed into the set of weight parameters by defining an additional input variable x0=1.
Important Notes:
The key difference compared to the perceptron is that the neural network uses continuous sigmoidal non-linear hidden units, whereas the perceptron uses step-function non-linearities. This means that the neural network function is differentiable which plays a central role in network training.
If the activation functions of all hidden units are taken to be linear, then any such network can be replaced by a network without the hidden units. This follows from the fact that the composition of successive linear transformations is itself a linear transformation.
In order to study these claims, lets consider a dataset, in which, the classes are separated by a non-linear decision boundary. Therefore, a simple linear model, without performing any sophisticated feature engineering, cannot capture.
x, y = load_planar_dataset()
plt.scatter(x[:, 0], x[:, 1])
plt.title("Planar dataset")
plt.xlabel("$X_1$")
plt.ylabel("$X_2$")
plt.show()
print(f"The shape of X is: {str(x.shape)}")
print(f"The shape of Y is: {str(y.shape)}")
print(f"We have {x.shape[1]} training examples!")
The shape of X is: (400, 2) The shape of Y is: (400, 1) We have 2 training examples!
Training either a simple perceptron or a logistic regression classifier, similar to the ones presented in Chapter 4, only manages to learn an insufficient linear decision boundary that cannot capture the underlying data distribution.
classifier = Perceptron()
classifier.fit(x, np.squeeze(y))
plot_2d_decision_boundary(lambda x: classifier.predict(x) > 0.5, x, y)
classifier = LogisticRegression()
classifier.fit(x, np.squeeze(y))
plot_2d_decision_boundary(lambda x: classifier.predict(x) > 0.5, x, y)
For efficiency reasons, in an actual implementation, its more convinient to represent the activations in some ℓ-layer as an M-dimensional vector,
a(ℓ)=[a(ℓ)1a(ℓ)2⋮a(ℓ)M]=[w(ℓ)T1z(ℓ−1)+w(ℓ)10w(ℓ)T2z(ℓ−1)+w(ℓ)20⋮w(ℓ)TMz(ℓ−1)+w(ℓ)M0]=[w(ℓ)T1w(ℓ)T2⋮w(ℓ)TM]z(ℓ−1)+w(ℓ)0=W(ℓ)z(ℓ−1)+w(ℓ)0In terms of matrix dimensions, we have (M×D)(D×1)+(M×1). Then, the activation function h(⋅) is applied on a(ℓ) to obtain,
z(ℓ)=h(ℓ)(a(ℓ))This can also be generalized across N training examples xi, by stacking them in columns, creating a matrix X of dimensions (D×N). Then, we obtain,
W(ℓ)Z(ℓ−1)+w(ℓ)0and
Z(ℓ)=h(ℓ)(A(ℓ))where Z(0)=X.
In the general case, we can use as an activation function any non-linear h(z). Some popular choices are the following non-linear functions.
The sigmoid function or σ(z)=11+e−z. Mostly used in the output layer for representing class probability. In the case of multi-class problems, the softmax activation function is used instead of the sigmoid.
The hyperbolic tangent or tanh(z)=ez−e−zez+e−z. Note that the hyperbolic tangent is a shifted version of the sigmoid function, that crosses zero point and rescales so that it ranges from −1 to 1. That means that it has the effect of centering the data around zero which is a desired property for learning algorithms.
However, a downside of both the sigmoid and the hyperbolic tangent is that as z gets very large or very small the slope of the function gets close to zero thus slowing down gradient descent.
The rectified linear unit or relu(z)=max(0,z). Thus, the derivative is 1 as long as z>0 and 0 when z≤0.
The leaky rectified linear unit or leakyrelu(z)=max(0.01z,z), attempts to improves upon the dying ReLU problem, which is that all negative input values become zero immediately. Another variation is the parametric ReLU, which simply makes 0.01 a parameter, i.e., max(αz,z).
Rectified linear unit actication functions, overcome the problem of vanishing gradients, and thus, they enable much faster training of neural networks.
The exponential linear unit (ELU) or elu(z)={zz≥0α(ez−1)z<0 uses a log curve to define the negative values unlike the parametric ReLU functions that use a straight line.
The self-gated activation function (Swish) or swish(z)=zσ(z) is a smooth function that does not abruptly change direction x=0. Rather, it smoothly bends from 0 towards values < 0 and then upwards again. It consistently matches or outperforms the ReLU activation functions.
import numpy as np
def sigmoid(z: np.ndarray) -> float:
return 1 / (1 + np.exp(-z))
z = np.linspace(-5, 5)
plt.figure(figsize=(10, 4))
plt.subplot(2, 3, 1)
plt.tight_layout()
plt.plot(z, sigmoid(z))
plt.plot(z, sigmoid(z) * (1 - sigmoid(z)), "k", linestyle="dotted")
plt.xlabel("z")
plt.ylabel("$\sigma(z)$")
plt.legend(["sigmoid", "derivative"])
plt.subplot(2, 3, 2)
plt.tight_layout()
plt.plot(z, np.tanh(z), "r")
plt.plot(z, 1 - np.tanh(z) * np.tanh(z), "k", linestyle="dotted")
plt.xlabel("z")
plt.ylabel("$\mathtt{tanh}(z)$")
plt.legend(["tanh", "derivative"])
plt.subplot(2, 3, 3)
plt.tight_layout()
plt.plot(z, np.maximum(0, z), "g")
plt.plot(z, np.where(z > 0, 1, 0), "k", linestyle="dotted")
plt.xlabel("z")
plt.ylabel("$\mathtt{relu}(z)$")
plt.legend(["ReLU", "derivative"])
plt.subplot(2, 3, 4)
plt.tight_layout()
plt.plot(z, np.maximum(0.01 * z, z), "b")
plt.plot(z, np.where(z > 0, 1, 0.01), "k", linestyle="dotted")
plt.xlabel("z")
plt.ylabel("$\mathtt{leakyrelu}(z)$")
plt.legend(["Leaky ReLU", "derivative"])
plt.subplot(2, 3, 5)
plt.tight_layout()
plt.plot(z, np.where(z > 0, z, np.exp(z) - 1), "y")
plt.plot(z, np.where(z > 0, 1, np.where(z > 0, z, np.exp(z) - 1) + 1), "k", linestyle="dotted")
plt.xlabel("z")
plt.ylabel("$\mathtt{elu}(z)$")
plt.legend(["ELU (a=1)", "derivative"])
plt.subplot(2, 3, 6)
plt.tight_layout()
plt.plot(z, z * sigmoid(z), "m")
plt.plot(z, sigmoid(z) + z * sigmoid(z) * (1 - sigmoid(z)), "k", linestyle="dotted")
plt.xlabel("z")
plt.ylabel("$\mathtt{swish}(z)$")
plt.legend(["Swish", "derivative"])
plt.show()
A simple approach to the problem of determining the network parameters is to revisit the discussion of polynomial curve fitting, and attempt to minimize a sum-of-squares error function. Thus, given a training set comprising a set of input vectors {xn}, where n=1,…,N, and a corresponding set of target vectors {tn}, we minimize the error function,
En(w)=12N∑n=1||y(xn,w)−tn)||2Consider regression problems, and for the moment, a single target variable t that may take any real value. Similar to Section 3.1, we assume that t follows a Gaussian distribution having an x-dependent mean, which is given by the output of the neural network,
p(t|x,w)=N(t|y(x,w),β−1)For the above conditional distribution, it is sufficient to take the output unit activation function to be the identity, because such a network can approximate any continuous function from x to y. Given a data set of N independent, identically distributed observations X, and the corresponding target values t, the likelihood function is as follows,
p(t|X,w,β)=N∏n=1p(tn|xn,w,β)=N∏n=1N(tn|y(xn,w),β−1)Then, by taking the negative logarithm, we obtain the same error function derived in (3.11). By minimizing the error function, we obtain the maximum likelihood solution wML. Having found wML, the value of β can be found using (3.21), derived by minimizing the negative log likelihood.
IMPORTANT: Keep in mind, however, that the nonlinearity of the network function y(xn,w) causes the error to be nonconvex, and so a local maxima of the likelihood may be found, corresponding to local minima of the error function.
In binary classification a single target variable t such that t=1 for class C1 and t=0 for class C2. We consider a network having a single output whose activation function is a logistic sigmoid,
y=σ(α)=11+exp(−α)so that 0≤y(x,w)≤1, is interpreted as the conditional probability p(Ck|x) given by 1−y(x,w). The conditional distribution of targets given inputs is then a Bernoulli distribution of the form,
p(t|x,w)=y(x,w)t{1−y(x,w)}1−tGiven a training set of independent observations, the the error function by the negative log-likelihood, if the cross-entropy error function of the form,
E(w)=−N∑n=1{tnlnyn+(1−tn)ln(1−yn)}NOTE: There is no analogue of the noise precision β because the target values are assumed to be correctly labelled.
IMPORTANT: There is a natural choice of both output unit activation function and matching error function, according to the type of problem being solved. For regression we rely on linear outputs and sum-of-squares error, and for binary logistic sigmoid (binary) or softmax (multiclass) outputs and cross-entropy error function.
The goal is to find a vector such that E(w) is minimized. However, the error function has a highly nonlinear dependence on the weights, and so there are many points in the weight space at which the gradient vanishes. Since there is no hope of finding an analytical solution to the equation ∇E(w)=0 we resort to iterative numerical procedures. Most of these techniques involve choosing an initial value w(0) for the weight vector and then moving through weight space in a succession of steps of the form,
w(τ+1)=w(τ)+Δw(τ)Such algorithms involve different choices for the weight vector update Δw(τ). Usually, they make use of gradient information and therefore require that, after each update, the value of ∇E(w) is evaluated at the updated weight vector w(τ+1). In order to understand the importance of gradient information, it is useful to consider a local approximation to the error function based on a Taylor expansion.
Consider the second-order Taylor expansion of E(w) around some point ˆw,
E(w)≈E(ˆw)+(w−ˆw)Tb+12(w−ˆw)TH(w−ˆw)where b=∇E|w=ˆw and H=∇∇E is the Hessian matrix of second derivatives. Thus, the local approximation to the gradient is given by,
E(w)≈b+H(w−ˆw)for points w that are sufficiently close to ˆw, these expressions give reasonable approximations for the error and its gradient.
Consider, for instance, a simple 2-dimensional error function of the form E(w1,w2)=w21+w42+w1w2.
w = np.linspace(-1, 1, 100)
w1, w2 = np.meshgrid(w, w)
def E(w1: float, w2: float) -> float:
return w1**2 + w2**4 + w1 * w2
plt.contour(w1, w2, w1**2 + w2**4 + w1 * w2)
plt.xlabel("$w_1$")
plt.ylabel("$w_2$")
plt.title("$E(w_1, w_2) = w_1^2 + w_2^4 + w_1w_2$")
plt.show()
The gradient of E(w1,w2) is defined as follows,
∇E=[∂Ew1∂Ew2]=[2w1+w24w2+w1]and the Hessian matrix H equals,
H=∇∇E=[∂Ew1w1∂Ew1w2∂Ew2w1∂Ew2w2]=[2114]def gradient(w1, w2):
return np.array([2 * w1 + w2, 4 * w2 + w1])
H = np.array([[2, 1], [1, 4]])
Then, given some point ˆw=[ˆw1,ˆw2], E(w1,w2) can be approximated by,
E(w1,w2)≈E(^w1,^w2)+[w1−^w1,w2−^w2][2^w1+^w24^w2+^w1]+12[w1−^w1,w2−^w2]T[2114][w1−^w1,w2−^w2]w_hat = np.array([0, -0.8])
@np.vectorize
def E_approx(w1, w2):
w = np.array([w1, w2])
return E(*w_hat) + np.dot((w - w_hat), gradient(*w_hat)).T + 0.5 * np.dot(w - w_hat, np.dot(H, w - w_hat))
plt.contour(w1, w2, E_approx(w1, w2), 20, colors="lightcoral")
plt.contour(w1, w2, E(w1, w2), 20, colors="powderblue")
plt.scatter(*w_hat, color="black", marker="x")
plt.xlabel("$w_1$")
plt.ylabel("$w_2$")
plt.show()
The simplest approach to using gradient information is to choose the weight update to be a small step in the direction of the negative gradient, so that,
w(τ+1)=w(τ)−η∇E(w(τ))where the parameter η>0 is known as learning rate. At each step the weight vector is moved in the direction of the greatest rate of decrease of the error function, and so the approach is known as gradient descent or steepest descent. Note that the error function is defined with respect to a training set, and thus, each step requires that the entire training set be processed in order to evaluate ∇E. Techniques that use the whole data set at once are called batch methods. Although such an approach might intuitively seem reasonable, in fact it turns out to be a poor algorithm.
For batch optimization, there are more efficient methods, such as conjugate gradients and quasi-Newton methods, which are much more robust and much faster than simple gradient descent. Unlike gradient descent, these algorithms have the property that the error function always decreases at each iteration unless the weight vector has arrived at a local or global minimum.
There is, however, an on-line version of gradient descent, known as sequential gradient descent or stochastic gradient descent, that has proved useful in practice for training neural networks on large data sets. Error functions based on maximum likelihood for a set of independent observations comprise a sum of terms, one for each data point,
E(w)=N∑n=1En(w)Stochastic gradient descent makes an update to the weight vector based on one data point at a time, so that
w(τ+1)=w(τ)−η∇En(w(τ))The update is repeated by cycling through the data either in sequence or by selecting points at random with replacement. There are of course intermediate scenarios in which the updates are based on batches of data points. One advantage of on-line methods compared to batch methods is that the former handle redundancy in the data much more efficiently. To see, this consider an extreme example in which we take a data set and double its size by duplicating every data point. Note that this simply multiplies the error function by a factor of 2 and so is equivalent to using the original error function. Batch methods will require double the computational effort to evaluate the batch error function gradient, whereas online methods will be unaffected. Another property of on-line gradient descent is the possibility of escaping from local minima, since a stationary point with respect to the error function for the whole data set will generally not be a stationary point for each data point individually.
Error backpropagation, or simply backprop, is an efficient technique for evaluating the gradient of an error function E(w) for a feed-forward neural net. This can be achieved using a local message passing scheme in which information is sent alternately forwards and backwards through the network.
Most training algorithms involve an iterative procedure for minimization of an error function. At each such step, we can distinguish between two stages:
The derivatives of the error function must be evaluated. The important contribution of the backpropagation technique is in providing a computationally efficient method for evaluating such derivatives. Since the errors are propagated backwards through the network, we use the term backpropagation to describe the evaluation of derivatives.
The derivatives are then used to compute the adjustments to be made to the weights, e.g., gradient descent.
It is important to recognize that these stages are distinct. Thus, the first stage, namely the propagation of errors backwards through the network in order to evaluate derivatives, can be applied to many other kinds of networks and not just the multilayer perceptron. It can also be applied to error functions other than the simple sum-of-squares, and to the evaluation of other derivatives, such as, the Jacobian and Hessian matrices.
The backpropagation algorithm can be applied to a general network of arbitrary topology, non-linear activation functions, and a broad class of error function. Many error functions of practical interest comprise a sum of terms, one of each data point, so that,
E(w)=N∑n=1En(w)For simplicity, consider the evalution of a single term ∇En(w). Consider the first linear model, where the outputs are linear combinations of the input variables.
ynk=yk(xn,w)=∑iwkixithus, the error function for input example n, takes the form,
En=12∑k(ynk−tnk)2and its gradient with respect to wji, is given by,
∂En∂wji=(ynj−tnj)xniIn a general feed-forward network, each unit j (in any layer ℓ) computes a weighted sum of its inputs, as follows,
aj=∑iwjiziwhere zi is the activation of a unit in the previous layer (ℓ−1), that sends a connection to unit j, and wji is the weight associated with the connection. Then, the sum aj is transformed by a non-linear activation function h(⋅) to give zj in the form,
zj=h(aj)In turn, zj may be sent as a connection to a subsequent unit in order to participate in another activation. Note that using the vectorized notation introduced in the beginning of this chapter, we can compute the transformed activations of any layer ℓ as follows,
aℓ=Wℓzℓ−1zℓ=h(aℓ)In order to apply backprop, we assume that we have computed the activations of all hidden and output units in the network, a process called forward propagation because it may be regarded as the forward flow of information through the network. Consider again the evaluation of the derivative En. Given an arbitraty unit j, En depends on the weight wji only via the input aj. Therefore, according to the chain rule for partial derivatives, we obtain,
∂En∂wji=∂En∂aj∂aj∂wji(5.48)=∂En∂ajziSince zi is computed during forward propagation, we only need to compute ∂En∂aj. For simplicity, lets define δj=∂En∂aj.
For the output units, we have that δk=∂En∂yk(5.46)=yk−tk.
For hidden units, we apply again the chain rule,
where the sum runs over all k units to which j sends connections. Thus, for a particular hidden unit, δ is obtained by propagating the δ backwards from units higher up in the network.
Since, we know the δ for the output units, we can recursively evaluate the δ for all the hidden units in a feed-forward neural net, regardless of the topology.
Error Backpropagation algorithm
Forward propagate any input vector xn using (5.48) and (5.49).
Evalute δk for output units using (5.54).
Backpropagate δ using (5.56) to obtain δj for each hidden unit.
Evalute the required derivatives using (5.53).
For batch methods, the derivative of the total error E is obtained be repeating the above steps for each example n and then summing over all examples.
When implementing neural networks is much more performant to perform forward and backward propagation using the matrix notation introduced in the beginning of this chapter. Therefore, here we present both propagations in matrix notation across multiple training examples.
Forward Propagation
Z(0)=X
Repeat for each layer ℓ:
Backward Propagation
For the output layer evalute δL=y−t
Backpropagate δℓ+1 to obtain δℓ=h′(aℓ)⊙(W(ℓ+1)Tδℓ+1)
Evaluate derivatives ∇En(Wℓ)=δℓzℓ−1
Let's train a shallow 2-layer neural network for classification, on the planar dataset, using one hidden layer of hyperbolic tangent activation functions and one sigmoid output layer. In order to train the network for classification, we use a cross-entropy loss function, similar to logistic regression. The weights of the neural network are initialized at random, while biases are initialized to zero values.
x, y = load_planar_dataset()
net = nn.NeuralNetwork(
nn.LinearLayer(2, 4),
nn.TanH(),
nn.LinearLayer(4, 1),
nn.Sigmoid(),
)
L = nn.BinaryCrossEntropyLoss()
net.fit(x, y, loss=L)
plot_2d_decision_boundary(lambda x: net.predict(x) > 0.5, x, y)
-- Epoch 1 --- Cost: 0.7080379029345033 -- Epoch 101 --- Cost: 0.5332581467045885 -- Epoch 201 --- Cost: 0.4320979117595954 -- Epoch 301 --- Cost: 0.37866149726575815 -- Epoch 401 --- Cost: 0.3473329935455369 -- Epoch 501 --- Cost: 0.32698746950287444 -- Epoch 601 --- Cost: 0.31283296402612243 -- Epoch 701 --- Cost: 0.30246073314522465 -- Epoch 801 --- Cost: 0.294544357721986 -- Epoch 901 --- Cost: 0.28830257594631986
Weight initialization helps, among others, avoiding vanishing/exloding gradients. A common choice is Xavier initilization, defined as follows,
w=N(0,√1N(ℓ−1))Xavier initilization works better for networks using tangent activation functions. A popular choice for ReLU activation functions is
w=N(0,√2N(ℓ−1))Another popular alternative is
w=N(0,√2N(ℓ−1)+Nℓ)Then all hidden units are symmetric (completely identical), thus computing the same function, which is undesirable. Therefore, weights must be initialized randomly. Biases can still be zero since they represent a single dimension in the weight vectors of the hidden units which already differ due to the random initialization. As a proof of concept, lets re-train the same exact network but initialize weights and bias to a constant value.
net = nn.NeuralNetwork(
nn.LinearLayer(2, 4, random_initialization=False),
nn.TanH(),
nn.LinearLayer(4, 1, random_initialization=False),
nn.Sigmoid(),
)
L = nn.BinaryCrossEntropyLoss()
net.fit(x, y, loss=L)
plot_2d_decision_boundary(lambda x: net.predict(x) > 0.5, x, y)
-- Epoch 1 --- Cost: 0.6932673944222819 -- Epoch 101 --- Cost: 0.6930829250688935 -- Epoch 201 --- Cost: 0.6884763177838701 -- Epoch 301 --- Cost: 0.6706060650349758 -- Epoch 401 --- Cost: 0.668686022681725 -- Epoch 501 --- Cost: 0.6683657053643131 -- Epoch 601 --- Cost: 0.6676242897407316 -- Epoch 701 --- Cost: 0.6658298361918764 -- Epoch 801 --- Cost: 0.6627349400852941 -- Epoch 901 --- Cost: 0.659059060995905
Note that the behavior of the trained network is identical to logistic regression and the perceptron models we used in the beginning of the chapter.
In case of multiclass classification the sigmoid activation in the final layer of the network should be replaced by a softmax activation, similar to softmax regression. For instance, consider a synthetic dataset comprise 100 training examples, each having 2 input features and belonging to one of 3 classes.
# number of training points
N = 100
x_train, t = make_classification(
n_features=2, n_informative=2, n_redundant=0, n_classes=3, n_clusters_per_class=1, n_samples=N, random_state=21
)
encoder = OneHotEncoder()
t_one_hot = encoder.encode(t)
model = nn.NeuralNetwork(nn.LinearLayer(2, 4), nn.ReLU(), nn.LinearLayer(4, 3), nn.Softmax())
model.fit(
x_train,
t_one_hot,
epochs=10000,
loss=nn.CrossEntropyLoss(),
optimizer=nn.GradientDescent(learning_rate=0.01),
verbose=True,
)
x1, x2 = np.meshgrid(np.linspace(-5, 5, 100), np.linspace(-5, 5, 100))
x_test = np.array([x1, x2]).reshape(2, -1).T
predicted = np.argmax(model.predict(x_test), axis=1)
print("Training Error:")
print(classification_report(t, np.argmax(model.predict(x_train), axis=-1)))
plt.scatter(x_train[:, 0], x_train[:, 1], c=t)
plt.contourf(x1, x2, predicted.reshape(100, 100), alpha=0.2, levels=np.linspace(0, 2, 4))
plt.xlim(-5, 5)
plt.ylim(-5, 5)
plt.xlabel("$x_1$", fontsize=12)
plt.ylabel("$x_2$", fontsize=12)
plt.show()
-- Epoch 1 --- Cost: 1.10166804109341 -- Epoch 1001 --- Cost: 0.4274539415136976 -- Epoch 2001 --- Cost: 0.38231954420096187 -- Epoch 3001 --- Cost: 0.3679944991104277 -- Epoch 4001 --- Cost: 0.3585322733626179 -- Epoch 5001 --- Cost: 0.34823524562803826 -- Epoch 6001 --- Cost: 0.3399993027070345 -- Epoch 7001 --- Cost: 0.33318353101161735 -- Epoch 8001 --- Cost: 0.3284922056777914 -- Epoch 9001 --- Cost: 0.3244782822114613 Training Error: precision recall f1-score support 0 0.90 0.82 0.86 33 1 0.94 0.86 0.90 35 2 0.82 0.97 0.89 32 accuracy 0.88 100 macro avg 0.88 0.88 0.88 100 weighted avg 0.89 0.88 0.88 100
Let a cost function f(θ), then by choosing a very small ϵ value, we can numerically approximate the gradient in any given point of the function by taking symmetrical central differences around that point, as depicted below,
Numerical differentiation is very important in practice, because a comparison of the derivatives calculated by backpropagation with those obtained using central differences provides a very accurate check on the correctness of any implementation of the backpropagation algorithm. When training networks in practice, derivatives should be evaluated using backpropagation, because this gives the greatest accuracy and numerical efficiency. However, the results should be compared with numerical differentiation in order to check the correctness of the implementation.
Note that by using these steps, for many problems, you can achieve significant reduction in both bias and variance, in constrast to simpler models, where the bias-variance tradeoff is more hard or even impossible to overcome. The main drawback here is that larger networks require have higher computation cost to train, and more data are not always easy to find.
The number M of hidden units if a free parameter, in contrast to input and output units, and can be adjusted to obtain the best predictive performance. Note that M indirectly controls the number of parameters (weights and biases) in the network, and therefore, we expect that by using maximum likelihood, we should find an optimal value for M that yields the best generalization performance, corresponding to the optimal balance between an under-fit and over-fit.
The generalization error, however, is not a simple function of M due to the presence of local minima in the error function. One approach of choosing M is to plot a graph of M against validation set performance and then choose the solution having the smallest validation set error, similar to model selection on Chapter 1.
Another approach of course is to choose a relatively large value for M and add a regularization term to the error function in order to control the model complexity. The simplest regularizer is the quadratic, also known as weight decay in the context of neural networks,
˜E(w)=E(w)+λ2wTwHowever, for the full neural network, the error function is obtained by,
˜E=E+λ2L∑ℓ=1||Wℓ||2Fwhere ||Wℓ||2F is the Forbenius norm, defined as,
||Wℓ||2F=Mℓ∑i=1M(ℓ−1)∑j=1w2ijThe effective model complexity is then determined by the choice of λ. As discussed in Chapter 1, the quadratic regularizer can be interpreted as the negative logarithm of a zero-mean Gaussian prior over the weight vector w. Then, adding a quadratic regularization term, the error function for input example n, takes the form,
˜En=12∑k(ynk−tnk)2+λ2∑k=1w2kwhere λ is called the regularization parameter, and the gradient is obtained as follows,
∂˜Enwji=(ynj−tnj)xni+λwj=∂Enwji+λwjor
∇˜En=(yn−tn)xn+λw=∇En+λwThen, by replacing back into (5.43), the stochastic gradient descent update becomes:
w(τ+1)=w(τ)−η(∇En(w(τ))+λw(τ))=w(τ)−η∇En(w(τ))−ηλw(τ)=(1−ηλ)w(τ)−η∇En(w(τ))x_train, y_train = generate_toy_data(lambda x: np.sin(2 * np.pi * x), sample_size=10, std=0.25)
plt.scatter(x_train, y_train, marker="x", color="k")
plt.show()
x_space = np.linspace(0, 1, 100)[:, None]
def create_network(m: int) -> nn.NeuralNetwork:
return nn.NeuralNetwork(
nn.LinearLayer(1, m),
nn.TanH(),
nn.LinearLayer(m, 1),
nn.Linear(),
)
plt.figure(figsize=(20, 5))
for i, m in enumerate([1, 10]):
model = create_network(m)
model.fit(
x_train[:, None],
y_train[:, None],
epochs=100000,
loss=nn.SSELoss(),
optimizer=nn.GradientDescent(learning_rate=0.01),
verbose=False,
)
y = model(x_space)
regularized_model = create_network(m)
regularized_model.fit(
x_train[:, None],
y_train[:, None],
epochs=100000,
loss=nn.SSELoss(),
optimizer=nn.GradientDescent(learning_rate=0.01, weight_decay=1e-3),
verbose=False,
)
y_regularized = regularized_model(x_space)
plt.subplot(1, 3, i + 1)
plt.scatter(x_train.ravel(), y_train.ravel(), marker="x", color="black")
plt.plot(x_space, np.sin(2 * np.pi * x_space), color="green")
plt.plot(x_space.ravel(), y.ravel(), color="orangered")
plt.plot(x_space.ravel(), y_regularized.ravel(), color="deepskyblue")
plt.annotate(f"M={m}", (0.7, 0.5))
plt.legend(["Training Data", "$\sin(2\pi x)$", "No regularization", "Weight Decay"])
plt.show()
In the three figures above, the unregularized neural network (red line), has high bias on the left (M=1) and high variance on the right (M=10). Regularization helps combat high variance by making the network simpler by enforcing the deactivation of some hidden units (by penalizing weight parameters). Let's see an intuitive example. Assume that a neural network uses tanh(x) activation functions for the hidden units. The tanh(x) activation has a roughly linear form for values close to zero, as shown in the following figure.
x = np.linspace(-5, 5)
narrow_x = np.linspace(-1, 1)
plt.plot(x, np.tanh(x), "k")
plt.plot(narrow_x, np.tanh(narrow_x), "r")
plt.vlines([-1, 1], -1, 1, colors="r", linestyles="dotted")
plt.xlabel("x")
plt.ylabel("$\mathtt{tanh}(x)$")
plt.show()
As the λ parameter gets larger, the effect of the regularization term penalizes the weight parameters to have smaller values (closer to zero). Note that hidden unit outputs (before activation) are a linear combination of the layer ℓ weights and the outputs of the previous layer, A(ℓ)=W(ℓ)Z(ℓ−1)+w(ℓ)0, thus, in turn, the resulting A(ℓ) would be also smaller since Z(ℓ−1) are multiplied by smaller values. Therefore, when passed through the activation functions tanh(z) would respond more linearly. Increasing regularization (larger λ) enforces roughly linear activations and leads to simpler models which helps combat high variance.
Dropout is another form of regularization that eliminates a percentage of the hidden units in the network by chance. For instance, you may toss a fair coin and have 50% change of keeping each hidden unit in some layer ℓ. Then, by removing these units from the parameters Wℓ of the ℓ layer, the resulting network is much smaller. Dropout defines an indicator vector (mask) of zeros and ones, per hidden unit in the layer ℓ, which is used for dropping or deactivating a percentage of hidden units. More formally, assuming tha layer ℓ has M hidden units,
dℓ∝Bin(M,p)where p is the probability of keeping a hidden unit, and
zℓ=dℓ⊙zℓpwhere the division by p is called inverted dropout and ensures that the expected value of z remains the same after dropping a percentage of hidden units. For each training example a new dℓ vector should be randomly chosen. Therefore given N training examples stacked in a matrix notation, the dropout mask is defined as,
Dℓ=[Bin1(M,p)…BinN(M,p)]and
Zℓ=Dℓ⊙ZℓpNote that on prediction (test) time, dropout should not be used, that is, the network should not activate hidden units at random, but instead should use all hidden units. Moreover, due to to randomization, the error function is ill-defined and therefore it may not decrease in every iteration of gradient descent as expected.
x_space = np.linspace(0, 1, 100)[:, None]
def create_network(m: int, dropout: float = 0) -> nn.NeuralNetwork:
return (
nn.NeuralNetwork(
nn.LinearLayer(1, m),
nn.Dropout(dropout),
nn.TanH(),
nn.LinearLayer(m, 1),
nn.Linear(),
)
if dropout > 0
else nn.NeuralNetwork(
nn.LinearLayer(1, m),
nn.TanH(),
nn.LinearLayer(m, 1),
nn.Linear(),
)
)
plt.figure(figsize=(20, 5))
for i, m in enumerate([1, 10]):
model = create_network(m)
model.fit(
x_train[:, None],
y_train[:, None],
epochs=100000,
loss=nn.SSELoss(),
optimizer=nn.GradientDescent(learning_rate=0.01),
verbose=False,
)
y = model(x_space)
regularized_model = create_network(m, dropout=0.99)
regularized_model.fit(
x_train[:, None],
y_train[:, None],
epochs=100000,
loss=nn.SSELoss(),
optimizer=nn.GradientDescent(learning_rate=0.01),
verbose=False,
)
y_regularized = regularized_model(x_space)
plt.subplot(1, 3, i + 1)
plt.scatter(x_train.ravel(), y_train.ravel(), marker="x", color="black")
plt.plot(x_space, np.sin(2 * np.pi * x_space), color="green")
plt.plot(x_space.ravel(), y.ravel(), color="orangered")
plt.plot(x_space.ravel(), y_regularized.ravel(), color="deepskyblue")
plt.annotate(f"M={m}", (0.7, 0.5))
plt.legend(["Training Data", "$\sin(2\pi x)$", "No regularization", "Dropout (90%)"])
Since dropout randomly deactivates, in any given layer, a portion of the inputs, that means, intuitively, that the learning algorithm cannot rely on any feature, and so, it is forced to spread out the weights. To that end, it shrinks the squared norm of the weights. Note that dropout can be shown to be an adaptive form of ℓ2-regularization.
As discussed earlier, to facilitate learning, weights are initialized to have zero mean and small variance. As training progresses and the parameters are updated to different extents, the initial normalization is lost, which, in turn, slows down training and amplifies changes as the network becomes deeper. Batch normalization reestablishes these normalizations for every mini-batch of N training examples, on every layer ℓ. By making batch normalization part of the model architecture, we are able to use higher learning rates and pay less attention to the initialization parameters. Batch normalization additionally acts as a regularizer, reducing (and sometimes even eliminating) the need for Dropout. Batch normalization is usually applied on top of the activations α(ℓ) as follows:
μ(ℓ)=1NN∑i=1α(ℓ)iσ(ℓ)2=1mN∑i=1(α(ℓ)i−μ(ℓ))2α(ℓ)i,norm=α(ℓ)i−μ(ℓ)√σ(ℓ)2+ϵˆα(ℓ)i=γ(ℓ)α(ℓ)i,norm+β(ℓ)where γ(ℓ) and β(ℓ) are learnable parameters, thus allowing each layer to have a different distribution. In order words, during forward propagation, each neuron or hidden unit includes a batch normalization operator between the activations α(ℓ) and the nonlinear activation functions h(⋅). However, the addition of the batch normalization operator, changes the derivation of backprop. During backward propagation, δ(ℓ)=∂En∂α(ℓ)i should be derived using the chain rule as follows,
∂En∂α(ℓ)i=∂En∂α(ℓ)i,norm∂α(ℓ)i,norm∂α(ℓ)i+∂En∂μ∂μ∂α(ℓ)i+∂En∂σ(ℓ)2∂σ(ℓ)2∂α(ℓ)iThe chain rule results in a summation having three components. Let's start from the simpler terms, which are the second terms in the product of each component,
∂α(ℓ)i,norm∂α(ℓ)i=1√σ(ℓ)2+ϵ,∂μ(ℓ)∂α(ℓ)i=1m,∂σ(ℓ)2∂α(ℓ)i=2(α(ℓ)i−μ(ℓ))mthen, let's move on to the first terms of the components,
∂En∂α(ℓ)i,norm=∂En∂^αi(ℓ)∂^αi(ℓ)∂α(ℓ)i,norm=∂En∂^αi(ℓ)γ(ℓ)therefore,
∂En∂μ(ℓ)=N∑i=1∂En∂α(ℓ)i,norm−1√σ(ℓ)2+ϵFinally, δℓ is obtained as follows,
δ(ℓ)=(∂En∂α(ℓ)i,norm1√σ(ℓ)2+ϵ)+(1mN∑j=1∂En∂α(ℓ)j,norm−1√σ(ℓ)2+ϵ)+(−12N∑j=1(α(ℓ)j−μ)(σ(ℓ)2+ϵ)−1.5∂En∂α(ℓ)j,norm2(α(ℓ)i−μ(ℓ))m)=(∂En∂α(ℓ)i,norm1√σ(ℓ)2+ϵ)−(N∑j=1∂En∂α(ℓ)j,norm1m√σ(ℓ)2+ϵ)−(N∑j=1(α(ℓ)j−μ(ℓ))(σ(ℓ)2+ϵ)−1.5∂En∂α(ℓ)j,norm(α(ℓ)i−μ(ℓ))m)=(∂En∂α(ℓ)i,norm1√σ(ℓ)2+ϵ)−(N∑j=1∂En∂α(ℓ)j,norm1m√σ(ℓ)2+ϵ)−(N∑j=1(α(ℓ)j−μ(ℓ))(α(ℓ)i−μ(ℓ))m√σ(ℓ)2+ϵ3∂En∂α(ℓ)j,norm)=1m√σ(ℓ)2+ϵ(m∂En∂α(ℓ)i,norm−N∑j=1∂En∂α(ℓ)j,norm−N∑j=1(α(ℓ)i−μ(ℓ))(α(ℓ)j−μ(ℓ))√σ(ℓ)2+ϵ2∂En∂α(ℓ)j,norm)=1m√σ(ℓ)2+ϵ(m∂En∂α(ℓ)i,norm−N∑j=1∂En∂α(ℓ)j,norm−α(ℓ)i,normN∑j=1α(ℓ)j,norm∂En∂α(ℓ)j,norm)The gradients for γ(ℓ) and β(ℓ) are obtained similarly,
∂En∂γ(ℓ)=N∑i=1∂En∂^αi(ℓ)∂^αi(ℓ)∂γ(ℓ)=N∑i=1∂En∂^αi(ℓ)α(ℓ)i,normand
∂En∂β(ℓ)=N∑i=1∂En∂^αi(ℓ)∂^αi(ℓ)∂β(ℓ)=N∑i=1∂En∂^αi(ℓ)x_space = np.linspace(0, 1, 100)[:, None]
model = nn.NeuralNetwork(
nn.LinearLayer(1, 10),
nn.BatchNorm(),
nn.TanH(),
nn.LinearLayer(10, 1),
nn.Linear(),
)
model.fit(
x_train[:, None],
y_train[:, None],
epochs=10000,
loss=nn.SSELoss(),
optimizer=nn.GradientDescent(learning_rate=0.01),
verbose=False,
)
y = model(x_space)
plt.scatter(x_train.ravel(), y_train.ravel(), marker="x", color="black")
plt.plot(x_space, np.sin(2 * np.pi * x_space), color="green")
plt.plot(x_space.ravel(), y.ravel(), color="orangered")
plt.annotate(f"M={4}", (0.7, 0.5))
plt.legend(["Training Data", "$\sin(2\pi x)$", "Batch normalization"])
plt.show()
Simple weight decay is affected by certain scaling properties of network mappings. A regularizer that is invariant to re-scaling of the weights and to shifts to biases, given a 2-layer neural network is defined as,
λ12∑w∈W1w2+λ22∑w∈W2w2where W1 denotes the weights of the first layer and W2 denotes the set of weights of the second layer and biases are excluded from the summations. The corresponding Gaussian prior for this regularizer takes the form
p(w|α1,α2)∝(−α12∑w∈W1w2−α22∑w∈W2w2)Note that priors of this form are improper, that means they cannot be normalized, because bias parameters are unconstrained. Since improper priors lead to zero evidence in the Bayesian framework, it is common practice to include separate priors for the biases.
In the general case, weights can be divided into any number of groups Wk, thus obtaining priors of the form,
p(w|α)∝(−12∑kαk||w||2k)where α=(α1,…,αk) and ||w||2k=∑j∈Wkw2j.
An alternative to regularization is the procedure of early stopping. For many algorithms used for network training, such as conjugate gradients, the error is a non-increasing function of the iteration index. However, the error measured on a validation set (independent data), often shows a decrease at first, followed by an increase as the network starts to over-fit. Training can thus be stopped at this point of smallest error with respect to the validation set in order to obtain good generalization performance.
In order to achieve or surpass human-level performance, which can be considered as a proxy to optimal Bayes error, the model should minimize the error in the training set to be as close as possible to the error achieved by humans (avoidable bias). However, at the same time, the model should retain low variance, that is, low error on the validation or development set. In the first case, where the error in the training set is off by a large percentage compared to the error measured in humans, one should consider training a deeper model, using a better optimization algorithm or even alternative neural network architectures. On the other hand, when variance is high, the model may have overfitted, which can be dealt using regularization techniques, more training data or data augmentation to include invariances.
Ideally, predictions should be unchanged or invariant under one or more transformations of the input variables. For example, in image classification tasks, such as digit recognition, the particular object should be assigned the same label irrespective of its position in the image (translation invariance) or of its size (scale invariance). Similar, in speech recognition, small levels of nonlinear warping along the time axis (assuming temporal ordering is perserved) should not change the interpretation of the signal.
Given a sufficiently large number of examples, an adaptive model can learn the invariance, even approximately. However, if the number of examples is limited, or there are several invariants, there is a number of alternative approaches for encouraging a model to exhibit the invariances:
Convolutional neural networks build invariance properties into the structure of the network and have been widely applied to image data. In general, image recognition may be performed using a fully connected neural network similar to the ones presented so far. Given sufficiently large training data, such a network could in principle yield a good solution and learn the appropriate invariances. However, typical neural networks ignore a key property of images, which is that nearby pixels are more strongly correlated than distance ones.
On the other hand, modern approaches to computer vision exploit this property by extracting local features that depend only on small subregions of the image. Information from such features are then merged in later stages of processing in order to detect higher-order features. Moreover, local features that are useful in one region of the image are likely to be useful in other regions of the image, for instance if the object of interest is translated.
These notions are incorporated into convolutional neural networks through three mechanisms:
The convolution operation is one of the fundamental building blocks of the convolutional neural networks. As a motivating example, lets perform edge detection on an image in order to get a grasp of the convolution operation. Consider an input grayscale image of Dr. Freeman (the silent protagonist of Half-Life).
from PIL import Image
im = Image.open("../images/gordon_freeman.jpg").convert("L")
im_array = np.asarray(im)
THRESHOLD = 125
im_array = np.where(im_array > THRESHOLD, 255, np.where(im_array <= THRESHOLD, 0, im_array))
plt.figure(figsize=(10, 10))
plt.subplot(1, 2, 1)
plt.imshow(im, cmap="gray")
plt.title("Grayscale")
plt.subplot(1, 2, 2)
plt.imshow(im_array, cmap="gray")
plt.title("Black and White")
plt.show()
Each grayscale image comprises a set of pixel intensity values represented as a 2-dimensional array of integers in [0,255], where 0 represents completely black and 255 completely white. In order to make subsequent edge detection more apparent, we further transform the input image to black and white using thresholding on intensity value 125. To that end, the image of Dr. Freeman is represented by the following matrix:
im_array
array([[255, 255, 255, ..., 255, 255, 255], [255, 255, 255, ..., 255, 255, 255], [255, 255, 255, ..., 255, 255, 255], ..., [255, 255, 255, ..., 0, 0, 0], [255, 255, 255, ..., 0, 0, 0], [255, 255, 255, ..., 0, 0, 0]], dtype=uint8)
Then, in order to detect edges, we may construct a small matrix (called a filter or a kernel in computer vision literature) and convolve it with the image of Dr. Freeman. If we wish to detect vertical edges, then the filter may have the following form,
[10−110−110−1]or, in case of horizontal edges, it may take the form,
[111000−1−1−1]The convolution of the image and the filter may be implemented as follows:
def convolve2D(image: np.ndarray, filter: np.ndarray, padding: int = 0, strides: int = 1) -> np.ndarray:
assert padding >= 0, "Padding cannot be negative"
assert strides > 0, "Stride cannot be zero or negative"
n, m = image.shape
f, k = filter.shape
# shape of output convolution
output_n = int(((n - f + 2 * padding) / strides) + 1)
output_m = int(((m - k + 2 * padding) / strides) + 1)
output = np.zeros((output_n, output_m), dtype=np.uint8)
# apply padding
if padding > 0:
padded_image = np.zeros((n + padding * 2, m + padding * 2))
padded_image[padding:-padding, padding:-padding] = image
else:
padded_image = image
for i in range(output_n):
for j in range(output_m):
output[i, j] = (filter * padded_image[i * strides : i * strides + f, j * strides : j * strides + f]).sum()
return output
The following figure depicts the original black and white image along the horizontal and vertical edges detected by the filters.
horizontal_filter = np.array(
[
[1, 1, 1],
[0, 0, 0],
[-1, -1, -1],
]
)
vertical_filter = np.array(
[
[1, 0, -1],
[1, 0, -1],
[1, 0, -1],
]
)
plt.figure(figsize=(10, 10))
plt.subplot(1, 3, 1)
plt.imshow(im_array, cmap="gray")
plt.subplot(1, 3, 2)
plt.imshow(convolve2D(im_array, horizontal_filter), cmap="gray")
plt.subplot(1, 3, 3)
plt.imshow(convolve2D(im_array, vertical_filter), cmap="gray")
plt.show()
Moreover, padding may be used on the original image before convoluation in order for the output size to be the same as the input size.
print(
f"Original size: {im_array.shape}, Output size (no padding): {convolve2D(im_array, horizontal_filter).shape}, Output size (padding): {convolve2D(im_array, horizontal_filter, 1).shape}"
)
Original size: (604, 604), Output size (no padding): (602, 602), Output size (padding): (604, 604)
These filters detect or emphasize edges having horizontal or vertical orientation. On the other hand, there are numerous filters developed in the image processing literature. To that end, the idea behind convolutional neural networks is to learn such filters that detect useful local features (e.g. edges) over the input images. Thus, the filters in a convolutional layer are represented using the following parametric form:
[w1w2w3w4w5w6w7w8w9]In the convolutional layer the units are organized into planes, each of which is called a feature map. Units in a feature map take inputs only from a small subregion of the image, and all of the units in a feature map are constrained to share the same weight values. The convolutional layers can also learn filters or feature maps over multiple color channels. For instance, consider the RGB version of Dr. Freeman picture:
from PIL import Image
im = Image.open("../images/gordon_freeman.jpg")
im_array = np.asarray(im)
plt.imshow(im, cmap="gray")
plt.show()
Note that the image matrix has three channels in the third dimension.
im_array.shape
(604, 604, 3)
The convolution over multiple color channels is similar to the grayscale one:
def convolve3D(image: np.ndarray, filter: np.ndarray, padding: int = 0, strides: int = 1) -> np.ndarray:
assert padding >= 0, "Padding cannot be negative"
assert strides > 0, "Stride cannot be zero or negative"
assert image.shape[2] == filter.shape[2], "Image and filter should have the same number of channels"
n, m, nc = image.shape
f, k, nc = filter.shape
# shape of output convolution
output_n = int(((n - f + 2 * padding) / strides) + 1)
output_m = int(((m - k + 2 * padding) / strides) + 1)
output = np.zeros((output_n, output_m), dtype=np.uint8)
# apply padding
if padding > 0:
padded_image = np.zeros((n + padding * 2, m + padding * 2, nc))
padded_image[padding:-padding, padding:-padding, :] = image
else:
padded_image = image
for i in range(output_n):
for j in range(output_m):
output[i, j] = (
filter * padded_image[i * strides : i * strides + f, j * strides : j * strides + f, :]
).sum()
return output
The following figure depicts the original RGB image along the horizontal and vertical edges detected by the 3-channel filters.
horizontal_filter = np.array(
[
[
[1, 1, 1],
[0, 0, 0],
[-1, -1, -1],
],
[
[1, 1, 1],
[0, 0, 0],
[-1, -1, -1],
],
[
[1, 1, 1],
[0, 0, 0],
[-1, -1, -1],
],
]
)
vertical_filter = np.array(
[
[
[1, 0, -1],
[1, 0, -1],
[1, 0, -1],
],
[
[1, 0, -1],
[1, 0, -1],
[1, 0, -1],
],
[
[1, 0, -1],
[1, 0, -1],
[1, 0, -1],
],
]
)
plt.figure(figsize=(10, 10))
plt.subplot(1, 3, 1)
plt.imshow(im_array)
plt.subplot(1, 3, 2)
plt.imshow(convolve3D(im_array, horizontal_filter))
plt.subplot(1, 3, 3)
plt.imshow(convolve3D(im_array, vertical_filter))
plt.show()
For RGB images the respective convolutional layers learn filters having multiple channels (usually three), by representing them as tensors.
A single convolutional layer may take as input an RGB image and convolve it with a number of filters as depicted in the following figure:
Summary of the notation:
Symbol | Description |
---|---|
n[l−1]h | input height |
n[l]h | output height |
n[l−1]w | input width |
n[l]w | output width |
f[l] | filter size |
p[l] | padding |
s[l] | stride |
n[l]c | number of filters |
Each filter convolution inside the layer yields an output matrix of dimension (n[0]h−f[0]+2∗p[0]s[0]+1)×(n[0]w−f[0]+2∗p[0]s[0]+1). Then the bias parameter is added to the resulting matrix and the result is passed through the activation function g. The final matrices across all n[0]c filters of the layer are stacked together to yield a tensor of dimension (n[0]h−f[0]+2∗p[0]s[0]+1)×(n[0]w−f[0]+2∗p[0]s[0]+1)×n[0]c (represented by the 3-dimensional rectangle). The parameter matrix W[l] of the convolutional layer has dimension f[l]×f[l]×n[l−1]c×n[l]c.
Why Convolutions?
Parameter Sharing: A feature detector that is useful in one part of the image is probably useful in another part of the image. Therefore filter parameters derived during a learning process are used to detect features or edges over many parts of the image.
Sparsity of connections: In each layer, each output value depends only on a small number of inputs, that is, adjacent pixel values.
Both of these properties allows us to construct neural networks that have a lot fewer parameters than fully connected architectures. Moreover, fewer parameters can be learned using smaller training sets in contrast to the corresponding fully connected neural networks. Moreover, convolutional layers are effective at capturing translation invariance, which means that an image shifted by a few pixels should result in pretty similar features and to that end, classified in a similar way.
Pooling layers perform a mathematical operation, usually an aggregation, over sub-regions of an image. For instance, max pooling computes the maximum pixel intensity of each sub-region of the input image. Thus, given a 4×4 image a max pooling layer of size 2 and stride 2, should yield the following:
Pooling layers are applied on each channel independently, thus computing an output tensor the same dimension. In the context of edge detection, a max pooling layer may intuitevely keep stronger or more apparent edges. However, in practice, pooling is used because it is shown experimentaly that works well and not because we have a concrete proof about its importance. There are other kinds of pooling, except max pooling, like average pooling which averages the pixel intensities of each region instead of computing the maximum. Note that pooling have no learnable parameters only a couple of hyperparameters.
pooled = nn.MaxPooling(pool_size=(25, 5), stride=5)._forward(im_array[None, :])
plt.imshow(pooled[0].astype(int))
plt.show()
images, labels = load_mnist_dataset(500)
random_indices = [random.randint(0, images.shape[0]) for _ in range(10)]
plt.figure(figsize=(10, 5))
for i, index in enumerate(random_indices):
image = images[index]
plt.subplot(int(len(random_indices) / 5) + 1, 5, i + 1)
plt.imshow(image, cmap=plt.cm.gray)
plt.title(f"Label: '{labels[index]}'")
plt.axis("off")
images = (images - images.mean(axis=(1, 2), keepdims=True)) / images.std(axis=(1, 2), keepdims=True)
encoder = OneHotEncoder()
one_hot_labels = encoder.encode(labels)
images_train, images_test, one_hot_labels_train, one_hot_labels_test = train_test_split(
images, one_hot_labels, test_size=0.2, stratify=one_hot_labels
)
images[0].shape
(28, 28)
model = nn.NeuralNetwork(
nn.ConvLayer(1, 6, kernel_size=(5, 5), padding=2),
nn.ReLU(),
nn.MaxPooling(pool_size=(2, 2), stride=2),
nn.ConvLayer(6, 16, kernel_size=(5, 5)),
nn.ReLU(),
nn.MaxPooling(pool_size=(2, 2), stride=2),
nn.Flatten(),
nn.LinearLayer(400, 120),
nn.ReLU(),
nn.LinearLayer(120, 84),
nn.ReLU(),
nn.LinearLayer(84, 10),
nn.Softmax(),
)
model.fit(
images_train[:, :, :, None],
one_hot_labels_train,
epochs=50,
batch_size=10,
loss=nn.CrossEntropyLoss(),
optimizer=nn.AdamW(learning_rate=0.00001, weight_decay=1e-2),
)
-- Epoch 1 --- Cost: 1.598538332796852 -- Epoch 3 --- Cost: 1.3129573812941102 -- Epoch 5 --- Cost: 1.3086841008174646 -- Epoch 7 --- Cost: 0.5296930303635164 -- Epoch 9 --- Cost: 0.24401742821367545 -- Epoch 11 --- Cost: 1.2518014840898435 -- Epoch 13 --- Cost: 0.1519895430574175 -- Epoch 15 --- Cost: 0.10023537642811078 -- Epoch 17 --- Cost: 0.5512170802002273 -- Epoch 19 --- Cost: 0.4524269162805252
train_predictions = np.argmax(model(images_train[:, :, :, None]), axis=1)
print(classification_report(encoder.decode(one_hot_labels_train), train_predictions, zero_division=0))
precision recall f1-score support 0 0.95 0.97 0.96 39 1 0.94 0.96 0.95 46 2 0.97 0.88 0.93 42 3 0.90 0.93 0.91 40 4 1.00 0.72 0.84 39 5 0.88 0.81 0.84 36 6 0.83 1.00 0.90 38 7 0.82 0.88 0.85 41 8 0.84 0.82 0.83 39 9 0.76 0.85 0.80 40 accuracy 0.88 400 macro avg 0.89 0.88 0.88 400 weighted avg 0.89 0.88 0.88 400
test_predictions = np.argmax(model(images_test[:, :, :, None]), axis=1)
print(classification_report(encoder.decode(one_hot_labels_test), test_predictions, zero_division=0))
precision recall f1-score support 0 0.82 0.90 0.86 10 1 1.00 1.00 1.00 11 2 1.00 0.50 0.67 10 3 0.60 0.60 0.60 10 4 0.88 0.70 0.78 10 5 0.62 0.89 0.73 9 6 1.00 1.00 1.00 10 7 0.89 0.80 0.84 10 8 0.70 0.70 0.70 10 9 0.69 0.90 0.78 10 accuracy 0.80 100 macro avg 0.82 0.80 0.80 100 weighted avg 0.82 0.80 0.80 100
Consider a dataset generated by sampling a variable x uniformly over the interval [0,1] and the corresponding target values t by computing the function f(x)=x+0.3sin(2πx) and adding uniform noise over the interval [−0.1,0.1]. The inverse dataset is obtained by exchanging the roles of x and t. Then, by training a 2-layer neural network having 6 hidden units and a single linear output unit, we can see that it leads to a very poor model for the highly non-Gaussian inverse problem. That is because least squares corresponds to maximum likelihood under a Gaussian assumption.
x, y = generate_toy_data(lambda x: x + 0.3 * np.sin(2 * np.pi * x), sample_size=300, std=0.1, uniform=True)
model_xy = create_network(6)
model_xy.fit(x[:, None], y[:, None], epochs=100000, loss=nn.SSELoss(), verbose=False)
model_yx = create_network(6)
model_yx.fit(
y[:, None],
x[:, None],
epochs=100000,
optimizer=nn.GradientDescent(learning_rate=0.5),
loss=nn.SSELoss(),
verbose=False,
)
x_space = np.linspace(0, 1, 100)[:, None]
y_space = np.linspace(0, 1, 100)[:, None]
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.scatter(x, y, facecolors="none", edgecolors="green")
plt.plot(x_space, model_xy.predict(x_space), color="red")
plt.xlabel("x")
plt.ylabel("y")
plt.subplot(1, 2, 2)
plt.scatter(y, x, facecolors="none", edgecolors="green")
plt.plot(y_space, model_yx.predict(y_space), color="red")
plt.xlabel("y")
plt.ylabel("x")
plt.show()
We therefore seek a general framework for modelling conditional probability distributions. This can be achieved by using a mixture model for p(t|x) in which both the mixing coefficients as well as the component densities are parametric functions of the input x, giving rise to the mixture density network.
Any mixture of distributions may be used for the components, such as Bernoulli if the target variables are binary, however, we shall develop the model explicitly for Gaussian components, so that,
p(t|x)=K∑k=1πk(x)N(t|μk(x),σ2k(x)I)which is an example of a heteroscedastic model specialized to the case of isotropic covariances for the components.
The partial derivatives with respect to the mixing coefficients are obtained by
∂En∂aπj=−∂∂aπjln{K∑k=1πkNnk}=−1∑Kk=1πkNnkK∑k=1∂πk∂aπjNnk=−1∑Kk=1πkNnkK∑k=1πj(Ikj−πk)Nnk=−1∑Kk=1πkNnk(πjNjk−πjK∑k=1πkNnk)=πj−πjNjk∑Kk=1πkNnk=πj−γnjNote that we use aπj instead of aπk for the subscript denoting the j component in order to avoid confusion with the summation subscript k. Thus, by changing the subscript back to k after the proof, we arrive at the same result presented in (5.154), that is, ∂En∂aπk=πk−γnk.
The partial derivatives with respect to the component means are obtained by
∂En∂aμk(5.152)=∂En∂μk=−∂∂μkln{K∑k=1πkNnk}=−1∑Kk=1πkNnk∂∂μkπkNnk=−1∑Kk=1πkNnk∂∂μk{−12(tn−μk)T(σ2kI)−1(tn−μk)}=−πkNnk∑Kk=1πkNnk(−12)(−21σ2kI(tn−μk))=πkNnk∑Kk=1πkNnkμk−tnσ2kI(5.154)=γnkμk−tnσ2kIThus, for a particular dimension l, the final derivative would be,
∂En∂aμkl=γnkμkl−tnlσ2kThe partial derivatives with respect to the component of variances are obtained by
∂En∂aσk=−∂∂aσkln{K∑k=1πkNnk}=−1∑Kk=1πkNnk∂∂aσkπkNnk=−1∑Kk=1πkNnkπk∂∂aσk1(2π)D/2|σkI|1/2exp{−12(tn−μk)T(σkI)−1(tn−μk)}|σkI|=σDk=−1∑Kk=1πkNnkπk∂∂aσk1(2π)D/2σD/2kexp{−12(tn−μk)T(σkI)−1(tn−μk)}=−1∑Kk=1πkNnkπk[1(2π)D/2∂∂aσk1σD/2kexp{−12(tn−μk)T(σkI)−1(tn−μk)}+1(2π)D/2σD/2kexp{−12(tn−μk)T(σkI)−1(tn−μk)}∂∂aσk(−12(tn−μk)T(σkI)−1(tn−μk))]=−1∑Kk=1πkNnkπk[−D2σkNnk+Nnk∂∂aσk(−12(tn−μk)T(σkI)−1(tn−μk))]=−1∑Kk=1πkNnkπkNnk[−D2σk+∂∂aσk(−12(tn−μk)T(σkI)−1(tn−μk))]=−πkNnk∑Kk=1πkNnk[−D2σk+∂∂aσk(−12||tn−μk||2σkI)]=−πkNnk∑Kk=1πkNnk[−D2σk+12||tn−μk||2σ2kI]=πkNnk∑Kk=1πkNnk[D2σk−12||tn−μk||2σ2kI]=γnk[Dσk−||tn−μk||2σ2kI]model = nn.NeuralNetwork(
nn.LinearLayer(1, 5), nn.TanH(), nn.LinearLayer(5, 9), nn.Concat([nn.Softmax(), nn.Linear(), nn.Exp()])
)
model.fit(
y[:, None],
x[:, None],
epochs=10000,
batch_size=50,
loss=nn.GaussianNLLLoss(n_components=3),
optimizer=nn.AdamW(learning_rate=0.0001, weight_decay=1e-2),
)
-- Epoch 1 --- Cost: 56.13401004150711 -- Epoch 1001 --- Cost: -12.960075807796274 -- Epoch 2001 --- Cost: -17.888693038278074 -- Epoch 3001 --- Cost: -25.77117031181849 -- Epoch 4001 --- Cost: -42.193536667020155 -- Epoch 5001 --- Cost: -42.774433858274755 -- Epoch 6001 --- Cost: -44.47439846519983 -- Epoch 7001 --- Cost: -55.262623240038444 -- Epoch 8001 --- Cost: -46.64232798438148 -- Epoch 9001 --- Cost: -46.8022878753674
Once the mixture density network has been trained, it can predict the conditional density function of the target data for any given value of the input vector. For many problems we might be interested instead in finding one specific value for the output vector. The most likely value for the output vector, for a given input vector x is given by the maximum of the conditional density p(t|x). Since this density is represented by a mixture model, the location of its global maximum is a problem of non-linear optimization. For applications where speed is important, a good approximation is to take the mean μi(x) fo the largest central value,
maxi{πi(x)σi(x)c}densities = model.predict(y_space)
pi, mu, sigma = np.array_split(densities, 3, axis=1)
predictions = np.take_along_axis(mu, (pi / sigma).argmax(axis=1)[:, None], axis=1)
plt.scatter(y, x, facecolors="none", edgecolors="green")
plt.plot(y_space, predictions, color="red")
plt.xlabel("y")
plt.ylabel("x")
plt.show()
plt.figure(figsize=(10, 4))
plt.subplot(1, 3, 1)
plt.plot(y_space, pi[:, 0], color="blue")
plt.plot(y_space, pi[:, 1], color="red")
plt.plot(y_space, pi[:, 2], color="green")
plt.title("$\pi$")
plt.subplot(1, 3, 2)
plt.plot(y_space, mu[:, 0], color="blue")
plt.plot(y_space, mu[:, 1], color="red")
plt.plot(y_space, mu[:, 2], color="green")
plt.title("$\mu$")
plt.subplot(1, 3, 3)
plt.plot(y_space, sigma[:, 0], color="blue")
plt.plot(y_space, sigma[:, 1], color="red")
plt.plot(y_space, sigma[:, 2], color="green")
plt.title("$\sigma$")
plt.show()
xx, yy = np.meshgrid(x_space.ravel(), y_space.ravel())
prob = pi * np.exp(-0.5 * ((y_space[:, None] - mu) ** 2) / sigma**2) / np.sqrt(2 * np.pi * sigma**2)
plt.contour(xx, yy, prob.sum(axis=-1), levels=25)
plt.scatter(y, x, facecolor="none", edgecolor="b")
plt.show()