Discovering differential equations from trajectories
The equation of motion of a damped oscillator recovered from positions alone, with numerical derivatives and a sparse regression over a library of terms, tested from a start it never saw, and the cases where the data or the library cannot settle the equation.
With scripts, this page keeps a record in your browser of whether you completed the lesson. Nothing is stored without them.
With scripts, this place suggests what to do next, with the reason, from the progress saved in your browser. Without them, follow the learning path.
The previous lesson found a formula for one number, the period. Mechanics is mostly not about such numbers: it is about equations of motion, the rule that gives the acceleration from the state. Newton wrote them down from principles. A camera that tracks a mass on a spring records something else, a list of positions at equal times. Can the equation be read back from that list?
The approach of this lesson, known as sparse identification of nonlinear dynamics, has two steps. First the derivatives that the equation is about, velocity and acceleration, are estimated from the positions. Then the acceleration is written as a combination of a few terms out of a large library of candidates, , keeping only those the data need. The result is an equation a person can read, and that can be integrated from any start to test it.
The lesson uses clean data: the positions are exact up to the simulation. The next lesson adds measurement noise, which is where this approach meets its hardest problem. No language model is used.
Assumptions
The positions come from a simulation and are sampled without noise. A damped oscillator, , is integrated with the fourth-order Runge-Kutta method and sampled every hundredth of a second. Only the positions are kept. With noise, the derivatives degrade; that is the next lesson.
The equation is autonomous and of second order. The acceleration is a function of the position and the velocity, not of time. A driven oscillator would need time in the library.
The law is a sum of the library’s terms. The library holds every product with . A law that is not such a sum, like the of a pendulum, can only be approximated; the exercise shows what that means.
Small terms are dropped by a stated rule. A term whose contribution to the acceleration is below two percent of the root-mean-square acceleration is removed. A real term smaller than that is lost; a spurious term larger than that is kept.
The trajectories decide what can be told apart. Four orbits from different starts are used. What one orbit cannot distinguish is shown in the worked examples.
From positions to an equation
Derivatives from samples
With positions every , the central differences follow from the Taylor expansion of : the odd terms cancel in the first, the even ones in the second, and what is left is an error proportional to . For a motion of angular frequency , the relative error of the acceleration is about , here far below the tolerance.
def central_differences(x: Array, dt: float) -> Derivatives:"""The velocity ``(x[i+1] - x[i-1]) / (2 dt)`` and the acceleration ``(x[i+1] - 2 x[i] + x[i-1]) / dt**2`` at every interior sample of the positions ``x`` (m), sampled every ``dt`` (s). Both are second order in ``dt``; a noise of standard deviation ``s`` in ``x`` becomes a noise of about ``s / dt`` in the velocity and ``2.4 s / dt**2`` in the acceleration. """ x = np.asarray(x, dtype=float)return Derivatives( x=x[1:-1], v=(x[2:] - x[:-2]) / (2* dt), a=(x[2:] -2* x[1:-1] + x[:-2]) / dt**2, )
Every sample gives one equation: the acceleration estimate equals the sum of the library terms, evaluated at that sample’s position and velocity, times unknown coefficients. Thousands of samples and ten terms make an overdetermined linear system, solved by least squares. Least squares alone would give every term a small coefficient, because the derivative errors leak into all of them. Sequentially thresholded least squares removes the terms whose contribution is below the threshold and fits the rest again, until nothing changes.
def sparse_regression( terms: Sequence[str], data: Derivatives, threshold: float= THRESHOLD) -> Equation:"""Sequentially thresholded least squares: fit the acceleration with all ``terms``, drop each term whose contribution is below ``threshold`` times the root-mean-square acceleration, and fit the rest again, until no term is dropped.""" columns = evaluate(terms, data.x, data.v) scale = np.sqrt((columns**2).mean(axis=0)) scale[scale ==0] =1.0 scaled = columns / scale target = data.a size = np.sqrt((target**2).mean()) active = np.ones(len(terms), dtype=bool) fitted = np.zeros(len(terms))for _ inrange(len(terms)): fitted[:] =0.0 fitted[active], *_ = np.linalg.lstsq(scaled[:, active], target, rcond=None) small = active & (np.abs(fitted) < threshold * size)ifnot small.any():break active &=~small coefficients = fitted / scale residual =float(np.sqrt(((target - columns @ coefficients) **2).mean()))return Equation( {t: float(c) for t, c inzip(terms, coefficients, strict=True) if c !=0.0}, residual, )
DEGREE =3# the library: every x^i v^j with i + j <= 3TOLERANCE =0.05# relative, on each coefficient of the true equationTRUE = {"x": -OMEGA2, "v": -DAMPING}
def discover(experiment: Experiment) -> Equation:"""Positions of four damped orbits, central differences, and the sparse regression; record the coefficients of every term.""" terms = polynomial_library(DEGREE) data = combine([central_differences(positions(damped, *s), DT) for s in STARTS]) equation = sparse_regression(terms, data) experiment.table("coefficients", [(t, TRUE.get(t, 0.0), equation.coefficients.get(t)) for t in terms], columns=[ ("term", "", "s"), ("true coefficient", "", ".4f"), ("found coefficient", "", ".4f"), ], caption="Each term of the library, its coefficient in the true equation,"" and its coefficient in the found one, to four decimals; an empty cell"" is a term the regression dropped. The unit of a coefficient is that of"" the acceleration divided by that of the term.", )return equation
Each term of the library, its coefficient in the true equation, and its coefficient in the found one, to four decimals; an empty cell is a term the regression dropped. The unit of a coefficient is that of the acceleration divided by that of the term.
term
true coefficient
found coefficient
1
0.0000
x
-4.0000
-3.9999
v
-0.3000
-0.3000
x^2
0.0000
x v
0.0000
v^2
0.0000
x^3
0.0000
x^2 v
0.0000
x v^2
0.0000
v^3
0.0000
Numerically verified (the simulated damped oscillator, positions without noise every 0.01 s, the cubic library): From the positions of four orbits, 3996 samples in all, the regression over 10 terms finds a = -4 x - 0.3 v: it keeps exactly the two terms of the true equation, and its largest coefficient error is 0.003 percent, within the tolerance of 5 percent. The equation is recovered. The other terms are not merely small: they are absent. That is the point of the threshold. A coefficient of a millionth on would claim a nonlinearity that the oscillator does not have.
The program
The library is generated from its degree, and every term is a product of powers of the position and the velocity. The regression scales each column to a root-mean-square of one before it fits, so that the threshold compares contributions, not coefficients in different units.
def polynomial_library(degree: int=3) ->tuple[str, ...]:"""The terms ``x^i v^j`` with ``i + j <= degree``, by degree: ``1, x, v, x^2, x v, v^2, ...``.""" names = []for total inrange(degree +1):for j inrange(total +1): names.append(_term(total - j, j))returntuple(names)
def singular_values(terms: Sequence[str], data: Derivatives) -> Array:"""The singular values of the library on ``data``, each column scaled to a root-mean-square of one, relative to the largest. A value near zero is a combination of terms that vanishes on the data: the data cannot tell an equation from the same equation plus that combination.""" columns = evaluate(terms, data.x, data.v) columns = columns / np.sqrt((columns**2).mean(axis=0)) values = np.linalg.svd(columns, compute_uv=False)return values / values[0]
An equation is a claim about every start, not only the four it was read from. The test is to integrate it from a start outside every training orbit and compare with the true motion.
HELD_OUT = (3.0, -2.0) # x0 (m), v0 (m/s): outside every training orbit
def held_out(equation: Equation) ->tuple[np.ndarray, np.ndarray, np.ndarray]:"""The true motion from the held-out start and the motion of the found equation from it, over the duration.""" t = np.arange(round(DURATION / DT) +1) * DT truth = integrate(damped, *HELD_OUT, t) found = integrate(equation.acceleration, *HELD_OUT, t)return t, truth, found
Figure 1: simulated The motion from a held-out start under the true equation (solid) and under the found one (dashed), and their difference below. The curves coincide over the ten seconds, and the difference stays small against the amplitude.
Numerically verified (one held-out start, ten seconds, the damped oscillator): Integrated from the held-out start, of amplitude 3.0 m, larger than any training orbit, the found equation follows the true motion over ten seconds within 2.5e-04 m. The difference grows slowly because the found coefficients differ slightly from the true ones, and an error of the frequency accumulates as a phase. The test supports the equation over this range of amplitudes and this duration; it says nothing about amplitudes where a real spring stops being linear.
One orbit admits several equations
Now take an oscillator without damping and a single orbit. On that orbit the energy is conserved: keeps the value it started with. So the combination of library terms is zero at every sample. Add any multiple of it to the true equation and the new equation fits the orbit exactly as well.
ORBIT = (1.0, 0.0) # x0 (m), v0 (m/s) of the undamped orbitOTHER_ORBIT = (2.0, 0.0)LAMBDA =1.0# 1/s², the size of the added term
def one_orbit(experiment: Experiment) ->tuple[np.ndarray, np.ndarray]:"""The library on one orbit and on four orbits of the undamped oscillator, and an equation the one orbit cannot tell from the true one; record the residual of both on the orbit and on another one.""" terms = polynomial_library(DEGREE) single = central_differences(positions(undamped, *ORBIT), DT) several = combine( [central_differences(positions(undamped, *s), DT) for s in STARTS] )# On the orbit the energy per mass, (OMEGA2 x^2 + v^2) / 2, keeps the# value it starts with; the added term is zero there and nowhere else. energy = OMEGA2 * ORBIT[0] **2+ ORBIT[1] **2 true = Equation({"x": -OMEGA2}, 0.0) rival = Equation( {"x": -OMEGA2 - LAMBDA,"x^3": LAMBDA * OMEGA2 / energy,"x v^2": LAMBDA / energy, },0.0, ) other = central_differences(positions(undamped, *OTHER_ORBIT), DT)def residual(equation: Equation, data) ->float:returnfloat( np.sqrt(((data.a - equation.acceleration(data.x, data.v)) **2).mean()) ) experiment.table("rival-equations", [ (equation.formula(), residual(equation, single), residual(equation, other))for equation in (true, rival) ], columns=[ ("equation", "", "s"), ("residual on the orbit", "m/s²", ".2g"), ("residual on another orbit", "m/s²", ".2g"), ], caption="Two equations on the orbit they were read from and on an orbit of"" twice the amplitude: the root-mean-square difference between the"" acceleration from central differences and the equation's, to two"" significant digits.", )return singular_values(terms, single), singular_values(terms, several)
Two equations on the orbit they were read from and on an orbit of twice the amplitude: the root-mean-square difference between the acceleration from central differences and the equation's, to two significant digits.
equation
residual on the orbit (m/s²)
residual on another orbit (m/s²)
a = -4 x
9.5e-05
0.00019
a = -5 x + 1 x^3 + 0.25 x v^2
0.00012
4.3
The two equations differ by three terms and fit the orbit equally well, to the error of the differences. On an orbit of another energy the added terms no longer vanish, and the rival is far off. The data of one orbit contain no reason to prefer the simpler equation; only the threshold, a rule of the method, does.
The singular values of the library show the problem without guessing the rival. A singular value near zero is a combination of terms that vanishes on the data: a direction in which the coefficients are not determined.
def draw_singular_values( single: np.ndarray, several: np.ndarray, floor: float) -> Figure:"""The singular values of the library on one orbit and on four.""" fig, ax = plt.subplots(figsize=(6.4, 3.6), constrained_layout=True) index = np.arange(1, len(single) +1) ax.semilogy( index, np.maximum(single, floor), color=FIGURE_COLOURS[1], marker="s", linestyle="--", label=f"one orbit (below {floor:g} drawn at {floor:g})", ) ax.semilogy( index, several, color=FIGURE_COLOURS[0], marker="o", linestyle="-", label="four orbits", ) ax.set_xlabel("singular value, largest first") ax.set_ylabel("relative to the largest (dimensionless)") ax.legend(loc="lower left", fontsize=8)return fig
Figure 2: simulated Singular values of the library, relative to the largest, on one orbit and on four orbits of the undamped oscillator. On one orbit several of them collapse, drawn at the floor of the axis: conserved energy ties combinations of terms. Four orbits of different energies remove the ties.
Numerically verified (the undamped oscillator, the cubic library, central differences of exact positions): On one orbit of the undamped oscillator 3 singular values of the library are below 1e-08 of the largest: energy conservation makes dependent, and multiplying the relation by and by gives two more. On four orbits the smallest is 0.23, and the equation is determined. This is identifiability in its plainest form. Several formulas describe the same observations whenever the observations satisfy a relation the library can express. A conserved quantity is such a relation, which is a little ironic: the most important property of the motion hides its equation. The remedy is data that break the relation, such as several energies, or a library chosen so that the relation cannot be written in it.
Limits
Clean data only. The derivatives here are exact up to the time step. With measurement noise, the second difference is the weak point; the next lesson measures how weak and compares a smoothing differentiator.
The library is the hypothesis space. The regression can only answer in terms of up to degree three. A law outside it is approximated by a sum that fits the training range and may fail beyond, as the pendulum exercise shows.
The threshold is a choice. Two percent of the acceleration is the stated rule. A real term below it, a weak damping for instance, would be dropped, and nothing in the data would warn.
One held-out start is one test. The found equation agrees with the truth from one start for ten seconds. That supports it over the probed amplitudes, not for every start or every time.
Recovered is not explained. The regression returns coefficients, not their meaning. That the coefficient is a stiffness over a mass and the coefficient a damping rate is the reader’s physics, not the method’s output.
In plain words: numerical derivatives and a sparse regression read the equation of a simulated oscillator back from its positions, and the equation holds from a start it never saw. Whether it is the equation depends on whether the data could have told it apart from others, and the singular values of the library are the place to look.
Self-check. A regression on a single long orbit of an undamped oscillator returns , the right equation. Why is this not evidence that the data determine the equation?
With scripts, you can record in this browser that you answered this question.
Solution to the self-check
On one orbit the conserved energy makes a combination of library terms vanish, so the true equation plus any multiple of that combination fits equally well. The threshold picked the simplest member of that family; the data did not. Orbits of different energies, or a check of the singular values, are needed before the result is evidence.
Exercises
Exercise 1.A pendulum through a polynomial library. A pendulum with per square second obeys , which is not a sum of the library’s terms. Run the regression on two swings that reach at most 0.2 rad, and on two that reach 1.5 rad. Which equations are found, and how far does each drift from the pendulum’s motion when released from 2 rad?
Hint 1 of 1 for exercise 1
Expand in a Taylor series. Which of its terms is larger than two percent of the first one for each set of swings?
Solution to exercise 1
def pendulum(experiment: Experiment) ->None:"""Exercise: the regression on pendulum swings at small and at large amplitude; record the equations found and their error at a larger one.""" terms = polynomial_library(DEGREE)def swing(x, v):return-PENDULUM * np.sin(x) t = np.arange(round(DURATION / DT) +1) * DT rows = []for amplitude in (0.2, 1.5): starts = [(amplitude, 0.0), (0.5* amplitude, 0.0)] data = combine([central_differences(positions(swing, *s), DT) for s in starts]) equation = sparse_regression(terms, data) truth = integrate(swing, 2.0, 0.0, t) found = integrate(equation.acceleration, 2.0, 0.0, t) rows.append( (amplitude, equation.formula(), float(np.max(np.abs(found - truth)))) ) experiment.table("pendulum", rows, columns=[ ("largest amplitude", "rad", "g"), ("found equation", "", "s"), ("largest error from 2 rad", "rad", ".2f"), ], caption="The regression on two swings of a pendulum with g/L = 4 per"" square second, and the largest difference over ten seconds between its"" motion and the pendulum's from 2 rad, to two decimals.", )
The regression on two swings of a pendulum with g/L = 4 per square second, and the largest difference over ten seconds between its motion and the pendulum's from 2 rad, to two decimals.
largest amplitude (rad)
found equation
largest error from 2 rad (rad)
0.2
a = -3.983 x
4.00
1.5
a = -3.965 x + 0.5853 x^3
0.82
The small swings give a linear spring: the cubic term of is too small to pass the threshold, and the regression cannot tell the pendulum from a harmonic oscillator. The large swings give a cubic term with the sign of , an approximation of the sine over that range. Both fit their own data; from rad the linear equation runs out of phase completely, and the cubic one drifts less. Neither found the sine, because the library does not contain it. The found equation is a law of the training range.
The experiment writes every figure of this page into results/, as <label>.png, and every number and table of the page, as the page shows it, into results/results.json. Its numbers match this page to the digits shown, and its figure files are the ones the page shows; digits beyond those can differ between machines, because floating-point libraries differ. Change the code of the experiment and run it again to see what changes.