Skip to content

Deep Ritz: solve a PDE by minimising an energy functional

A VPINN (previous tutorial) minimises the weak residual test-projected onto the FE basis. The Deep Ritz method takes the other classical route: it minimises the energy functional directly — the network is the trial, there are no test functions and no assembled system. For Poisson \(-\Delta u = f\) with \(u=0\) on \(\partial\Omega\) the governing energy is

\[ J[u] = \int_\Omega \Big( \tfrac12\,|\nabla u|^2 - f\,u \Big)\,dx, \]

whose minimiser over \(H^1_0\) is the weak solution. Lower derivative order (only \(\nabla u\), no \(\Delta u\)) and no linear solve, at the price of a non-convex optimisation.

Method: Deep Ritz — E & Yu, Commun. Math. Stat. 6:1 (2018), §2 (arXiv:1710.00211); the solid-mechanics form is the Deep Energy Method (Samaniego et al., CMAME 362, 2020; Nguyen-Thanh, Zhuang & Rabczuk, Eur. J. Mech. A/Solids 80, 2020).

The energy as a loss

expr.integrate() integrates a scalar expression over the mesh (auto-detecting volume vs boundary from the coordinate variable), so the whole method is: build the network trial, write the energy density, integrate it, and hand the single scalar to jno.core as the loss.

xi, yi, _ = dom.variable("interior", split=True)
net = jnn.nn.wrap(foundax.mlp(2, hidden_dims=48, num_layers=4, activation=jax.nn.tanh, key=key))

ansatz = xi * (1 - xi) * yi * (1 - yi)      # hard-BC ansatz: vanishes on ∂Ω  → u ∈ H¹₀
u  = net(xi, yi) * ansatz
ux, uy = jnn.grad(u, xi), jnn.grad(u, yi)
f  = 2.0 * (xi * (1 - xi) + yi * (1 - yi))

energy = (0.5 * (ux**2 + uy**2) - f * u).integrate(quadrature="gauss")   # J[u] = ∫ (½|∇u|² − f u) dx

net.optimizer(optax.adam(3e-3))
jno.core([energy], domain=dom).solve(4000)             # minimise the (signed) energy directly

The loss is the signed energy — .integrate() reduces to a scalar and jno.core minimises it as is (no square), so the reported loss converges to \(J_{\min}<0\). General geometries that cannot use a hard-BC ansatz add a boundary penalty (β * (net(xb, yb) - g)).mse as a second constraint.

Quadrature consistency — why quadrature="gauss"

The default .integrate() uses the P1 nodal-volume (vertex) rule: the energy is sampled only at the mesh nodes. A network expressive enough to develop structure between nodes can drive the quadrature-estimated energy below the true minimum — a variational crime in which the discrete energy stops being a faithful estimate, and the solution degrades even as the reported loss keeps falling. .integrate(quadrature="gauss") samples the per-element Gauss points (weighting by \(J\,W\)) instead — many points inside each element, far harder to alias — so a capable network can minimise the energy safely. Pass quadrature=<int> to set the Gauss degree ("gauss" = 4).

With this same 48×4 network the two rules diverge sharply: Gauss holds the true minimum and reaches rel-\(L^2 \approx 8\times10^{-5}\), while the nodal rule lets the energy sink past the true minimum and the error blows up. (Gauss integration is currently volume-only; boundary integrals use the nodal rule.)

The result

Deep Ritz solution and pointwise error vs the analytic solution

The trained network matches the analytic \(x(1-x)y(1-y)\) to rel-\(L^2 \approx 8\times10^{-5}\). The energy plateaus at the true minimum within a few hundred epochs and stays there.

What to notice

  • Energy, not residual. The loss is the functional \(J[u]\) itself, integrated over the mesh with .integrate() — no test functions, no assembled operator. Contrast the VPINN, which needs the FE test space.
  • Signed objective. jno.core minimises the raw reduced value, so a signed energy that goes negative is exactly right — no .mse wrapper.
  • Quadrature matters for energy losses. A fixed nodal rule can be aliased by an expressive integrand; quadrature="gauss" samples inside each element and resists it. If a loss dips below the analytic energy while the error grows, that is the quadrature being exploited.

Full script

"""Deep Ritz / Deep Energy Method: solve a PDE by **minimising an energy functional** with a network.

Where a VPINN (`vpinn_poisson_2d.py`) minimises the *weak residual* test-projected onto the FE basis,
the Deep Ritz method minimises the **energy functional** directly — no test functions, no assembly.
For Poisson ``-Δu = f`` with ``u = 0`` on ``∂Ω`` the governing energy is

    J[u] = ∫_Ω ( ½ |∇u|² − f·u ) dx ,

whose minimiser over ``H¹₀`` is the weak solution. The neural network *is* the trial ``u``; we integrate
the energy density over the mesh with ``expr.integrate()`` and hand the single scalar ``J`` to
``jno.core`` as the loss. Lower derivative order and no linear solve, at the price of a
non-convex optimisation.

Method: **Deep Ritz** — E & Yu, *Commun. Math. Stat.* 6:1 (2018), §2 (arXiv:1710.00211); the solid-
mechanics form is the **Deep Energy Method**, Samaniego et al., *CMAME* 362 (2020) and Nguyen-Thanh,
Zhuang & Rabczuk, *Eur. J. Mech. A/Solids* 80 (2020).

We solve ``-Δu = f`` on the unit square with exact ``u = x(1-x)y(1-y)`` (so ``f = 2[x(1-x)+y(1-y)]``).

Quadrature consistency (why we use Gauss): the default ``.integrate()`` uses the P1 nodal-volume
(vertex) rule — the energy is sampled *only at mesh nodes*. A network expressive enough to develop
structure *between* nodes can then drive the discrete energy *below* the true minimum (a variational
crime: the quadrature is no longer a faithful estimate) and the solution degrades even as the
reported loss keeps dropping. ``.integrate(quadrature="gauss")`` samples the per-element Gauss points
instead, which is far harder to alias — it lets a *capable* network minimise the energy safely and
converge to ``rel-L2 ≈ 1e-4`` here. (Swap in the default nodal rule with this same network and the
energy sinks past the true minimum while the error blows up.)
"""

import os

os.environ["MPLBACKEND"] = "Agg"

from pathlib import Path  # noqa: E402

import foundax  # noqa: E402
import jax  # noqa: E402  (jax.nn / jax.random for the network)
import matplotlib.pyplot as plt  # noqa: E402
import matplotlib.tri as mtri  # noqa: E402
import numpy as np  # noqa: E402
import optax  # noqa: E402

import jno  # noqa: E402
import jno.jnp_ops as jnn  # noqa: E402

jax.config.update("jax_enable_x64", True)  # the mesh integral accumulates in float64

# ---- domain, network trial, energy functional ----------------------------------------------
dom = jno.shape.rect(0, 0, 1, 1, size=0.05).domain()
xi, yi, _ = dom.variable("interior", split=True)  # .integrate() re-evaluates at the quadrature points

# a capable network — the Gauss quadrature below keeps its energy honest (see the consistency note)
net = jnn.nn.wrap(foundax.mlp(2, hidden_dims=48, num_layers=4, activation=jax.nn.tanh, key=jax.random.PRNGKey(0)))
ansatz = xi * (1 - xi) * yi * (1 - yi)  # hard-BC ansatz: vanishes on the [0,1]^2 boundary → u ∈ H¹₀
u = net(xi, yi) * ansatz  # the network trial
ux, uy = jnn.grad(u, xi), jnn.grad(u, yi)
f = 2.0 * (xi * (1 - xi) + yi * (1 - yi))  # -Δ[x(1-x)y(1-y)]

# energy functional J[u] = ∫ (½|∇u|² − f u) dx, integrated with element GAUSS quadrature
# (alias-resistant — a nodal rule would let this network cheat the integral; see the docstring)
energy = (0.5 * (ux**2 + uy**2) - f * u).integrate(quadrature="gauss")

# ---- train the network by minimising J[u] through jno.core ---------------------------------
net.optimizer(optax.adam(3e-3))
crux = jno.core([energy], domain=dom)  # the *signed* energy is the loss (it converges to J_min < 0)
crux.solve(4000)

# ---- verify the trained network against the analytic solution (on a fresh grid) ------------
test_dom = jno.shape.rect(0, 0, 1, 1, size=0.035).domain()
xt, yt, _ = test_dom.variable("interior", split=True)
exact_expr = xt * (1 - xt) * yt * (1 - yt)
pred = np.asarray(crux.eval([net(xt, yt) * exact_expr], domain=test_dom)).reshape(-1)
exact = np.asarray(crux.eval([exact_expr], domain=test_dom)).reshape(-1)
rel = float(np.linalg.norm(pred - exact) / np.linalg.norm(exact))
print(f"\nDeep Ritz Poisson 2D: minimised energy functional; dofs={dom.mesh.points.shape[0]}")
print(f"  trained Deep-Ritz vs analytic x(1-x)y(1-y):  rel-L2 = {rel:.3e}")