In this section, the goal is to use the FNN on small examples
in order to have a feeling for the expected results.
Hint
On some systems, even if the matplotlib library is installed,
a window manager backend is missing. It leads to an error when plt.show().
The solution is to add the pyqt6 dependency to the project.
Exercise (intermediate)
Create the smallest and simplest possible FNN and learn the parameters. Comment.
(no hidden layer, linear activation, only two entries in the train set).
Make an inference. Comment.
Increase the size of the train set and use non aligned points. Plot the result and comment.
Build a train set around a known non linear function (for example \(f(x)=x^2+5\)).
Use one hidden layer and with a logistic activation. Plot and comment.
FNN code
The FNN implementation used in the exercises below is:
1"""Forward Neural Network implementation from scratch using numpy. 2Three sections: 3- the vectorized activation functions and their derivative 4- the Layer class to be used in the FNN class 5- the FNN class""" 6 7fromdataclassesimportdataclass,field 8fromcollections.abcimportCallable,Sequence 9 10fromtqdmimporttqdm 11importnumpyasnp 12importnumpy.typingasnpt 13 14FLOAT=np.float64 15typeArray2D=npt.NDArray[FLOAT] 16typeVector=npt.NDArray[FLOAT] 17typeFuncVector=Callable[[Vector],Vector] 18 19 20classIncompatibleDimensions(Exception):... 21 22 23### vectorized activation and their derivative 24 25 26defrelu(vector:Vector)->Vector: 27result:Vector=np.zeros(vector.shape) 28is_pos=vector>0.0 29result[is_pos]=vector[is_pos] 30returnresult 31 32 33defrelu_derivative(vector:Vector)->Vector: 34result:Vector=np.zeros(vector.shape) 35is_pos=vector>0.0 36result[is_pos]=1.0 37returnresult 38 39 40deflogistic(vector:Vector)->Vector: 41return1.0/(1+np.exp(-vector)) 42 43 44deflogistic_derivative(vector:Vector)->Vector: 45v:Vector=logistic(vector) 46returnv*(1.0-v) 47 48 49deftanh(vector:Vector)->Vector: 50returnnp.tanh(vector) 51 52 53deftanh_derivative(vector:Vector)->Vector: 54return1.0-np.tanh(vector)**2 55 56 57deflinear(vector:Vector)->Vector: 58returnvector 59 60 61deflinear_derivative(vector:Vector)->Vector: 62returnnp.ones(vector.shape) 63 64 65defsoftmax(vector:Vector)->Vector: 66num:Vector=np.exp(vector) 67returnnum/sum(num) 68 69 70defsoftmax_derivative(vector:Vector)->Vector: 71returnvector*(1.0-vector) 72 73 74_activations_and_derivatives:dict[FuncVector,FuncVector]={ 75relu:relu_derivative, 76linear:linear_derivative, 77logistic:logistic_derivative, 78softmax:softmax_derivative, 79tanh:tanh_derivative, 80} 81 82 83@dataclass 84classLayer: 85"""A layer has a certain number of nodes on the left, 86 and another on the right. Every node on the left is 87 fully connected to every node on the right. 88 89 A layer has the ownership of the nodes on the right only. 90 91 The nodes on the right have two states: z then a 92 - before activation z_i = sum_k w_ik a_k_previous_layer 93 - affter activation a_i = sigma(z_i) 94 95 The weights and biases are initialized randomly, 96 potentially using a seed for repeatability.""" 97 98nb_nodes_left:int 99nb_nodes_right:int100seed:int|None=None101102def__post_init__(self)->None:103self.init_parameters_random()104self.z:Vector=np.zeros(self.nb_nodes_right,dtype=FLOAT)105self.a:Vector=np.zeros(self.nb_nodes_right,dtype=FLOAT)106107definit_parameters_random(self)->None:108rng=np.random.default_rng(self.seed)109self.weights:Array2D=(110rng.random((self.nb_nodes_right,self.nb_nodes_left),dtype=FLOAT)-0.5111)112self.biases:Vector=rng.random(self.nb_nodes_right,dtype=FLOAT)-0.5113114115@dataclass116classFNN:117"""A FNN has a sequence of layers. It owns all the hidden layers118 and the output layer, not the input layer. However, to build119 the first layer, the number of nodes of the input layer must be known.120121 Every layer has an activation, and its derivative can be retrieved in122 the activation_derivatives dict.123124 The three main methods are:125 - infer: forward execution of the FNN from input to output126 with the current weights and biases127 - fit: learn all the weights and biases on a train set128 - mean_accuracy: assess the accuracy of the estimations129 """130131nb_nodes_per_layer_including_input:Sequence[int]132activations:Sequence[FuncVector]133seed:int|None=None134135activation_derivatives:dict[FuncVector,FuncVector]=field(136default_factory=_activations_and_derivatives.copy137)138139def__post_init__(self)->None:140"""creates a sequence of layers."""141self.layers:list[Layer]=[]142self.nb_layers:int=len(self.nb_nodes_per_layer_including_input)-1143ifself.nb_layers!=len(self.activations):144raiseIncompatibleDimensions(145"The number of activation functions is not compatible with the number of nodes."146)147148forindinrange(self.nb_layers):149layer=Layer(150self.nb_nodes_per_layer_including_input[ind],151self.nb_nodes_per_layer_including_input[ind+1],152self.seed+indifself.seedisnotNoneelseNone,153# to avoid using the same sequence of random numbers154# for every layer which could introduce unwanted symetries155)156self.layers.append(layer)157158definfer(self,input_:Vector)->Vector:159"""forward sweep across the FNN,160 z_right = w . a_left161 a_right = sigma(z_right)162163 changes the attributes z and a of every layer."""164ifself.bad_input_or_target_size(165self.nb_nodes_per_layer_including_input[0],[input_]166):167raiseIncompatibleDimensions("The size of the input is incompatible.")168169left:Vector=input_170forind_layer,layerinenumerate(self.layers):171layer.z=layer.weights.dot(left)+layer.biases172layer.a=self.activations[ind_layer](layer.z)173left=layer.a174175returnleft176177definit_parameters_gradient_zero(self)->tuple[list[Array2D],list[Vector]]:178"""initialize the arrays for the weight and biases gradients to zero179 with the correct dimension to be able to compute the mean gradients180 accross the train set."""181biases_all:list[Vector]=[]182weights_all:list[Array2D]=[]183# init to zero for cumsum over training examples184forind_layerinrange(0,self.nb_layers):185layer=self.layers[ind_layer]186weights_all.append(187np.zeros((layer.nb_nodes_right,layer.nb_nodes_left),dtype=FLOAT)188)189biases_all.append(np.zeros(layer.nb_nodes_right,dtype=FLOAT))190returnweights_all,biases_all191192defget_left_a_or_input(self,ind_layer:int,input_:Vector)->Vector:193"""helper function, the FNN does not own the input,194 but uses it when left nodes of the first layer are required for an operation"""195returninput_ifind_layer==0elseself.layers[ind_layer-1].a196197defget_layer_activation_activation_derivative(198self,ind_layer:int199)->tuple[Layer,FuncVector,FuncVector]:200"""helper function, code factorisation"""201layer:Layer=self.layers[ind_layer]202activation:FuncVector=self.activations[ind_layer]203activation_derivative:FuncVector=self.activation_derivatives[activation]204returnlayer,activation,activation_derivative205206defparameter_gradient(207self,inputs:Sequence[Vector],targets:Sequence[Vector]208)->tuple[list[Array2D],list[Vector]]:209"""The loss gradient with respect to the parameters: either weights or biases.210211 dJ/dz of the last layer is first calculated.212 The detail of this computation depends on the loss function itself: usually213 either multi-class or regression. It is performed in the last_layer_dJdz method.214215 The dJdz of the last layer allows to obtain the dJdb and dJdw of the last layer:216 dJ/dw_jk = dJ/dz_j a_k with index k on the left and j on the right217 (which can be obtained by broadcast218 a_0 ... a_k ...219 dJ/dz_0220 ...221 dJ/dz_j222 ...223 )224225 Then, using back-propagation, every layer can be obtained from one before last to first:226 dJ/dw_kl = [ sum_j [w_jk s'(z_k) ] dJ/dz_j ] a_l with index k on the left and j on the right227228 c_jk = w_jk s'(z_k) simple broadcasting229230 sum_j c_jk dJ/dz_j is almost a dot product, requires a transpose -> d = c^T . dJ/dz231232 dJ/dw_kl = c_k a_l can be obtained by broadcast similar as the one for the last layer233234 At the end, the sum of the gradients for every case of the train set is divided by235 the number of cases of the train set to get the average gradient.236 """237# first store the sum for all training example of gradients238# then at the end, the mean gradient239gradients_weights:list[Array2D]240gradients_biases:list[Vector]241gradients_weights,gradients_biases=self.init_parameters_gradient_zero()242243forinput_,targetinzip(inputs,targets):244# update all a and z for given training example245self.infer(input_)246247## last layer248ind_layer=self.nb_layers-1249layer,activation,activation_derivative=(250self.get_layer_activation_activation_derivative(ind_layer)251)252left:Vector=self.get_left_a_or_input(ind_layer,input_)253254dJdz:Vector=self.last_layer_dJdz(255layer,activation,activation_derivative,target256)257258gradients_biases[ind_layer]+=dJdz259dJdw:Array2D=dJdz[:,np.newaxis]*left# broadcast260gradients_weights[ind_layer]+=dJdw261262dJdz_prev=dJdz263264## layers from one before last included, to first265forind_layerinrange(self.nb_layers-2,-1,-1):266layer,_,activation_derivative=(267self.get_layer_activation_activation_derivative(ind_layer)268)269left:Vector=self.get_left_a_or_input(ind_layer,input_)270dJdz=(271self.layers[ind_layer+1].weights272*activation_derivative(layer.z)# broadcast273).T.dot(dJdz_prev)274275gradients_biases[ind_layer]+=dJdz276dJdw=dJdz[:,np.newaxis]*left# broadcast277gradients_weights[ind_layer]+=dJdw278279dJdz_prev=dJdz280281# from sum to average282nb_training=len(inputs)283forind_layerinrange(0,self.nb_layers):284gradients_weights[ind_layer]/=nb_training285gradients_biases[ind_layer]/=nb_training286287returngradients_weights,gradients_biases288289@staticmethod290deflast_layer_dJdz(291layer:Layer,292activation:FuncVector,293activation_derivative:FuncVector,294target:Vector,295)->Vector:296"""Depending on the last layer activation, one assumes the loss is:297 + multi-class cross entropy for softmax298 + mean square error for linear"""299error:Vector=layer.a-target300ifactivation==softmax:# multi-class cross entropy gradient301dJdz:Vector=error*target302elifactivation==linear:# regression mean square gradient303dJdz:Vector=error304else:# regression mean square gradient, other activations305dJdz:Vector=error*activation_derivative(layer.z)306returndJdz307308deffit(309self,310inputs:Sequence[Vector],311targets:Sequence[Vector],312learning_rate:float=0.1,313nb_iter_gradient:int=1_000,314regularization:float=0.0,315rel_convergence:float=1e-3,316display_interval:int|None=100,317)->None:318"""Gradient descent algorithm on both the weights and biases, no stopping criteria,319 no stochastic pooling.320321 The learning_rate is for the gradient from the train set.322323 The regularization is the lambda_r of the basic regularization:324 J_r = lambda_r/2 (sum_all w^2 + sum_all b^2)325 """326ifself.bad_input_or_target_size(327self.nb_nodes_per_layer_including_input[0],inputs328):329raiseIncompatibleDimensions("The size of the input is incompatible.")330ifself.bad_input_or_target_size(331self.nb_nodes_per_layer_including_input[-1],targets332):333raiseIncompatibleDimensions("The size of the target is incompatible.")334335first_gradient_norm=0.0336337forind_iterintqdm(range(nb_iter_gradient),desc="fnn fit"):338gradients_weights,gradients_biases=self.parameter_gradient(339inputs,targets340)341gradient_norm=0.0342forind_layerinrange(self.nb_layers):343self.apply_regularization(regularization)344self.layers[ind_layer].weights-=(345learning_rate*gradients_weights[ind_layer]346)347self.layers[ind_layer].biases-=(348learning_rate*gradients_biases[ind_layer]349)350gradient_norm+=np.linalg.norm(351gradients_weights[ind_layer]352+regularization*self.layers[ind_layer].weights,353"fro",354)355356ifind_iter==0:357first_gradient_norm=gradient_norm358359ifdisplay_intervalisnotNoneandind_iter%display_interval==0:360tqdm.write(str(gradient_norm/first_gradient_norm))361362ifgradient_norm/first_gradient_norm<rel_convergence:363break364365defapply_regularization(self,regularization:float):366"""The gradient descent for the regularization used is:367 w_new = w_current - lambda_r w_current = (1-lambda_r) w_current368 w_new *= 1-lambda_r"""369forind_layerinrange(self.nb_layers):370self.layers[ind_layer].weights*=1.0-regularization371self.layers[ind_layer].biases*=1.0-regularization372373@staticmethod374defbad_input_or_target_size(expected_size:int,vectors:Sequence[Vector])->bool:375"""check that every vector in a sequence is of the expected size"""376returnany([vector.size!=expected_sizeforvectorinvectors])377378defjacobian(self,input_:Vector)->Array2D:379self.infer(input_)380jacobian:Array2D=np.eye(self.nb_nodes_per_layer_including_input[0])381forind_layerinrange(0,self.nb_layers):382layer,_,activation_derivative=(383self.get_layer_activation_activation_derivative(ind_layer)384)385jacobian=(386layer.weights.dot(jacobian).T387*activation_derivative(layer.z)# broadcast388).T389390returnjacobian391392defmean_accuracy(393self,394inputs:list[Vector],395targets:list[Vector],396metric_single_example:Callable[[Vector,Vector],float|bool],397)->float:398"""with a given metric callable on every pair (prediction, target)399 which gives either a measure of their distance or a measure of exact match,400 this method computes a mean accuracy of the FNN over a train or test set."""401residual:float=0.0402forinput_,targetinzip(inputs,targets):403prediction:Vector=self.infer(input_)404residual+=metric_single_example(prediction,target)405returnresidual/len(inputs)406407408defmean_square_loss_single(prediction:Vector,target:Vector)->float:409returnfloat(np.linalg.norm(prediction-target)**2/target.size)410411412defis_correct_class_single(prediction:Vector,target:Vector)->bool:413"""is the most probable estimated class the correct one?"""414returnnp.argmax(prediction)==np.argmax(target)
Correction
Careful, even if each input and target are of length 1, they still must be Vector.
Two entries (meaning two inputs and two targets), means that the parameters represent
exactly the slope and intercept of the straight line going the two points.
1defR_to_R_linear_two_points( 2learning_rate:float=0.1, 3nb_iter_gradient:int=1_000, 4regularization:float=0.0, 5rel_convergence:float=1e-6, 6display_interval:int|None=100, 7)->None: 8fnn=FNN([1,1],[linear]) 9inputs:list[Vector]=[np.array([0.0]),np.array([1.0])]10targets:list[Vector]=[np.array([1.0]),np.array([3.0])]11fnn.fit(12inputs,13targets,14learning_rate=learning_rate,15nb_iter_gradient=nb_iter_gradient,16regularization=regularization,17rel_convergence=rel_convergence,18display_interval=display_interval,19)20middle:Vector=np.array([0.5])21inferred:Vector=fnn.infer(middle)22print("R to R, no hidden layer, linear activation")23print(f"inputs: {inputs}")24print(f"targets: {targets}")25print(f"weights: {fnn.layers[0].weights}")26print(f"biases: {fnn.layers[0].biases}")27print(f"inference: in={middle}, out={inferred}")28print("-"*30)
R to R, no hidden layer, linear activationinputs: [array([0.]), array([1.])]targets: [array([1.]), array([3.])]weights: [[1.99999114]]biases: [1.00000547]inference: in=[0.5], out=[2.00000105]
Using a larger train set and building it around a knwown slope and intercept adding a noise,
one can check that the obtained model is in fact the least square best stright line.
the two added logistic function are clearly visible
extrapolation outside the range of training data will not give very satisfying predictions when one keeps in mind the non-linear function
which has been used to generate this example
FNN fit and inference: non-linear plus noise, 1 hidden layer with 2 nodes¶
If the number of nodes in the hidden layer is increased:
FNN fit and inference: non-linear plus noise, 1 hidden layer with 6 nodes¶
Note that even if the inference sticks much closely to the training data, it does not mean that this inference is “better” than the previous one.
If one recalls the function with has been used to generate the example, it means that the extra nodes here have mostly learned the “noise”.
It will not generalize well one a test set with a new random noise.
One may achieve similar result with other activation function (below \(\text{atanh}\)) and with deeper and narrower network:
FNN fit and inference: non-linear plus noise, several hidden layers with 3 nodes¶
The goal is now to illustrate the FNN for an higher dimension problem: \(\mathbb{R}^2 \to \mathbb{R}^2\).
The following figure shows in black a vector field:
\[\begin{split}\begin{cases}
r &= \sqrt{x^2 + y^2} \\
\theta &= \arctan \left( \cfrac{y}{x} \right) \\
u(r, \theta) &= r * \cos(\theta^2) \\
v(r, \theta) &= r * \sin(\theta)
\end{cases}\end{split}\]
In red, the inferred values at the same locations fit pretty well.
The network has two 8 nodes hidden layer.
FNN fit and inference: 2d non-linear, several hidden layers¶