English (unofficial) translations of posts at kexue.fm
Source

RNNs and ODEs: Seemingly Different but Identical in Spirit --- An Introduction to Fancy RNNs

Translated by DeepSeek V4 Pro. Translations can be inaccurate, please refer to the original post for important stuff.

Originally, I had decided not to play with RNNs anymore. However, while reflecting last week, I suddenly realized that RNNs actually correspond to numerical methods for solving ODEs (Ordinary Differential Equations). This provided a path for something I have always wanted to do: using deep learning to solve pure mathematical problems. In fact, this is a rather interesting and useful result, so I will introduce it here. Incidentally, this article also involves writing your own RNNs from scratch, so it can serve as a simple tutorial for creating custom RNN layers.

Note: This article is not an introduction to the recent trending topic "Neural ODEs" (though there is a certain connection).

RNN Basics

What is an RNN?

As is well known, RNN stands for "Recurrent Neural Network." Unlike CNNs, an RNN is a general term for a class of models rather than a single specific model. Simply put, any model that takes an input vector sequence (\boldsymbol{x}_1, \boldsymbol{x}_2, \dots, \boldsymbol{x}_T), produces another vector sequence (\boldsymbol{y}_1, \boldsymbol{y}_2, \dots, \boldsymbol{y}_T), and satisfies the following recursive relationship: \boldsymbol{y}_t = f(\boldsymbol{y}_{t-1}, \boldsymbol{x}_t, t) \tag{1} can be called an RNN. Because of this, the original vanilla RNN, as well as improved models like GRU, LSTM, and SRU, are all called RNNs because they can be seen as special cases of the above equation. There are also some things that seem unrelated to RNNs, such as the calculation of the CRF denominator introduced recently, which is actually a simple RNN.

To put it plainly, RNN is essentially recursive computation.

Writing Your Own RNN

Here we first introduce how to use Keras to quickly and easily write a custom RNN.

In fact, whether in Keras or pure TensorFlow, customizing your own RNN is not complicated. In Keras, you just need to write the recursive function for each step; in TensorFlow, it is slightly more complex, requiring you to encapsulate the recursive function into an RNNCell class. Below is an implementation of a basic RNN in Keras: \boldsymbol{y}_t = \tanh(\boldsymbol{W}_1 \boldsymbol{y}_{t-1} + \boldsymbol{W}_2 \boldsymbol{x}_t + \boldsymbol{b}) \tag{2} The code is very simple:

#! -*- coding: utf-8- -*-

from keras.layers import Layer
import keras.backend as K

class My_RNN(Layer):

    def __init__(self, output_dim, **kwargs):
        self.output_dim = output_dim # Output dimension
        super(My_RNN, self).__init__(**kwargs)

    def build(self, input_shape): # Define trainable parameters
        self.kernel1 = self.add_weight(name='kernel1',
                                      shape=(self.output_dim, self.output_dim),
                                      initializer='glorot_normal',
                                      trainable=True)
        self.kernel2 = self.add_weight(name='kernel2',
                                      shape=(input_shape[-1], self.output_dim),
                                      initializer='glorot_normal',
                                      trainable=True)
        self.bias = self.add_weight(name='bias',
                                      shape=(self.output_dim,),
                                      initializer='glorot_normal',
                                      trainable=True)

    def step_do(self, step_in, states): # Define iteration for each step
        step_out = K.tanh(K.dot(states[0], self.kernel1) +
                          K.dot(step_in, self.kernel2) +
                          self.bias)
        return step_out, [step_out]

    def call(self, inputs): # Define the execution function
        init_states = [K.zeros((K.shape(inputs)[0],
                                self.output_dim)
                              )] # Define initial state (all zeros)
        outputs = K.rnn(self.step_do, inputs, init_states) # Loop step_do
        return outputs[0] # outputs is a tuple, outputs[0] is the last output,
                          # outputs[1] is the full sequence, output[2] is a list
                          # of intermediate hidden states.

    def compute_output_shape(self, input_shape):
        return (input_shape[0], self.output_dim)

As you can see, although there are many lines of code, most are boilerplate. What truly defines the RNN is the step_do function. This function accepts two inputs: step_in and states. step_in is a tensor of shape (batch_size, input_dim) representing the current sample \boldsymbol{x}_t, while states is a list representing \boldsymbol{y}_{t-1} and some intermediate variables. It is important to note that states is a list of tensors, not a single tensor, because multiple intermediate variables might need to be passed during recursion (e.g., LSTM requires two state tensors). Finally, step_do must return \boldsymbol{y}_t and the new states; this is the standard for writing this function.

The K.rnn function accepts three basic parameters: the first is the step_do function we just wrote, the second is the input time series, and the third is the initial state, which matches the states mentioned earlier. Naturally, init_states is also a list of tensors, usually initialized to all zeros.

ODE Basics

What is an ODE?

ODE stands for "Ordinary Differential Equation." Here we refer to a general system of ordinary differential equations: \dot{\boldsymbol{x}}(t) = \boldsymbol{f}\big(\boldsymbol{x}(t), t\big) \tag{3} The field studying ODEs is often directly called "Dynamics" or "Dynamical Systems," because Newtonian mechanics is usually just a set of ODEs.

ODEs can generate a very rich variety of functions. For example, e^t is the solution to \dot{x}=x, and \sin t and \cos t are solutions to \ddot{x}+x=0 (with different initial conditions). In fact, some tutorials define the e^t function directly through the differential equation \dot{x}=x. Besides these elementary functions, many special functions whose names we know but whose natures seem mysterious are derived via ODEs, such as hypergeometric functions, Legendre functions, Bessel functions...

In short, ODEs can and do generate all sorts of strange and wonderful functions.

Numerical Solutions to ODEs

ODEs that can be solved exactly with analytical solutions are quite rare, so we often require numerical methods.

Numerical solutions for ODEs are a mature discipline. We won’t introduce much here, only the most basic iteration formula proposed by the mathematician Euler: \boldsymbol{x}(t + h) = \boldsymbol{x}(t) + h \boldsymbol{f}\big(\boldsymbol{x}(t), t\big) \tag{4} where h is the step size. Euler’s method comes from a simple approximation: \frac{\boldsymbol{x}(t + h) - \boldsymbol{x}(t)}{h} \tag{5} to approximate the derivative term \dot{\boldsymbol{x}}(t). Given an initial condition \boldsymbol{x}(0), we can iterate step-by-step using (4) to calculate the result at each time point.

ODE and RNN

ODEs are also RNNs

If you compare (4) and (1) carefully, do you see the connection?

In (1), t is an integer variable, while in (4), t is a floating-point variable. Other than that, there is no obvious difference between (4) and (1). In fact, in (4), we can use h as the unit of time and let t=nh, then (4) becomes: \boldsymbol{x}\big((n+1)h\big) = \boldsymbol{x}(nh) + h \boldsymbol{f}\big(\boldsymbol{x}(nh), nh\big) \tag{6} Now the time variable n in (6) is also an integer.

Thus, we know: Euler’s method for ODEs (4) is actually just a special case of an RNN. This might indirectly explain why RNNs have such strong fitting capabilities (especially for time-series data). We see that ODEs can generate many complex functions, and since an ODE is just a special case of an RNN, RNNs can generate even more complex functions.

Solving ODEs with RNNs

Consequently, we can write an RNN to solve an ODE. For example, take the case from "Competition Model of Two Biological Populations": \left\{\begin{aligned}\frac{dx_1}{dt}=r_1 x_1\left(1-\frac{x_1}{N_1}\right)-a_1 x_1 x_2 \\ \frac{dx_2}{dt}=r_2 x_2\left(1-\frac{x_2}{N_2}\right)-a_2 x_1 x_2\end{aligned}\right. \tag{7} We can write:

#! -*- coding: utf-8- -*-

from keras.layers import Layer
import keras.backend as K

class ODE_RNN(Layer):

    def __init__(self, steps, h, **kwargs):
        self.steps = steps
        self.h = h
        super(ODE_RNN, self).__init__(**kwargs)

    def step_do(self, step_in, states): # Define iteration for each step
        x = states[0]
        r1,r2,a1,a2,iN1,iN2 = 0.1,0.3,0.0001,0.0002,0.002,0.003
        _1 = r1 * x[:,0] * (1 - iN1 * x[:,0]) - a1 * x[:,0] * x[:,1]
        _2 = r2 * x[:,1] * (1 - iN2 * x[:,1]) - a2 * x[:,0] * x[:,1]
        _1 = K.expand_dims(_1, 1)
        _2 = K.expand_dims(_2, 1)
        _ = K.concatenate([_1, _2], 1)
        step_out = x + self.h * _
        return step_out, [step_out]

    def call(self, inputs): # inputs are the initial conditions
        init_states = [inputs]
        zeros = K.zeros((K.shape(inputs)[0],
                         self.steps,
                         K.shape(inputs)[1])) # No external input needed
                                              # dummy zeros for K.rnn
        outputs = K.rnn(self.step_do, zeros, init_states)
        return outputs[1] # Output the entire sequence

    def compute_output_shape(self, input_shape):
        return (input_shape[0], self.steps, input_shape[1])

from keras.models import Sequential
import numpy as np
import matplotlib.pyplot as plt

steps, h = 1000, 0.1

M = Sequential()
M.add(ODE_RNN(steps, h, input_shape=(2,)))
M.summary()

# Forward propagation directly outputs the solution
result = M.predict(np.array([[100, 150]]))[0] # Initial condition [100, 150]
times = np.arange(1, steps+1) * h

# Plotting
plt.plot(times, result[:,0])
plt.plot(times, result[:,1])
plt.savefig('test.png')

The process is easy to understand, but two points should be noted. First, since the system of equations (7) is only two-dimensional and not easily written as matrix operations, I operated on the indices directly in the step_do function (x[:,0], x[:,1]). If the equation had higher dimensions and could be written as matrix operations, it would be more efficient. Second, after building the model, calling predict outputs the result directly; no "training" is required.

RNN solving the competition model of two species

Inferring ODE Parameters

The previous section showed that the forward propagation of an RNN corresponds to the Euler solution of an ODE. What does backpropagation correspond to?

In practical problems, there is a class of problems called "model inference," which involves guessing the model (mechanism inference) that fits a set of experimental data. This usually involves two steps: first, guessing the form of the model, and second, determining the model’s parameters. Assuming the data can be described by an ODE and the form of the ODE is known, we need to estimate the parameters within it.

If the ODE could be solved analytically, this would be a simple regression problem. But as mentioned, most ODEs have no closed-form solution, so numerical methods are necessary. This is exactly what backpropagation in the corresponding RNN does: forward propagation solves the ODE (the RNN’s prediction process), and backpropagation naturally infers the ODE’s parameters (the RNN’s training process). This is a very interesting fact: ODE parameter inference is a well-studied subject, yet in deep learning, it is just a basic application of RNNs.

Let’s save the solution data from the previous example and take only a few points to see if we can reverse-engineer the original differential equation. The data points are:

Time 0 10 15 30 36 40 42
x_1 100 165 197 280 305 318 324
x_2 150 283 290 276 269 266 264

Assuming we only know these limited data points and the form of equation (7), we want to find the parameters. We modify the previous code:

#! -*- coding: utf-8- -*-

from keras.layers import Layer
import keras.backend as K

def my_init(shape, dtype=None): # Need proper initialization
    return K.variable([0.1, 0.1, 0.001, 0.001, 0.001, 0.001])

class ODE_RNN(Layer):
    
    def __init__(self, steps, h, **kwargs):
        self.steps = steps
        self.h = h
        super(ODE_RNN, self).__init__(**kwargs)
    
    def build(self, input_shape): # Set parameters as trainable
        self.kernel = self.add_weight(name='kernel', 
                                      shape=(6,),
                                      initializer=my_init,
                                      trainable=True)
    def step_do(self, step_in, states):
        x = states[0]
        r1,r2,a1,a2,iN1,iN2 = (self.kernel[0], self.kernel[1],
                               self.kernel[2], self.kernel[3],
                               self.kernel[4], self.kernel[5])
        _1 = r1 * x[:,0] * (1 - iN1 * x[:,0]) - a1 * x[:,0] * x[:,1]
        _2 = r2 * x[:,1] * (1 - iN2 * x[:,1]) - a2 * x[:,0] * x[:,1]
        _1 = K.expand_dims(_1, 1)
        _2 = K.expand_dims(_2, 1)
        _ = K.concatenate([_1, _2], 1)
        step_out = x + self.h * K.clip(_, -1e5, 1e5) # Prevent gradient explosion
        return step_out, [step_out]
    
    def call(self, inputs):
        init_states = [inputs]
        zeros = K.zeros((K.shape(inputs)[0],
                         self.steps,
                         K.shape(inputs)[1]))
        outputs = K.rnn(self.step_do, zeros, init_states)
        return outputs[1]
    
    def compute_output_shape(self, input_shape):
        return (input_shape[0], self.steps, input_shape[1])

from keras.models import Sequential
from keras.optimizers import Adam
import numpy as np
import matplotlib.pyplot as plt

steps, h = 50, 1 # Large step size to reduce steps and long-term dependency
series = {0: [100, 150],
          10: [165, 283],
          15: [197, 290],
          30: [280, 276],
          36: [305, 269],
          40: [318, 266],
          42: [324, 264]}

M = Sequential()
M.add(ODE_RNN(steps, h, input_shape=(2,)))
M.summary()

# Construct training samples
X = np.array([series[0]])
Y = np.zeros((1, steps, 2))

for i,j in series.items():
    if i != 0:
        Y[0, int(i/h)-1] += series[i]

# Custom loss: only consider time points where data exists
def ode_loss(y_true, y_pred):
    T = K.sum(K.abs(y_true), 2, keepdims=True)
    T = K.cast(K.greater(T, 1e-3), 'float32')
    return K.sum(T * K.square(y_true - y_pred), [1, 2])

M.compile(loss=ode_loss, optimizer=Adam(1e-4))
M.fit(X, Y, epochs=10000)

# Predict and plot
result = M.predict(np.array([[100, 150]]))[0]
times = np.arange(1, steps+1) * h

plt.clf()
plt.plot(times, result[:,0], color='blue')
plt.plot(times, result[:,1], color='green')
plt.plot(list(series.keys()), [i[0] for i in series.values()], 'o', color='blue')
plt.plot(list(series.keys()), [i[1] for i in series.values()], 'o', color='green')
plt.savefig('test.png')

The results can be seen in the following figure:

Effect of RNN for ODE parameter estimation (Dots: limited experimental data; Curves: estimated model)

Clearly, the results are satisfying.

Conclusion

This article introduced the RNN model and its custom implementation in Keras within a general framework, then revealed the connection between ODEs and RNNs. Based on this, it introduced the basic ideas of solving ODEs directly with RNNs and inferring ODE parameters using RNNs. I must remind readers that in the backpropagation of RNN models, one must be cautious with initialization and truncation, and choose the learning rate carefully to prevent gradient explosion (gradient vanishing is just poor optimization, but gradient explosion leads to total collapse; solving gradient explosion is particularly important).

In short, gradient vanishing and explosion are classic difficulties in RNNs. In fact, models like LSTM and GRU were introduced primarily to solve the gradient vanishing problem, while gradient explosion is addressed by using tanh or sigmoid activation functions. However, if we use RNNs to solve ODEs, we do not have the freedom to choose activation functions (the activation function is part of the ODE itself), so we must be careful with initialization and other treatments. It is said that as long as initialization is done carefully, using ReLU as an activation function in a standard RNN is perfectly fine.

When reposting, please include the original article address: https://kexue.fm/archives/5643

For more detailed reposting matters, please refer to: "Scientific Space FAQ"