All projects

Machine learning

Physics-Informed vs Raw Neural Network

An interactive comparison of two learning philosophies: one model starts with physical structure, the other starts with raw features. They train side-by-side on the same projectile dataset so you can see the trade-off between inductive bias and representation power in real time.
  • Neural networks
  • Physics-informed ML

Live Training & Visualization

Both models train simultaneously on the same mini-batches. You can inspect trajectory predictions, decision fields, confusion matrices, and learning curves while changing hyperparameters.

Physics-Informed vs Raw MLP ClassifierOpen on its own

The short story

This page is a controlled experiment about inductive bias. The question is simple: when we already know the governing physics, does encoding that structure improve sample efficiency and training stability compared to a generic MLP?

The experimental rule is strict: keep data generation and evaluation synchronized, and change only representation. That way, the performance gap is interpretable rather than anecdotal.

Before the details

This project isolates one variable on purpose: representation. Both models are trained on the same task, same labels, same data stream, and same evaluation rhythm. The only thing that changes is what we feed them before optimization starts.

In the physics-informed branch, I encode known ballistic structure into the feature itself. In the raw branch, I do not provide that shortcut — the MLP has to infer the geometry from gradients. This is why the comparison is meaningful: we can observe not only final accuracy, but also stability, sample efficiency, and boundary formation during training.

So the page is less about “which model wins” and more about learning dynamics under controlled conditions. If one branch converges faster or behaves more calmly, we can attribute that to inductive bias rather than dataset luck.

Quick glossary

  • MLP (Multi-Layer Perceptron): A feed-forward neural network that learns nonlinear decision boundaries from data.
  • Physics-informed: A model that receives explicit structure from known equations before training.
  • Feature: Any numeric quantity the model receives as input.
  • Inductive bias: Built-in structure or assumptions that guide learning.
  • Decision field: A visual map of predicted classes across input space.
  • Confusion matrix: A compact view of correct predictions versus class-specific mistakes.
  • Validation: Evaluation on held-out samples to estimate generalization.

What you are seeing first

The target is a 3-class projectile range classifier. Colors are fixed across all panels so geometric changes in the decision boundary can be interpreted immediately.

  • Short: R<100 mR \lt 100\,\mathrm{m}
  • Medium: 100 m≤R<200 m100\,\mathrm{m} \le R \lt 200\,\mathrm{m}
  • Long: R≥200 mR \ge 200\,\mathrm{m}

Blue marks short range, green medium range, and orange long range. Keeping this mapping invariant across views is what makes the live comparison readable rather than cosmetic.

Problem setup

Given initial speed vv and launch angle θ\theta, the goal is to map the resulting range RR to one of the three class intervals above. The 600 launches are drawn uniformly from v∈[5,60] m/sv \in [5, 60]\,\mathrm{m/s} and θ∈[5∘,85∘]\theta \in [5^\circ, 85^\circ].

The noiseless generator relation (flat ground, no drag, g=9.81 m/s2g = 9.81\,\mathrm{m/s^2}) is:

R=v2sin⁡(2θ)gR = \frac{v^2\sin(2\theta)}{g}

This equation defines the target manifold that both models are implicitly trying to separate.

Physics-informed model

The physics branch explicitly constructs R^=v2sin⁡(2θ)/g\hat{R} = v^2 \sin(2\theta)/g and feeds [1, R^/100 m][1,\ \hat{R}/100\,\mathrm{m}] into a linear softmax layer. This deliberately low-capacity model is useful because it exposes exactly how far a well-chosen feature can go.

Measuring R^\hat{R} in units of 100 m is not cosmetic. In metres the feature runs up to 367, so the loss is over ten thousand times more curved along its weight than along the bias, and plain SGD overshoots at any learning rate that still moves the bias: fed in metres, the same model hovers around 55% and jumps by about ten points from tick to tick, even at a learning rate of 0.001. Rescaled, the class edges sit at 1 and 2 and SGD settles quickly.

Raw MLP model

The raw model receives

x=[1, v/60, sin⁡θ+ϵ, cos⁡θ+ϵ]\mathbf{x}=[1,\ v/60,\ \sin\theta + \epsilon,\ \cos\theta + \epsilon]

with vv in m/s and ϵ\epsilon drawn uniformly from [−0.02, 0.02][-0.02,\ 0.02] for each training sample, and passes this vector through a two-layer MLP (16 hidden units, Leaky-ReLU with slope 0.1, Adam). In this branch, the geometric structure must be learned from gradients rather than injected analytically.

Fair comparison protocol

The protocol is synchronized by construction. One seed fixes everything random: the 600 launches, the 70/30 split (420 training, 180 validation samples), both models' initial weights, and the order of the mini-batches. Each update draws one batch from that seeded stream and gives the same sample indices to both models; after every tick both are scored on the same validation samples, which are never trained on. The same seed therefore replays exactly the same run.

Both models minimize the mean cross-entropy over the batch plus the same L2 penalty, λ2∥W∥2\tfrac{\lambda}{2}\lVert W\rVert^2 with λ=10−4\lambda = 10^{-4} on the weights (biases are not penalized). The physics branch uses plain SGD while the MLP uses Adam, so architecture and optimizer differ, but the experimental stream does not.

What the default run shows

The defaults are 2 updates per 50 ms tick (40 updates per second), batches of 64, SGD learning rate 0.5 for the softmax, Adam learning rate 0.02 for the MLP, and seed 1337. With them the physics-informed model is above 85% validation accuracy after the first second (40 updates), stays above 95% from about 430 updates on (around 11 seconds), and settles at 98–99%, moving by about a third of a percentage point from tick to tick. Its decision field snaps into the iso-range shape almost at once, because that geometry is already encoded in the feature.

The raw MLP reaches about 88% after 200 updates and 91% after 400, and 95% after about 800 (20 seconds). It gets there, but it keeps fluctuating: between 2,000 and 6,000 updates it averages 97% with a standard deviation of 1.5 points and occasional dips below 90%, and its tick-to-tick changes are about three times the physics model's.

The MLP is also more sensitive to the knobs. Averaged over eight seeds, scaling both learning rates by 4 in either direction moves its accuracy after 200 updates between 70% and 92%, against 89% to 97% for the physics model; going from batches of 16 to 256 moves the MLP from 80% to 93% while the physics model stays at 96%. By the end both models perform strongly, but the route they take to get there is meaningfully different.

Implementation notes

The host page uses Next.js, React, and TypeScript for layout and interaction flow. The ML runtime itself is intentionally implemented in vanilla JavaScript inside the embedded visualization.

For this project I deliberately avoid TensorFlow.js or PyTorch. Forward passes, softmax, cross-entropy gradients, SGD updates (physics branch), and Adam updates (MLP branch) are implemented explicitly. That choice keeps every step inspectable and makes it easier to audit where performance differences come from.

Feature design is the real axis of comparison: the raw branch ingests [1,  v/60,  sin⁡θ+ϵ,  cos⁡θ+ϵ][1,\; v/60,\; \sin\theta + \epsilon,\; \cos\theta + \epsilon], while the physics-informed branch uses [1,  R^/100 m][1,\; \hat{R}/100\,\mathrm{m}] with R^=v2sin⁡(2θ)/g\hat{R} = v^2 \sin(2\theta)/g. Both are then trained under the same seeded stream and synchronized evaluation cycle.

Architecturally, this separation of concerns is important: the React layer focuses on narrative and orchestration, and the embedded runtime owns numerical updates plus rendering. Training runs at most one tick per animation frame, so a large setting slows the run down instead of freezing the tab.

The core of the embedded runtime, unedited: the two feature maps, one SGD step of the physics model, the MLP's forward pass, and the update that feeds one shared mini-batch to both models.

const R_UNIT = 100;        // R̂ is fed in units of 100 m so the feature is O(1), like the raw inputs
const L2 = 1e-4;           // same L2 penalty in both models, on weights (not biases)

// The two representations of one launch (v, θ)
const physFeatures = (v, thDeg) => [1, rangeOf(v, thDeg) / R_UNIT];
const rawFeatures = (v, thDeg, e1 = 0, e2 = 0) => [1, v / 60, Math.sin(rad(thDeg)) + e1, Math.cos(rad(thDeg)) + e2];

// Plain SGD on one mini-batch: W ← W − η (mean ∇CE + λW), bias column not penalized
function sgdStepSoftmax(model, X, Y, batch, lr) {
  const W = model.W;
  const grad = zerosLike(W);
  let loss = 0;
  for (const i of batch) {
    const x = X[i];
    const p = model.proba(x);
    loss -= Math.log(p[Y[i]] + 1e-12);
    for (let c = 0; c < W.length; c++) {
      const err = p[c] - (c === Y[i] ? 1 : 0); // ∂CE/∂z_c
      for (let j = 0; j < x.length; j++) grad[c][j] += err * x[j];
    }
  }
  const inv = 1 / batch.length;
  for (let c = 0; c < W.length; c++)
    for (let j = 0; j < W[c].length; j++)
      W[c][j] -= lr * (grad[c][j] * inv + (j > 0 ? L2 * W[c][j] : 0));
  return loss * inv;
}

// Raw branch: 4 → 16 (Leaky-ReLU, slope 0.1) → 3 softmax
class MLP {
  // ...
  forward(x) {
    const z1 = this.W1.map((w, i) => this.b1[i] + dot(w, x));
    const a1 = z1.map((z) => (z > 0 ? z : 0.1 * z));
    const p = softmax(this.W2.map((w, c) => this.b2[c] + dot(w, a1)));
    return { z1, a1, p };
  }
}

// One update: draw one mini-batch (with replacement) and give the same indices to both models
exp.update = (batchSize, lrPhys, lrMlp) => {
  const batch = Array.from({ length: batchSize }, () => (batchRng() * nTrain) | 0);
  const lossPhys = sgdStepSoftmax(phys, data.train.phys, data.train.y, batch, lrPhys);
  const lossMlp = adamStepMLP(mlp, data.train.raw, data.train.y, batch, lrMlp);
  exp.updates += 1;
  exp.samplesSeen += batchSize;
  return { lossPhys, lossMlp };
};