Examples

Single input, single output

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)

  1. 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.

  2. Increase the size of the train set and use non aligned points. Plot the result and comment.

  3. 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:

src
  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
  7from dataclasses import dataclass, field
  8from collections.abc import Callable, Sequence
  9
 10from tqdm import tqdm
 11import numpy as np
 12import numpy.typing as npt
 13
 14FLOAT = np.float64
 15type Array2D = npt.NDArray[FLOAT]
 16type Vector = npt.NDArray[FLOAT]
 17type FuncVector = Callable[[Vector], Vector]
 18
 19
 20class IncompatibleDimensions(Exception): ...
 21
 22
 23### vectorized activation and their derivative
 24
 25
 26def relu(vector: Vector) -> Vector:
 27    result: Vector = np.zeros(vector.shape)
 28    is_pos = vector > 0.0
 29    result[is_pos] = vector[is_pos]
 30    return result
 31
 32
 33def relu_derivative(vector: Vector) -> Vector:
 34    result: Vector = np.zeros(vector.shape)
 35    is_pos = vector > 0.0
 36    result[is_pos] = 1.0
 37    return result
 38
 39
 40def logistic(vector: Vector) -> Vector:
 41    return 1.0 / (1 + np.exp(-vector))
 42
 43
 44def logistic_derivative(vector: Vector) -> Vector:
 45    v: Vector = logistic(vector)
 46    return v * (1.0 - v)
 47
 48
 49def tanh(vector: Vector) -> Vector:
 50    return np.tanh(vector)
 51
 52
 53def tanh_derivative(vector: Vector) -> Vector:
 54    return 1.0 - np.tanh(vector) ** 2
 55
 56
 57def linear(vector: Vector) -> Vector:
 58    return vector
 59
 60
 61def linear_derivative(vector: Vector) -> Vector:
 62    return np.ones(vector.shape)
 63
 64
 65def softmax(vector: Vector) -> Vector:
 66    num: Vector = np.exp(vector)
 67    return num / sum(num)
 68
 69
 70def softmax_derivative(vector: Vector) -> Vector:
 71    return vector * (1.0 - vector)
 72
 73
 74_activations_and_derivatives: dict[FuncVector, FuncVector] = {
 75    relu: relu_derivative,
 76    linear: linear_derivative,
 77    logistic: logistic_derivative,
 78    softmax: softmax_derivative,
 79    tanh: tanh_derivative,
 80}
 81
 82
 83@dataclass
 84class Layer:
 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
 98    nb_nodes_left: int
 99    nb_nodes_right: int
100    seed: int | None = None
101
102    def __post_init__(self) -> None:
103        self.init_parameters_random()
104        self.z: Vector = np.zeros(self.nb_nodes_right, dtype=FLOAT)
105        self.a: Vector = np.zeros(self.nb_nodes_right, dtype=FLOAT)
106
107    def init_parameters_random(self) -> None:
108        rng = np.random.default_rng(self.seed)
109        self.weights: Array2D = (
110            rng.random((self.nb_nodes_right, self.nb_nodes_left), dtype=FLOAT) - 0.5
111        )
112        self.biases: Vector = rng.random(self.nb_nodes_right, dtype=FLOAT) - 0.5
113
114
115@dataclass
116class FNN:
117    """A FNN has a sequence of layers. It owns all the hidden layers
118    and the output layer, not the input layer. However, to build
119    the first layer, the number of nodes of the input layer must be known.
120
121    Every layer has an activation, and its derivative can be retrieved in
122    the activation_derivatives dict.
123
124    The three main methods are:
125    - infer: forward execution of the FNN from input to output
126      with the current weights and biases
127    - fit: learn all the weights and biases on a train set
128    - mean_accuracy: assess the accuracy of the estimations
129    """
130
131    nb_nodes_per_layer_including_input: Sequence[int]
132    activations: Sequence[FuncVector]
133    seed: int | None = None
134
135    activation_derivatives: dict[FuncVector, FuncVector] = field(
136        default_factory=_activations_and_derivatives.copy
137    )
138
139    def __post_init__(self) -> None:
140        """creates a sequence of layers."""
141        self.layers: list[Layer] = []
142        self.nb_layers: int = len(self.nb_nodes_per_layer_including_input) - 1
143        if self.nb_layers != len(self.activations):
144            raise IncompatibleDimensions(
145                "The number of activation functions is not compatible with the number of nodes."
146            )
147
148        for ind in range(self.nb_layers):
149            layer = Layer(
150                self.nb_nodes_per_layer_including_input[ind],
151                self.nb_nodes_per_layer_including_input[ind + 1],
152                self.seed + ind if self.seed is not None else None,
153                # to avoid using the same sequence of random numbers
154                # for every layer which could introduce unwanted symetries
155            )
156            self.layers.append(layer)
157
158    def infer(self, input_: Vector) -> Vector:
159        """forward sweep across the FNN,
160        z_right = w . a_left
161        a_right = sigma(z_right)
162
163        changes the attributes z and a of every layer."""
164        if self.bad_input_or_target_size(
165            self.nb_nodes_per_layer_including_input[0], [input_]
166        ):
167            raise IncompatibleDimensions("The size of the input is incompatible.")
168
169        left: Vector = input_
170        for ind_layer, layer in enumerate(self.layers):
171            layer.z = layer.weights.dot(left) + layer.biases
172            layer.a = self.activations[ind_layer](layer.z)
173            left = layer.a
174
175        return left
176
177    def init_parameters_gradient_zero(self) -> tuple[list[Array2D], list[Vector]]:
178        """initialize the arrays for the weight and biases gradients to zero
179        with the correct dimension to be able to compute the mean gradients
180        accross the train set."""
181        biases_all: list[Vector] = []
182        weights_all: list[Array2D] = []
183        # init to zero for cumsum over training examples
184        for ind_layer in range(0, self.nb_layers):
185            layer = self.layers[ind_layer]
186            weights_all.append(
187                np.zeros((layer.nb_nodes_right, layer.nb_nodes_left), dtype=FLOAT)
188            )
189            biases_all.append(np.zeros(layer.nb_nodes_right, dtype=FLOAT))
190        return weights_all, biases_all
191
192    def get_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"""
195        return input_ if ind_layer == 0 else self.layers[ind_layer - 1].a
196
197    def get_layer_activation_activation_derivative(
198        self, ind_layer: int
199    ) -> tuple[Layer, FuncVector, FuncVector]:
200        """helper function, code factorisation"""
201        layer: Layer = self.layers[ind_layer]
202        activation: FuncVector = self.activations[ind_layer]
203        activation_derivative: FuncVector = self.activation_derivatives[activation]
204        return layer, activation, activation_derivative
205
206    def parameter_gradient(
207        self, 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.
210
211        dJ/dz of the last layer is first calculated.
212        The detail of this computation depends on the loss function itself: usually
213        either multi-class or regression. It is performed in the last_layer_dJdz method.
214
215        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 right
217        (which can be obtained by broadcast
218                    a_0 ... a_k ...
219        dJ/dz_0
220        ...
221        dJ/dz_j
222        ...
223        )
224
225        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 right
227
228        c_jk = w_jk s'(z_k) simple broadcasting
229
230        sum_j c_jk  dJ/dz_j is almost a dot product, requires a transpose -> d = c^T . dJ/dz
231
232        dJ/dw_kl = c_k a_l can be obtained by broadcast similar as the one for the last layer
233
234        At the end, the sum of the gradients for every case of the train set is divided by
235        the number of cases of the train set to get the average gradient.
236        """
237        # first store the sum for all training example of gradients
238        # then at the end, the mean gradient
239        gradients_weights: list[Array2D]
240        gradients_biases: list[Vector]
241        gradients_weights, gradients_biases = self.init_parameters_gradient_zero()
242
243        for input_, target in zip(inputs, targets):
244            # update all a and z for given training example
245            self.infer(input_)
246
247            ## last layer
248            ind_layer = self.nb_layers - 1
249            layer, activation, activation_derivative = (
250                self.get_layer_activation_activation_derivative(ind_layer)
251            )
252            left: Vector = self.get_left_a_or_input(ind_layer, input_)
253
254            dJdz: Vector = self.last_layer_dJdz(
255                layer, activation, activation_derivative, target
256            )
257
258            gradients_biases[ind_layer] += dJdz
259            dJdw: Array2D = dJdz[:, np.newaxis] * left  # broadcast
260            gradients_weights[ind_layer] += dJdw
261
262            dJdz_prev = dJdz
263
264            ## layers from one before last included, to first
265            for ind_layer in range(self.nb_layers - 2, -1, -1):
266                layer, _, activation_derivative = (
267                    self.get_layer_activation_activation_derivative(ind_layer)
268                )
269                left: Vector = self.get_left_a_or_input(ind_layer, input_)
270                dJdz = (
271                    self.layers[ind_layer + 1].weights
272                    * activation_derivative(layer.z)  # broadcast
273                ).T.dot(dJdz_prev)
274
275                gradients_biases[ind_layer] += dJdz
276                dJdw = dJdz[:, np.newaxis] * left  # broadcast
277                gradients_weights[ind_layer] += dJdw
278
279                dJdz_prev = dJdz
280
281        # from sum to average
282        nb_training = len(inputs)
283        for ind_layer in range(0, self.nb_layers):
284            gradients_weights[ind_layer] /= nb_training
285            gradients_biases[ind_layer] /= nb_training
286
287        return gradients_weights, gradients_biases
288
289    @staticmethod
290    def last_layer_dJdz(
291        layer: Layer,
292        activation: FuncVector,
293        activation_derivative: FuncVector,
294        target: Vector,
295    ) -> Vector:
296        """Depending on the last layer activation, one assumes the loss is:
297        + multi-class cross entropy for softmax
298        + mean square error for linear"""
299        error: Vector = layer.a - target
300        if activation == softmax:  # multi-class cross entropy gradient
301            dJdz: Vector = error * target
302        elif activation == linear:  # regression mean square gradient
303            dJdz: Vector = error
304        else:  # regression mean square gradient, other activations
305            dJdz: Vector = error * activation_derivative(layer.z)
306        return dJdz
307
308    def fit(
309        self,
310        inputs: Sequence[Vector],
311        targets: Sequence[Vector],
312        learning_rate: float = 0.1,
313        nb_iter_gradient: int = 1_000,
314        regularization: float = 0.0,
315        rel_convergence: float = 1e-3,
316        display_interval: int | None = 100,
317    ) -> None:
318        """Gradient descent algorithm on both the weights and biases, no stopping criteria,
319        no stochastic pooling.
320
321        The learning_rate is for the gradient from the train set.
322
323        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        """
326        if self.bad_input_or_target_size(
327            self.nb_nodes_per_layer_including_input[0], inputs
328        ):
329            raise IncompatibleDimensions("The size of the input is incompatible.")
330        if self.bad_input_or_target_size(
331            self.nb_nodes_per_layer_including_input[-1], targets
332        ):
333            raise IncompatibleDimensions("The size of the target is incompatible.")
334
335        first_gradient_norm = 0.0
336
337        for ind_iter in tqdm(range(nb_iter_gradient), desc="fnn fit"):
338            gradients_weights, gradients_biases = self.parameter_gradient(
339                inputs, targets
340            )
341            gradient_norm = 0.0
342            for ind_layer in range(self.nb_layers):
343                self.apply_regularization(regularization)
344                self.layers[ind_layer].weights -= (
345                    learning_rate * gradients_weights[ind_layer]
346                )
347                self.layers[ind_layer].biases -= (
348                    learning_rate * gradients_biases[ind_layer]
349                )
350                gradient_norm += np.linalg.norm(
351                    gradients_weights[ind_layer]
352                    + regularization * self.layers[ind_layer].weights,
353                    "fro",
354                )
355
356            if ind_iter == 0:
357                first_gradient_norm = gradient_norm
358
359            if display_interval is not None and ind_iter % display_interval == 0:
360                tqdm.write(str(gradient_norm / first_gradient_norm))
361
362            if gradient_norm / first_gradient_norm < rel_convergence:
363                break
364
365    def apply_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_current
368        w_new *= 1-lambda_r"""
369        for ind_layer in range(self.nb_layers):
370            self.layers[ind_layer].weights *= 1.0 - regularization
371            self.layers[ind_layer].biases *= 1.0 - regularization
372
373    @staticmethod
374    def bad_input_or_target_size(expected_size: int, vectors: Sequence[Vector]) -> bool:
375        """check that every vector in a sequence is of the expected size"""
376        return any([vector.size != expected_size for vector in vectors])
377
378    def jacobian(self, input_: Vector) -> Array2D:
379        self.infer(input_)
380        jacobian: Array2D = np.eye(self.nb_nodes_per_layer_including_input[0])
381        for ind_layer in range(0, self.nb_layers):
382            layer, _, activation_derivative = (
383                self.get_layer_activation_activation_derivative(ind_layer)
384            )
385            jacobian = (
386                layer.weights.dot(jacobian).T
387                * activation_derivative(layer.z)  # broadcast
388            ).T
389
390        return jacobian
391
392    def mean_accuracy(
393        self,
394        inputs: list[Vector],
395        targets: list[Vector],
396        metric_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."""
401        residual: float = 0.0
402        for input_, target in zip(inputs, targets):
403            prediction: Vector = self.infer(input_)
404            residual += metric_single_example(prediction, target)
405        return residual / len(inputs)
406
407
408def mean_square_loss_single(prediction: Vector, target: Vector) -> float:
409    return float(np.linalg.norm(prediction - target) ** 2 / target.size)
410
411
412def is_correct_class_single(prediction: Vector, target: Vector) -> bool:
413    """is the most probable estimated class the correct one?"""
414    return np.argmax(prediction) == np.argmax(target)
Correction
  1. 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.

    This could have been written as a test.

src
 1def R_to_R_linear_two_points(
 2    learning_rate: float = 0.1,
 3    nb_iter_gradient: int = 1_000,
 4    regularization: float = 0.0,
 5    rel_convergence: float = 1e-6,
 6    display_interval: int | None = 100,
 7) -> None:
 8    fnn = FNN([1, 1], [linear])
 9    inputs: list[Vector] = [np.array([0.0]), np.array([1.0])]
10    targets: list[Vector] = [np.array([1.0]), np.array([3.0])]
11    fnn.fit(
12        inputs,
13        targets,
14        learning_rate=learning_rate,
15        nb_iter_gradient=nb_iter_gradient,
16        regularization=regularization,
17        rel_convergence=rel_convergence,
18        display_interval=display_interval,
19    )
20    middle: Vector = np.array([0.5])
21    inferred: Vector = fnn.infer(middle)
22    print("R to R, no hidden layer, linear activation")
23    print(f"inputs: {inputs}")
24    print(f"targets: {targets}")
25    print(f"weights: {fnn.layers[0].weights}")
26    print(f"biases: {fnn.layers[0].biases}")
27    print(f"inference:  in={middle}, out={inferred}")
28    print("-" * 30)
R to R, no hidden layer, linear activation
inputs: [array([0.]), array([1.])]
targets: [array([1.]), array([3.])]
weights: [[1.99999114]]
biases: [1.00000547]
inference:  in=[0.5], out=[2.00000105]
  1. 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.

src
 1def R_to_R_linear_with_noise(
 2    noise_amplitude: float = 0.4,
 3    seed: int = 0,
 4    path: str | None = None,
 5    learning_rate: float = 0.1,
 6    nb_iter_gradient: int = 1_000,
 7    regularization: float = 0.0,
 8    rel_convergence: float = 1e-12,
 9    display_interval: int | None = 100,
10) -> None:
11    fnn = FNN([1, 1], [linear])
12    left_bound, right_bound = 0.0, 5.0
13    nb_points: int = 11
14    abscissas = np.linspace(
15        left_bound, right_bound, num=nb_points, endpoint=True, dtype=FLOAT
16    )
17    inputs: list[Vector] = [np.array([abscissa]) for abscissa in abscissas]
18    slope: float = 2.0
19    intercept: float = 1.0
20    rng = np.random.default_rng(seed=seed)
21
22    def generate_y(abscissa):
23        return (
24            intercept
25            + slope * abscissa
26            + rng.uniform(-noise_amplitude, noise_amplitude)
27        )
28
29    targets: list[Vector] = [np.array([generate_y(abscissa)]) for abscissa in abscissas]
30    fnn.fit(
31        inputs,
32        targets,
33        learning_rate=learning_rate,
34        nb_iter_gradient=nb_iter_gradient,
35        regularization=regularization,
36        rel_convergence=rel_convergence,
37        display_interval=display_interval,
38    )
39
40    matrix_for_least_squares = np.vstack([abscissas, np.ones(nb_points)]).T
41    least_squares_slope, least_sqares_intercept = np.linalg.lstsq(
42        matrix_for_least_squares, np.array(targets).squeeze()
43    )[0]
44
45    print("R to R, no hidden layer, linear activation")
46    print(f"base slope: {slope}")
47    print(f"base intercept: {intercept}")
48    print(f"inputs: {inputs}")
49    print(f"targets: {targets}")
50    print(f"weights: {fnn.layers[0].weights}")
51    print(f"biases: {fnn.layers[0].biases}")
52    print(f"least squares slope: {least_squares_slope}")
53    print(f"least squares intercept: {least_sqares_intercept}")
54    print("-" * 30)
55
56    abscissas_plot = np.linspace(
57        left_bound, right_bound, num=21, endpoint=True, dtype=FLOAT
58    )
59    ordinates_plot = []
60    for abscissa in abscissas_plot:
61        infered_ordinate = fnn.infer(np.array([abscissa]))
62        ordinates_plot.append(infered_ordinate[0])
63
64    fig, ax = plt.subplots()
65    for abscissa, target in zip(inputs, targets):
66        ax.plot(abscissa[0], target[0], "ok")
67    ax.plot(abscissas_plot, ordinates_plot, "-+r")
68
69    ax.set_xlabel("x")
70    ax.set_ylabel("y")
71    ax.set_title("strictly linear model")
72
73    if path is not None:
74        fig.savefig(path)
base slope: 2.0
base intercept: 1.0
inputs: [array([0.]), array([0.5]), array([1.]), array([1.5]), array([2.]), array([2.5]), array([3.]), array([3.5]), array([4.]), array([4.5]), array([5.])]
targets: [array([1.10956935]), array([1.81582937]), array([2.63277882]), array([3.61322211]), array([5.25061619]), array([6.33020446]), array([7.08530862]), array([8.18359725]), array([9.03489999]), array([10.34805794]), array([11.25268284])]
weights: [[2.09138705]]
biases: [0.83123845]
least squares slope: 2.091387054024167
least squares intercept: 0.8312384509871413
fit_linear

FNN fit and inference: linear plus noise

  1. With the proposed non-linear function and some noise, one hidden layer with two nodes with logistic activation.

src
 1def R_to_R_nonlinear_with_noise(
 2    nb_nodes_per_layer_including_input: Sequence[int],
 3    activations: Sequence[FuncVector],
 4    func: Callable,
 5    noise_amplitude: float = 3.0,
 6    seed: int = 0,
 7    path: str | None = None,
 8    learning_rate: float = 0.01,
 9    nb_iter_gradient: int = 100_000,
10    regularization: float = 0.0,
11    rel_convergence: float = 1e-3,
12    display_interval: int | None = 100,
13):
14    fnn = FNN(nb_nodes_per_layer_including_input, activations)
15    left_bound, right_bound = 0.0, 5.0
16    nb_points: int = 11
17    abscissas = np.linspace(
18        left_bound, right_bound, num=nb_points, endpoint=True, dtype=FLOAT
19    )
20    inputs: list[Vector] = [np.array([abscissa]) for abscissa in abscissas]
21
22    rng = np.random.default_rng(seed=seed)
23
24    def generate_y(abscissa):
25        return func(abscissa) + rng.uniform(-noise_amplitude, noise_amplitude)
26
27    targets: list[Vector] = [np.array([generate_y(abscissa)]) for abscissa in abscissas]
28
29    fnn.fit(
30        inputs,
31        targets,
32        learning_rate=learning_rate,
33        nb_iter_gradient=nb_iter_gradient,
34        regularization=regularization,
35        rel_convergence=rel_convergence,
36        display_interval=display_interval,
37    )
38
39    left_extra, right_extra = -2.0, 3.0
40    abscissas_plot = np.linspace(
41        left_bound + left_extra,
42        right_bound + right_extra,
43        num=201,
44        endpoint=True,
45        dtype=FLOAT,
46    )
47    ordinates_plot = []
48    for abscissa in abscissas_plot:
49        infered_ordinate = fnn.infer(np.array([abscissa]))
50        ordinates_plot.append(infered_ordinate[0])
51
52    # breakpoint()
53
54    fig, ax = plt.subplots()
55    for abscissa, target in zip(inputs, targets):
56        ax.plot(abscissa[0], target[0], "ok")
57    ax.plot(abscissas_plot, ordinates_plot, "-+r")
58
59    ax.set_xlabel("x")
60    ax.set_ylabel("y")
61    ax.set_title(
62        (
63            f"with hidden layer ({fnn.nb_nodes_per_layer_including_input} nodes) "
64            f"with {fnn.activations[0].__name__} activation model"
65        )
66    )
67    if path is not None:
68        fig.savefig(path)

Note:

  • 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

fit_nonlinear_2nodes

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:

fit_nonlinear_6nodes

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:

fit_nonlinear_6nodes

FNN fit and inference: non-linear plus noise, several hidden layers with 3 nodes

Deeper nets are usually harder to train.

Double input, double output

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.

fit_nonlinear_6nodes

FNN fit and inference: 2d non-linear, several hidden layers