Symbolic Neural Networks
A SymbolicNeuralNetwork pairs a model with symbolic stand-ins for its parameters and its input. Everything else in this package is built from expressions formed with those two.
using SymbolicNeuralNetworks
using AbstractNeuralNetworks: Chain, Dense, params
c = Chain(Dense(2, 3, tanh), Dense(3, 1, tanh))
snn = SymbolicNeuralNetwork(c)
SymbolicNeuralNetwork with
Architecture = AbstractNeuralNetworks.UnknownArchitecture()
Model = AbstractNeuralNetworks.Chain{Tuple{AbstractNeuralNetworks.Dense{2, 3, true, AbstractNeuralNetworks.TanhActivation}, AbstractNeuralNetworks.Dense{3, 1, true, AbstractNeuralNetworks.TanhActivation}}}((AbstractNeuralNetworks.Dense{2, 3, true, AbstractNeuralNetworks.TanhActivation}(AbstractNeuralNetworks.TanhActivation()), AbstractNeuralNetworks.Dense{3, 1, true, AbstractNeuralNetworks.TanhActivation}(AbstractNeuralNetworks.TanhActivation())))
Symbolic Params = NetworkParameters((L1 = (W = Symbolics.Num[W_1₁ˏ₁ W_1₁ˏ₂; W_1₂ˏ₁ W_1₂ˏ₂; W_1₃ˏ₁ W_1₃ˏ₂], b = Symbolics.Num[W_2₁, W_2₂, W_2₃]), L2 = (W = Symbolics.Num[W_3₁ˏ₁ W_3₁ˏ₂ W_3₁ˏ₃], b = Symbolics.Num[W_4₁])))Any of the following can be used to construct one — a model, an architecture, a single layer, or an AbstractNeuralNetworks.NeuralNetwork whose numeric parameters are only used for their shapes:
SymbolicNeuralNetwork(c) # a Chain
SymbolicNeuralNetwork(Dense(2, 3, tanh)) # a single layer, wrapped in a Chain
SymbolicNeuralNetwork(architecture) # an Architecture
SymbolicNeuralNetwork(architecture, model)
SymbolicNeuralNetwork(NeuralNetwork(c))The symbolic input and parameters
The input is a vector of symbolic variables, one per input dimension:
snn.input\[ \begin{equation} \left[ \begin{array}{c} \mathtt{x{_1}} \\ \mathtt{x{_2}} \\ \end{array} \right] \end{equation} \]
The parameters have the same nesting as the numeric ones — one entry per layer, each holding the weight matrix and the bias vector — with every leaf replaced by symbolic variables of the same shape:
params(snn).L1.W\[ \begin{equation} \left[ \begin{array}{cc} \mathtt{W\_1{{_1}}ˏ{_1}} & \mathtt{W\_1{{_1}}ˏ{_2}} \\ \mathtt{W\_1{{_2}}ˏ{_1}} & \mathtt{W\_1{{_2}}ˏ{_2}} \\ \mathtt{W\_1{{_3}}ˏ{_1}} & \mathtt{W\_1{{_3}}ˏ{_2}} \\ \end{array} \right] \end{equation} \]
They are numbered in the order in which they occur in the parameter set, so W_1 is the weight matrix of the first layer, W_2 its bias, W_3 the weight matrix of the second layer, and so on. This is done by symbolic_variables, which works on any nesting of NamedTuples and NetworkParameters.
params(snn).L1.W is a Matrix{Num}, not a Symbolics.Arr. The difference matters: Symbolics cannot differentiate with respect to an entry of a Symbolics.Arr without scalarising it first, and Symbolics.build_function cannot generate code for an expression that still contains one. Using scalar variables throughout avoids both problems, at the cost of expressions that are fully expanded — which is why this package is meant for small networks.
Building expressions
Applying the model to the symbolic input and parameters gives the symbolic output:
soutput = c(snn.input, params(snn))\[ \begin{equation} \left[ \begin{array}{c} \tanh\left( \mathtt{W\_4_1} + \mathtt{W\_3_{1}ˏ_1} ~ \tanh\left( \mathtt{W\_2_1} + \mathtt{W\_1_{1}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{1}ˏ_2} ~ \mathtt{x_2} \right) + \mathtt{W\_3_{1}ˏ_2} ~ \tanh\left( \mathtt{W\_2_2} + \mathtt{W\_1_{2}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{2}ˏ_2} ~ \mathtt{x_2} \right) + \mathtt{W\_3_{1}ˏ_3} ~ \tanh\left( \mathtt{W\_2_3} + \mathtt{W\_1_{3}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{3}ˏ_2} ~ \mathtt{x_2} \right) \right) \\ \end{array} \right] \end{equation} \]
From there, ordinary Julia code builds whatever expression is wanted:
using LinearAlgebra: norm
norm(soutput) ^ 2\[ \begin{equation} \left|\tanh\left( \mathtt{W\_4_1} + \mathtt{W\_3_{1}ˏ_1} ~ \tanh\left( \mathtt{W\_2_1} + \mathtt{W\_1_{1}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{1}ˏ_2} ~ \mathtt{x_2} \right) + \mathtt{W\_3_{1}ˏ_2} ~ \tanh\left( \mathtt{W\_2_2} + \mathtt{W\_1_{2}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{2}ˏ_2} ~ \mathtt{x_2} \right) + \mathtt{W\_3_{1}ˏ_3} ~ \tanh\left( \mathtt{W\_2_3} + \mathtt{W\_1_{3}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{3}ˏ_2} ~ \mathtt{x_2} \right) \right)\right|^{2} \end{equation} \]
Reductions such as sum and norm work directly, because the output is an ordinary array of scalar expressions.
Expressions may also involve variables of your own — a target output, for instance, which is what a loss function needs:
using Symbolics
starget = Symbolics.variables(:y, 1:1)
(soutput - starget) .^ 2\[ \begin{equation} \left[ \begin{array}{c} \left( \tanh\left( \mathtt{W\_4_1} + \mathtt{W\_3_{1}ˏ_1} ~ \tanh\left( \mathtt{W\_2_1} + \mathtt{W\_1_{1}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{1}ˏ_2} ~ \mathtt{x_2} \right) + \mathtt{W\_3_{1}ˏ_2} ~ \tanh\left( \mathtt{W\_2_2} + \mathtt{W\_1_{2}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{2}ˏ_2} ~ \mathtt{x_2} \right) + \mathtt{W\_3_{1}ˏ_3} ~ \tanh\left( \mathtt{W\_2_3} + \mathtt{W\_1_{3}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{3}ˏ_2} ~ \mathtt{x_2} \right) \right) - \mathtt{y_1} \right)^{2} \\ \end{array} \right] \end{equation} \]
Any expression built this way can be turned into a function with build_nn_function; see Building Functions.
Printing expressions
Symbolic expressions get long quickly. Latexify renders them readably:
using Latexify: latexify
latexify(soutput[1])\begin{equation}
\tanh\left( \mathtt{W\_4_1} + \mathtt{W\_3_{1}ˏ_1} ~ \tanh\left( \mathtt{W\_2_1} + \mathtt{W\_1_{1}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{1}ˏ_2} ~ \mathtt{x_2} \right) + \mathtt{W\_3_{1}ˏ_2} ~ \tanh\left( \mathtt{W\_2_2} + \mathtt{W\_1_{2}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{2}ˏ_2} ~ \mathtt{x_2} \right) + \mathtt{W\_3_{1}ˏ_3} ~ \tanh\left( \mathtt{W\_2_3} + \mathtt{W\_1_{3}ˏ_1} ~ \mathtt{x_1} + \mathtt{W\_1_{3}ˏ_2} ~ \mathtt{x_2} \right) \right)
\end{equation}