Physics-Informed Neural Network

Build a PINN in PyTorch that combines sparse data with ODE constraints.

Introduction

30 Second Summary

In science and engineering, you often have a physics model that describes how a system behaves, but only a handful of experimental measurements to work with. Traditional machine learning ignores the physics entirely and needs mountains of data to produce accurate predictions.

In this project, you will build a Physics-Informed Neural Network (PINN) that combines sparse experimental data with a known physics equation to predict chemical reaction kinetics more accurately than a data-only model.

What You'll Build

A neural network that solves a nonlinear ODE for chemical reaction kinetics by embedding the physics directly into the network's loss function, built from scratch in PyTorch.

By the end of this project, you'll have:

  • A working PINN trained with both sparse experimental data and physics constraints, producing better predictions than a data-only model.
  • A side-by-side comparison plot proving quantitatively that physics-informed training outperforms data-only training on sparse data.
  • A reproducible GitHub repository with a CLAUDE.md file that configures Claude Code for your ML workflow.
  • Secret Mission: Use the PINN to discover an unknown reaction rate parameter from data alone.

Are there any prerequisites?

You should have basic Python knowledge (variables, functions, loops) and some familiarity with differential equations (ODEs). You do not need prior experience with PyTorch or neural networks.

Before We Start

Before We Start...

Set Up Your Environment and Repository

To build a Physics-Informed Neural Network, you need a reproducible Python environment with the right machine learning libraries installed.

In this step, you will create a project folder, install PyTorch and supporting libraries, initialize a Git repository, and generate the synthetic data your neural network will train on.

In this step, get ready to:
  • Create a virtual environment and install PyTorch, NumPy, and Matplotlib.
  • Initialize a GitHub repository with a CLAUDE.md configuration file.
  • Generate synthetic experimental data for training.
Create a virtual environment and install dependencies

A virtual environment isolates your project's packages from the rest of your system. This keeps your PINN dependencies self-contained and reproducible.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir pinn-project
cd pinn-project
  • Create a Python virtual environment by running:
python3 -m venv .venv
  • Activate the virtual environment by running:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\Activate.ps1

You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

  • Install PyTorch, NumPy, and Matplotlib by running:
pip3 install torch torchvision numpy matplotlib

What does this install?

This installs four packages into your virtual environment. torch is the PyTorch deep learning framework you will use to build and train your neural network. torchvision provides utilities that torch depends on.

numpy handles numerical arrays for data generation. matplotlib creates the comparison plots.

  • Verify that PyTorch installed correctly by running:
python3 -c "import torch; print(torch.__version__)"

You should see a version number printed, such as 2.12.0. Any 2.x version confirms your install is working.

Seeing an error during install?

If you see a permissions error, make sure your virtual environment is active (look for (.venv) in your prompt). If pip3 is not found, try pip instead.

If torch fails to import, confirm you are running python3 from inside the activated virtual environment, not your system Python.

Help me troubleshoot my PyTorch installation

Initialize the GitHub repository and CLAUDE.md

A CLAUDE.md file at the root of your project tells Claude Code about your conventions, directory structure, and domain context. Claude Code reads this file automatically at the start of every session.

  • Initialize a Git repository by running:
git init
  • Create the .gitignore file with standard Python ignores. Paste this into a new file called .gitignore in your project root:
# Byte-compiled files
__pycache__/
*.py[cod]
*$py.class

# Virtual environments
.venv/
env/
venv/

# Distribution / packaging
build/
dist/
*.egg-info/

# IDE
.idea/
.vscode/

# OS files
.DS_Store

# Environment variables
.env

What does .gitignore do?

The .gitignore file tells Git which files to exclude from version control. The virtual environment folder (.venv/) and compiled Python files (__pycache__/) are large and machine-specific, so you do not want them in your repository.

  • Create the CLAUDE.md file in your project root with the following content:
# PINN Project - Physics-Informed Neural Networks

## Stack
- Python 3.10+, PyTorch, NumPy, Matplotlib
- Data: data/ (synthetic experimental and simulation datasets)
- Models: results/ (saved .pt model weights and .png plots)
- Source: src/ (all Python modules)

## Rules - Always Apply
- Use RANDOM_STATE = 42 everywhere for reproducibility
- All plots saved to results/ directory
- Use torch.manual_seed(RANDOM_STATE) before any random operation
- Use numpy random Generator with seed for data generation
- Physics domain: chemical reaction kinetics, concentration decay ODE

## Physics Context
- ODE: dC/dt = -k * C^n (nonlinear reaction kinetics)
- True parameters: k=0.1, n=2, C0=1.0
- Analytical solution: C(t) = C0 / (1 + k*C0*t) for n=2
- Time domain: t in [0, 10]

Why create a CLAUDE.md?

Claude Code reads CLAUDE.md at the start of every session. By encoding your random seed convention (RANDOM_STATE = 42), directory structure, and physics context here, any code Claude Code generates will follow your project's rules automatically.

  • Create the project directory structure by running:
mkdir src data results
  • Confirm the directories exist by running:
ls

You should see CLAUDE.md, data, results, src, and .gitignore listed in the output.

Create the synthetic experimental data

Real experiments are expensive and slow. For this project, you will generate synthetic data that mimics a real chemical reaction. This lets you validate your PINN against a known ground truth.

Why synthetic data?

The ODE dC/dt = -k * C^n describes how a reactant's concentration decays over time. With n=2, the exact solution is C(t) = C0 / (1 + k*C0*t).

You will generate sparse noisy "experimental" data from this solution, then later see if the PINN can recover the true curve even between measurement points.

  • Create src/generate_data.py with the following code:
import numpy as np
import os

RANDOM_STATE = 42

def analytical_solution(t, k=0.1, n=2, C0=1.0):
    """Exact solution for dC/dt = -k * C^n with n=2."""
    return C0 / (1 + k * C0 * t)

def generate_datasets():
    rng = np.random.default_rng(RANDOM_STATE)

    # Sparse "experimental" data (15 noisy points)
    t_exp = np.sort(rng.uniform(0, 10, 15))
    C_exp_true = analytical_solution(t_exp)
    noise = rng.normal(0, 0.02, size=C_exp_true.shape)
    C_exp = C_exp_true + noise

    # Dense "simulation" data (100 clean points)
    t_sim = np.linspace(0, 10, 100)
    C_sim = analytical_solution(t_sim)

    # Save datasets
    os.makedirs("data", exist_ok=True)
    np.save("data/t_experimental.npy", t_exp)
    np.save("data/C_experimental.npy", C_exp)
    np.save("data/t_simulation.npy", t_sim)
    np.save("data/C_simulation.npy", C_sim)

    print(f"Generated {len(t_exp)} experimental points (noisy)")
    print(f"Generated {len(t_sim)} simulation points (clean)")

if __name__ == "__main__":
    generate_datasets()

What does this code do?

The analytical_solution function computes the exact concentration at any time t using the closed-form ODE solution. This is your ground truth.

The generate_datasets function creates two datasets. The "experimental" set has only 15 randomly placed points with added Gaussian noise (standard deviation 0.02) to simulate real measurement error. The "simulation" set has 100 evenly spaced clean points for reference.

Using np.random.default_rng(RANDOM_STATE) ensures every run produces identical data, making your results reproducible.

  • Save src/generate_data.py.
  • Run the data generation script:
python3 src/generate_data.py

You should see this output:

Generated 15 experimental points (noisy)
Generated 100 simulation points (clean)
  • Confirm the data files were saved by running:
ls data/

You should see C_experimental.npy, C_simulation.npy, t_experimental.npy, and t_simulation.npy listed.

Seeing a ModuleNotFoundError?

If Python cannot find numpy, make sure your virtual environment is still active. Check for (.venv) in your terminal prompt.

If it disappeared, re-activate with source .venv/bin/activate (macOS) or .venv\Scripts\Activate.ps1 (Windows).

Help me fix a ModuleNotFoundError when running generate_data.py

✔️ Awesome, I've got everything!

Great. Double check you have saved src/generate_data.py and that the four .npy files exist in your data/ folder.

ⓧ I'd like to double check the full code

Here is the complete src/generate_data.py file:

import numpy as np
import os

RANDOM_STATE = 42

def analytical_solution(t, k=0.1, n=2, C0=1.0):
    """Exact solution for dC/dt = -k * C^n with n=2."""
    return C0 / (1 + k * C0 * t)

def generate_datasets():
    rng = np.random.default_rng(RANDOM_STATE)

    # Sparse "experimental" data (15 noisy points)
    t_exp = np.sort(rng.uniform(0, 10, 15))
    C_exp_true = analytical_solution(t_exp)
    noise = rng.normal(0, 0.02, size=C_exp_true.shape)
    C_exp = C_exp_true + noise

    # Dense "simulation" data (100 clean points)
    t_sim = np.linspace(0, 10, 100)
    C_sim = analytical_solution(t_sim)

    # Save datasets
    os.makedirs("data", exist_ok=True)
    np.save("data/t_experimental.npy", t_exp)
    np.save("data/C_experimental.npy", C_exp)
    np.save("data/t_simulation.npy", t_sim)
    np.save("data/C_simulation.npy", C_sim)

    print(f"Generated {len(t_exp)} experimental points (noisy)")
    print(f"Generated {len(t_sim)} simulation points (clean)")

if __name__ == "__main__":
    generate_datasets()
  • Commit your work and push to GitHub by running:
git add .
git commit -m "Initial setup: venv, CLAUDE.md, data generation"

How do I push to GitHub?

If you have the GitHub CLI (gh) installed, you can create and push in one step:

gh repo create pinn-project --public --source=. --push

Otherwise, create a new repository on github.com, then add the remote and push:

git remote add origin https://github.com/[[YOUR_USERNAME="your-github-username"]]/pinn-project.git
git branch -M main
git push -u origin main

Your environment is ready and your synthetic data is waiting. Next up, you will build a neural network from scratch in PyTorch and see how it performs on this sparse data.

Build a Neural Network from Scratch

In the previous step you generated synthetic experimental data for a chemical reaction. Now you have 15 noisy data points to work with.

The challenge is clear: can a neural network learn the true concentration curve from only 15 noisy measurements? In this step, you will build a Physics-Informed Neural Network class from scratch in PyTorch, train it using only data, and observe where that approach breaks down.

In this step, get ready to:
  • Define a PINN class with hidden layers, Tanh activations, and a physics loss method.
  • Train the network using only the sparse experimental data.
  • Plot the data-only predictions and observe overfitting between data points.
Define the PINN architecture in PyTorch

A neural network is a stack of linear transformations with nonlinear activation functions between them. For this problem, the network takes a single input (time t) and outputs a single value (concentration C).

  • Create a new file src/pinn.py and add the PINN class definition with its constructor and forward method:
import torch
import torch.nn as nn

class PINN(nn.Module):
    """Physics-Informed Neural Network for reaction kinetics."""

    def __init__(self, hidden_size=32, num_hidden=3):
        super().__init__()

        # Build the network: input -> hidden layers -> output
        layers = [nn.Linear(1, hidden_size), nn.Tanh()]
        for _ in range(num_hidden - 1):
            layers.append(nn.Linear(hidden_size, hidden_size))
            layers.append(nn.Tanh())
        layers.append(nn.Linear(hidden_size, 1))

        self.network = nn.Sequential(*layers)

    def forward(self, t):
        return self.network(t)

What does this code do?

The PINN class inherits from nn.Module, which is PyTorch's base class for all neural networks. The constructor builds 3 hidden layers of 32 neurons each.

Each layer uses nn.Linear (a fully connected layer that applies y = xA^T + b) followed by nn.Tanh() activation. Tanh is smooth and infinitely differentiable, which is critical because the physics loss will differentiate through the entire network.

The forward method simply passes input through all layers in sequence. PyTorch's nn.Sequential handles chaining them together automatically.

Now add the physics loss method below the forward method. This is what makes the network "physics-informed." It computes how much the network's predictions violate the known ODE.

  • Add the physics_loss method to the PINN class below the forward method:
    def physics_loss(self, t_collocation, k=0.1, n=2):
        """Compute ODE residual: dC/dt + k * C^n = 0."""
        # Enable gradient tracking on input time points
        t_collocation = t_collocation.requires_grad_(True)
        C_pred = self.forward(t_collocation)

        # Compute dC/dt using automatic differentiation
        dC_dt = torch.autograd.grad(
            outputs=C_pred,
            inputs=t_collocation,
            grad_outputs=torch.ones_like(C_pred),
            create_graph=True,
            retain_graph=True
        )[0]

        # ODE residual: should be zero if physics is satisfied
        residual = dC_dt + k * C_pred ** n
        return torch.mean(residual ** 2)

What does this code do?

This method enforces the physics equation dC/dt = -k * C^n. It passes collocation points (uniformly sampled time points) through the network to get predicted concentrations.

Then it uses torch.autograd.grad with create_graph=True to compute the derivative dC/dt through the network. Setting create_graph=True builds a differentiable graph of the derivative itself, so the optimizer can backpropagate through this computation.

The residual (dC/dt + k * C^n) should be zero everywhere if the network's predictions satisfy the ODE. The method returns the mean squared residual as a loss value. You will use this in the next step to train the PINN.

  • Save src/pinn.py.

Seeing an indentation error?

The physics_loss method must be indented inside the PINN class, at the same level as forward. Make sure it uses exactly 4 spaces of indentation from the class body.

help me fix my pinn.py indentation

✔️ Awesome, I've got everything!

Great. Double check you have saved src/pinn.py.

ⓧ I'd like to double check the full code

import torch
import torch.nn as nn

class PINN(nn.Module):
    """Physics-Informed Neural Network for reaction kinetics."""

    def __init__(self, hidden_size=32, num_hidden=3):
        super().__init__()

        layers = [nn.Linear(1, hidden_size), nn.Tanh()]
        for _ in range(num_hidden - 1):
            layers.append(nn.Linear(hidden_size, hidden_size))
            layers.append(nn.Tanh())
        layers.append(nn.Linear(hidden_size, 1))

        self.network = nn.Sequential(*layers)

    def forward(self, t):
        return self.network(t)

    def physics_loss(self, t_collocation, k=0.1, n=2):
        """Compute ODE residual: dC/dt + k * C^n = 0."""
        t_collocation = t_collocation.requires_grad_(True)
        C_pred = self.forward(t_collocation)

        dC_dt = torch.autograd.grad(
            outputs=C_pred,
            inputs=t_collocation,
            grad_outputs=torch.ones_like(C_pred),
            create_graph=True,
            retain_graph=True
        )[0]

        residual = dC_dt + k * C_pred ** n
        return torch.mean(residual ** 2)
Implement the data-only training loop

With the network defined, you need a training loop that teaches it to match the experimental data. The loop follows PyTorch's standard pattern: zero the gradients, make a prediction, compute loss, backpropagate, and update weights.

  • Create a new file src/train.py and add the imports and train_data_only function:
import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt
import os
from pinn import PINN
from generate_data import analytical_solution

RANDOM_STATE = 42

def train_data_only(epochs=5000, lr=1e-3):
    """Train using only experimental data (no physics)."""
    torch.manual_seed(RANDOM_STATE)

    # Load the sparse experimental data
    t_exp = np.load("data/t_experimental.npy")
    C_exp = np.load("data/C_experimental.npy")

    # Convert to PyTorch tensors with shape (N, 1)
    t_tensor = torch.tensor(t_exp, dtype=torch.float32).unsqueeze(1)
    C_tensor = torch.tensor(C_exp, dtype=torch.float32).unsqueeze(1)

    model = PINN()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.MSELoss()

    for epoch in range(epochs):
        optimizer.zero_grad()
        C_pred = model(t_tensor)
        loss = loss_fn(C_pred, C_tensor)
        loss.backward()
        optimizer.step()

        if (epoch + 1) % 1000 == 0:
            print(f"Epoch {epoch+1}/{epochs}, Loss: {loss.item():.6f}")

    os.makedirs("results", exist_ok=True)
    torch.save(model.state_dict(), "results/model_data_only.pt")
    return model

What does this code do?

The train_data_only function loads the 15 experimental data points and converts them to PyTorch tensors. The .unsqueeze(1) reshapes each 1D array into a column vector with shape (N, 1), which is what nn.Linear expects.

The training loop runs for 5000 epochs. Each epoch: zero old gradients, predict C from t, compute MSE loss against the real data, backpropagate the error, and update the weights via Adam optimizer. This is the standard PyTorch training pattern.

After training, it saves the model weights to results/model_data_only.pt so you can reload the model later without retraining.

Now add the physics-informed training function below train_data_only. You will use this in the next step, but writing it now keeps all training logic in one file.

  • Add the train_pinn function below train_data_only in src/train.py:
def train_pinn(epochs=5000, lr=1e-3, lambda_phys=1.0, n_collocation=200):
    """Train with combined data + physics loss."""
    torch.manual_seed(RANDOM_STATE)

    t_exp = np.load("data/t_experimental.npy")
    C_exp = np.load("data/C_experimental.npy")

    t_tensor = torch.tensor(t_exp, dtype=torch.float32).unsqueeze(1)
    C_tensor = torch.tensor(C_exp, dtype=torch.float32).unsqueeze(1)

    model = PINN()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.MSELoss()

    for epoch in range(epochs):
        optimizer.zero_grad()

        # Data loss
        C_pred = model(t_tensor)
        loss_data = loss_fn(C_pred, C_tensor)

        # Physics loss on collocation points
        t_collocation = torch.linspace(0, 10, n_collocation).unsqueeze(1)
        loss_physics = model.physics_loss(t_collocation, k=0.1, n=2)

        # Combined loss
        loss_total = loss_data + lambda_phys * loss_physics
        loss_total.backward()
        optimizer.step()

        if (epoch + 1) % 1000 == 0:
            print(f"Epoch {epoch+1}/{epochs}, Data: {loss_data.item():.6f}, "
                  f"Physics: {loss_physics.item():.6f}, Total: {loss_total.item():.6f}")

    os.makedirs("results", exist_ok=True)
    torch.save(model.state_dict(), "results/model_pinn.pt")
    return model

What does this code do?

This function combines two loss terms: the data loss (MSE against experimental points) and the physics loss (ODE residual on 200 collocation points). The parameter lambda_phys controls the balance between them.

You will run this function in the next step to see how adding physics constraints improves predictions. For now, just write it so the file is complete.

  • Save src/train.py.
Plot data-only results

To see how the data-only model performs, you need a plotting function and a script entry point that ties everything together.

  • Add the plot_comparison function and the __main__ block at the bottom of src/train.py:
def plot_comparison(model_data_only, model_pinn):
    """Plot data-only vs PINN predictions."""
    t_exp = np.load("data/t_experimental.npy")
    C_exp = np.load("data/C_experimental.npy")

    # Dense time points for smooth curves
    t_dense = np.linspace(0, 10, 200)
    C_true = analytical_solution(t_dense)

    t_plot = torch.tensor(t_dense, dtype=torch.float32).unsqueeze(1)

    with torch.no_grad():
        C_data_only = model_data_only(t_plot).numpy().flatten()
        C_pinn = model_pinn(t_plot).numpy().flatten()

    plt.figure(figsize=(10, 6))
    plt.plot(t_dense, C_true, "k-", linewidth=2, label="True Solution")
    plt.plot(t_dense, C_data_only, "r--", linewidth=2, label="Data-Only NN")
    plt.plot(t_dense, C_pinn, "b-.", linewidth=2, label="PINN (Data + Physics)")
    plt.scatter(t_exp, C_exp, c="green", s=50, zorder=5, label="Training Data")
    plt.xlabel("Time")
    plt.ylabel("Concentration")
    plt.title("Data-Only vs Physics-Informed Neural Network")
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.savefig("results/comparison.png", dpi=150)
    plt.close()
    print("Saved comparison plot to results/comparison.png")

if __name__ == "__main__":
    print("=" * 50)
    print("Training data-only model...")
    print("=" * 50)
    model_do = train_data_only()

    print("\n" + "=" * 50)
    print("Training PINN model...")
    print("=" * 50)
    model_pinn = train_pinn()

    print("\n" + "=" * 50)
    print("Generating comparison plot...")
    print("=" * 50)
    plot_comparison(model_do, model_pinn)

What does this code do?

The plot_comparison function generates predictions from both models across 200 dense time points and plots them against the true analytical solution. The green scatter points show the sparse training data.

The __main__ block runs both training functions in sequence, then creates the comparison plot. When you run this script, it trains the data-only model first, then the PINN, then plots both together.

  • Save src/train.py.
  • Run the training script by running this command from your project root:
cd src && python3 train.py

You should see epoch-by-epoch loss values printed for both models. The data-only model trains first (about 10-20 seconds), then the PINN trains (about 30-60 seconds due to the physics loss computation).

What should I see in the output?

The data-only model's loss will drop quickly and stabilize near zero because it only has 15 points to memorize. The PINN's output shows three loss values: data loss, physics loss, and total loss.

After training completes, open results/comparison.png. The red dashed line (data-only model) hits the green training points but wobbles between them. The blue dash-dot line (PINN) follows the true black curve closely, even between data points. This is the power of physics constraints.

Seeing a ModuleNotFoundError?

If Python says it cannot find pinn or generate_data, make sure you are running the script from inside the src/ directory. The imports assume the working directory is src/ where both pinn.py and generate_data.py live. Use cd src before running python3 train.py.

help me fix the ModuleNotFoundError when running train.py

✔️ Awesome, I've got everything!

Great. Double check you have saved src/train.py and confirmed the comparison plot exists in results/.

ⓧ I'd like to double check the full code

import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt
import os
from pinn import PINN
from generate_data import analytical_solution

RANDOM_STATE = 42

def train_data_only(epochs=5000, lr=1e-3):
    """Train using only experimental data (no physics)."""
    torch.manual_seed(RANDOM_STATE)

    t_exp = np.load("data/t_experimental.npy")
    C_exp = np.load("data/C_experimental.npy")

    t_tensor = torch.tensor(t_exp, dtype=torch.float32).unsqueeze(1)
    C_tensor = torch.tensor(C_exp, dtype=torch.float32).unsqueeze(1)

    model = PINN()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.MSELoss()

    for epoch in range(epochs):
        optimizer.zero_grad()
        C_pred = model(t_tensor)
        loss = loss_fn(C_pred, C_tensor)
        loss.backward()
        optimizer.step()

        if (epoch + 1) % 1000 == 0:
            print(f"Epoch {epoch+1}/{epochs}, Loss: {loss.item():.6f}")

    os.makedirs("results", exist_ok=True)
    torch.save(model.state_dict(), "results/model_data_only.pt")
    return model

def train_pinn(epochs=5000, lr=1e-3, lambda_phys=1.0, n_collocation=200):
    """Train with combined data + physics loss."""
    torch.manual_seed(RANDOM_STATE)

    t_exp = np.load("data/t_experimental.npy")
    C_exp = np.load("data/C_experimental.npy")

    t_tensor = torch.tensor(t_exp, dtype=torch.float32).unsqueeze(1)
    C_tensor = torch.tensor(C_exp, dtype=torch.float32).unsqueeze(1)

    model = PINN()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.MSELoss()

    for epoch in range(epochs):
        optimizer.zero_grad()

        # Data loss
        C_pred = model(t_tensor)
        loss_data = loss_fn(C_pred, C_tensor)

        # Physics loss on collocation points
        t_collocation = torch.linspace(0, 10, n_collocation).unsqueeze(1)
        loss_physics = model.physics_loss(t_collocation, k=0.1, n=2)

        # Combined loss
        loss_total = loss_data + lambda_phys * loss_physics
        loss_total.backward()
        optimizer.step()

        if (epoch + 1) % 1000 == 0:
            print(f"Epoch {epoch+1}/{epochs}, Data: {loss_data.item():.6f}, "
                  f"Physics: {loss_physics.item():.6f}, Total: {loss_total.item():.6f}")

    os.makedirs("results", exist_ok=True)
    torch.save(model.state_dict(), "results/model_pinn.pt")
    return model

def plot_comparison(model_data_only, model_pinn):
    """Plot data-only vs PINN predictions."""
    t_exp = np.load("data/t_experimental.npy")
    C_exp = np.load("data/C_experimental.npy")

    t_dense = np.linspace(0, 10, 200)
    C_true = analytical_solution(t_dense)

    t_plot = torch.tensor(t_dense, dtype=torch.float32).unsqueeze(1)

    with torch.no_grad():
        C_data_only = model_data_only(t_plot).numpy().flatten()
        C_pinn = model_pinn(t_plot).numpy().flatten()

    plt.figure(figsize=(10, 6))
    plt.plot(t_dense, C_true, "k-", linewidth=2, label="True Solution")
    plt.plot(t_dense, C_data_only, "r--", linewidth=2, label="Data-Only NN")
    plt.plot(t_dense, C_pinn, "b-.", linewidth=2, label="PINN (Data + Physics)")
    plt.scatter(t_exp, C_exp, c="green", s=50, zorder=5, label="Training Data")
    plt.xlabel("Time")
    plt.ylabel("Concentration")
    plt.title("Data-Only vs Physics-Informed Neural Network")
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.savefig("results/comparison.png", dpi=150)
    plt.close()
    print("Saved comparison plot to results/comparison.png")

if __name__ == "__main__":
    print("=" * 50)
    print("Training data-only model...")
    print("=" * 50)
    model_do = train_data_only()

    print("\n" + "=" * 50)
    print("Training PINN model...")
    print("=" * 50)
    model_pinn = train_pinn()

    print("\n" + "=" * 50)
    print("Generating comparison plot...")
    print("=" * 50)
    plot_comparison(model_do, model_pinn)

Your data-only model memorizes the 15 training points perfectly but has no idea what happens between them. In the next step, you will dig deeper into how the physics loss acts as a regularizer and run experiments to quantify exactly when physics-informed training shines.

Add Physics-Informed Loss

In the last step, you trained a data-only model and saw it overfit between sparse data points. The network had no way to know what physically plausible behavior looks like between those 15 measurements.

This is where the physics-informed approach changes everything. By encoding the governing ODE directly into the loss function, you force the network to produce solutions that respect the underlying physics. The result is a model that generalizes far better from sparse data.

In this step, get ready to:
  • Walk through the physics-informed loss function that computes the ODE residual.
  • Train the PINN with combined data + physics loss.
  • Compare data-only vs. PINN predictions side by side.
Understand the physics residual

The core idea of a PINN is to penalize the network whenever its predictions violate the known physics. Your physics_loss method in src/pinn.py does exactly this.

  • Open src/pinn.py and locate the physics_loss method at the bottom of the PINN class:
    def physics_loss(self, t_collocation, k=0.1, n=2):
        """Compute ODE residual: dC/dt + k * C^n = 0."""
        t_collocation = t_collocation.requires_grad_(True)
        C_pred = self.forward(t_collocation)

        dC_dt = torch.autograd.grad(
            outputs=C_pred,
            inputs=t_collocation,
            grad_outputs=torch.ones_like(C_pred),
            create_graph=True,
            retain_graph=True
        )[0]

        residual = dC_dt + k * C_pred ** n
        return torch.mean(residual ** 2)

What does this code do?

This method computes how badly the network violates the ODE at a set of "collocation points." Here is the breakdown:

  • t_collocation.requires_grad_(True) tells PyTorch to track gradients through these time points so we can differentiate the network output with respect to time.
  • torch.autograd.grad computes dC/dt by differentiating the network's concentration prediction with respect to its time input. The create_graph=True flag keeps the computation graph alive so the optimizer can backpropagate through this derivative.
  • The residual dC_dt + k * C_pred ** n should equal zero if the network perfectly satisfies the ODE. The MSE of this residual is the physics loss.

What are collocation points?

Collocation points are uniformly sampled points across the time domain where you enforce the physics equation. You don't need measured data at these points. You only need the ODE itself to hold there.

This is the key power of PINNs: you can enforce physics at hundreds of points even if you only have 15 measurements. The physics equation acts as free supervision.

Walk through the combined PINN training loop

The training loop combines two loss signals: how well the network fits the data, and how well it satisfies the physics. The train_pinn function in src/train.py implements this combined approach.

  • Switch to src/train.py and locate the train_pinn function. Look at the core of the training loop:
    for epoch in range(epochs):
        optimizer.zero_grad()

        # Data loss
        C_pred = model(t_tensor)
        loss_data = loss_fn(C_pred, C_tensor)

        # Physics loss on collocation points
        t_collocation = torch.linspace(0, 10, n_collocation).unsqueeze(1)
        loss_physics = model.physics_loss(t_collocation, k=0.1, n=2)

        # Combined loss
        loss_total = loss_data + lambda_phys * loss_physics
        loss_total.backward()
        optimizer.step()

What's happening here?

  • loss_data is the standard MSE between the network's predictions and the 15 experimental data points.
  • loss_physics measures how much the network violates the ODE at 200 evenly spaced collocation points. No measured data is needed here.
  • loss_total = loss_data + lambda_phys * loss_physics combines both. With lambda_phys=1.0, both losses contribute equally.

The optimizer now gets gradient signals from both the data and the physics. Between data points (where data loss is silent), the physics loss alone guides the network toward physically plausible solutions.

Why equal weighting?

Setting lambda_phys=1.0 means the data loss and physics loss contribute equally. Too low and the physics is ignored. Too high and the data is ignored. You will explore this tradeoff in the next step.

Run the training and compare results

Now run the full training script. It retrains the data-only model, trains the PINN, then generates a side-by-side comparison plot.

  • Run the training script from your project root by running this command:
python3 src/train.py

What should I see?

The script prints loss values every 1000 epochs for both models. For the data-only model, you see a single loss value. For the PINN, you see three values: Data loss, Physics loss, and Total loss.

Training takes about 30-60 seconds on a CPU. Watch the physics loss decrease over time as the network learns to satisfy the ODE.

  • Confirm the PINN model was saved by running:
ls results/
  • You should see model_data_only.pt, model_pinn.pt, data_only_fit.png, and comparison.png listed.

Seeing a ModuleNotFoundError?

  • Make sure you are running the command from your project root directory (the folder containing src/, data/, and results/), not from inside src/.
  • Confirm your virtual environment is still active. You should see (.venv) at the beginning of your terminal prompt.
  • If you see a file-not-found error for the .npy files, re-run python3 src/generate_data.py first to regenerate the data.

Why is my PINN training script failing?

  • Open results/comparison.png in your file browser or image viewer.

You should see four elements on the plot: the true solution in black, the data-only model in red (dashed), the PINN in blue (dash-dot), and the green scatter points showing your 15 training measurements.

Why does the PINN perform better?

The data-only model (red) wiggles between data points because it has no guidance on what physically valid behavior looks like in those gaps. The PINN (blue) follows the true solution closely even where no data exists.

The physics loss acts as a regularizer. At every one of those 200 collocation points, the network is penalized for violating dC/dt = -k * C^n. This prevents unphysical oscillations and guides the model toward the correct solution shape.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both src/pinn.py and src/train.py, and that results/comparison.png is visible.

ⓧ I'd like to double check the full code

Here is the complete src/pinn.py:

import torch
import torch.nn as nn

class PINN(nn.Module):
    """Physics-Informed Neural Network for reaction kinetics."""

    def __init__(self, hidden_size=32, num_hidden=3):
        super().__init__()

        layers = [nn.Linear(1, hidden_size), nn.Tanh()]
        for _ in range(num_hidden - 1):
            layers.append(nn.Linear(hidden_size, hidden_size))
            layers.append(nn.Tanh())
        layers.append(nn.Linear(hidden_size, 1))

        self.network = nn.Sequential(*layers)

    def forward(self, t):
        return self.network(t)

    def physics_loss(self, t_collocation, k=0.1, n=2):
        """Compute ODE residual: dC/dt + k * C^n = 0."""
        t_collocation = t_collocation.requires_grad_(True)
        C_pred = self.forward(t_collocation)

        dC_dt = torch.autograd.grad(
            outputs=C_pred,
            inputs=t_collocation,
            grad_outputs=torch.ones_like(C_pred),
            create_graph=True,
            retain_graph=True
        )[0]

        residual = dC_dt + k * C_pred ** n
        return torch.mean(residual ** 2)

And here is the complete src/train.py:

import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt
import os
from pinn import PINN
from generate_data import analytical_solution

RANDOM_STATE = 42

def train_data_only(epochs=5000, lr=1e-3):
    """Train using only experimental data (no physics)."""
    torch.manual_seed(RANDOM_STATE)

    t_exp = np.load("data/t_experimental.npy")
    C_exp = np.load("data/C_experimental.npy")

    t_tensor = torch.tensor(t_exp, dtype=torch.float32).unsqueeze(1)
    C_tensor = torch.tensor(C_exp, dtype=torch.float32).unsqueeze(1)

    model = PINN()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.MSELoss()

    for epoch in range(epochs):
        optimizer.zero_grad()
        C_pred = model(t_tensor)
        loss = loss_fn(C_pred, C_tensor)
        loss.backward()
        optimizer.step()

        if (epoch + 1) % 1000 == 0:
            print(f"Epoch {epoch+1}/{epochs}, Loss: {loss.item():.6f}")

    os.makedirs("results", exist_ok=True)
    torch.save(model.state_dict(), "results/model_data_only.pt")
    return model

def train_pinn(epochs=5000, lr=1e-3, lambda_phys=1.0, n_collocation=200):
    """Train with combined data + physics loss."""
    torch.manual_seed(RANDOM_STATE)

    t_exp = np.load("data/t_experimental.npy")
    C_exp = np.load("data/C_experimental.npy")

    t_tensor = torch.tensor(t_exp, dtype=torch.float32).unsqueeze(1)
    C_tensor = torch.tensor(C_exp, dtype=torch.float32).unsqueeze(1)

    model = PINN()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.MSELoss()

    for epoch in range(epochs):
        optimizer.zero_grad()

        # Data loss
        C_pred = model(t_tensor)
        loss_data = loss_fn(C_pred, C_tensor)

        # Physics loss on collocation points
        t_collocation = torch.linspace(0, 10, n_collocation).unsqueeze(1)
        loss_physics = model.physics_loss(t_collocation, k=0.1, n=2)

        # Combined loss
        loss_total = loss_data + lambda_phys * loss_physics
        loss_total.backward()
        optimizer.step()

        if (epoch + 1) % 1000 == 0:
            print(f"Epoch {epoch+1}/{epochs}, Data: {loss_data.item():.6f}, "
                  f"Physics: {loss_physics.item():.6f}, Total: {loss_total.item():.6f}")

    os.makedirs("results", exist_ok=True)
    torch.save(model.state_dict(), "results/model_pinn.pt")
    return model

def plot_comparison(model_data_only, model_pinn):
    """Plot data-only vs PINN predictions."""
    t_exp = np.load("data/t_experimental.npy")
    C_exp = np.load("data/C_experimental.npy")

    t_dense = np.linspace(0, 10, 200)
    C_true = analytical_solution(t_dense)

    t_plot = torch.tensor(t_dense, dtype=torch.float32).unsqueeze(1)

    with torch.no_grad():
        C_data_only = model_data_only(t_plot).numpy().flatten()
        C_pinn = model_pinn(t_plot).numpy().flatten()

    plt.figure(figsize=(10, 6))
    plt.plot(t_dense, C_true, "k-", linewidth=2, label="True Solution")
    plt.plot(t_dense, C_data_only, "r--", linewidth=2, label="Data-Only NN")
    plt.plot(t_dense, C_pinn, "b-.", linewidth=2, label="PINN (Data + Physics)")
    plt.scatter(t_exp, C_exp, c="green", s=50, zorder=5, label="Training Data")
    plt.xlabel("Time")
    plt.ylabel("Concentration")
    plt.title("Data-Only vs Physics-Informed Neural Network")
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.savefig("results/comparison.png", dpi=150)
    plt.close()
    print("Saved comparison plot to results/comparison.png")

if __name__ == "__main__":
    print("=" * 50)
    print("Training data-only model...")
    print("=" * 50)
    model_do = train_data_only()

    print("\n" + "=" * 50)
    print("Training PINN model...")
    print("=" * 50)
    model_pinn = train_pinn()

    print("\n" + "=" * 50)
    print("Generating comparison plot...")
    print("=" * 50)
    plot_comparison(model_do, model_pinn)

You now have quantitative proof that combining data with physics produces a better model on sparse data. Next up, you will explore exactly how much this advantage depends on data sparsity and the physics weight.

Experiment and Analyze

Your comparison plot from the last step proved that physics constraints improve predictions on sparse data. But one comparison at one data size with one physics weight is just a single data point.

In this step, you'll run systematic experiments to quantify when and why physics-informed training helps. You'll vary the amount of training data and the physics weight to build the kind of evidence you need before applying this technique to a real system.

In this step, get ready to:
  • Vary training data amounts to quantify the PINN advantage over data-only models.
  • Sweep the physics weight lambda to find the sweet spot between data and physics.
  • Commit and push all experiments to GitHub as a reproducible repository.
Vary the data sparsity

The core question in hybrid modeling is: how much does physics help when data is scarce? To answer this systematically, you'll train both model types across different training set sizes and compare their test errors.

You'll start by creating a reusable training helper that toggles the physics loss on and off. This keeps the experiment code clean and ensures fair comparisons.

  • Create a new file src/experiments.py and add the imports, constant, and helper function by pasting this code:
import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt
import os
from pinn import PINN
from generate_data import analytical_solution

RANDOM_STATE = 42

def train_model(t_exp, C_exp, use_physics=False, epochs=5000, lr=1e-3,
                lambda_phys=1.0, n_collocation=200):
    """Train a PINN with optional physics loss."""
    torch.manual_seed(RANDOM_STATE)

    t_tensor = torch.tensor(t_exp, dtype=torch.float32).unsqueeze(1)
    C_tensor = torch.tensor(C_exp, dtype=torch.float32).unsqueeze(1)

    model = PINN()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.MSELoss()

    for epoch in range(epochs):
        optimizer.zero_grad()
        C_pred = model(t_tensor)
        loss = loss_fn(C_pred, C_tensor)

        if use_physics:
            t_collocation = torch.linspace(0, 10, n_collocation).unsqueeze(1)
            loss_physics = model.physics_loss(t_collocation, k=0.1, n=2)
            loss = loss + lambda_phys * loss_physics

        loss.backward()
        optimizer.step()

    return model

What does this code do?

The train_model() helper wraps your earlier training functions into a single reusable function.

  • The use_physics flag controls whether the physics loss is included. When False, the model trains on data alone.
  • The torch.manual_seed(RANDOM_STATE) call ensures every run starts from the same random weights, making comparisons fair.
  • The function accepts training data as arguments so you can easily pass in different-sized datasets for each experiment.
  • Below the train_model() function, add the data sparsity experiment:
def experiment_data_sparsity():
    """Compare models with varying amounts of training data."""
    rng = np.random.default_rng(RANDOM_STATE)
    data_sizes = [5, 10, 15, 30]
    results_do = []
    results_pinn = []

    # Dense test set
    t_test = np.linspace(0, 10, 200)
    C_test = analytical_solution(t_test)
    t_test_tensor = torch.tensor(t_test, dtype=torch.float32).unsqueeze(1)

    for n_points in data_sizes:
        t_exp = np.sort(rng.uniform(0, 10, n_points))
        C_exp = analytical_solution(t_exp) + rng.normal(0, 0.02, n_points)

        # Data-only
        model_do = train_model(t_exp, C_exp, use_physics=False)
        with torch.no_grad():
            pred_do = model_do(t_test_tensor).numpy().flatten()
        mse_do = np.mean((pred_do - C_test) ** 2)
        results_do.append(mse_do)

        # PINN
        model_pinn = train_model(t_exp, C_exp, use_physics=True)
        with torch.no_grad():
            pred_pinn = model_pinn(t_test_tensor).numpy().flatten()
        mse_pinn = np.mean((pred_pinn - C_test) ** 2)
        results_pinn.append(mse_pinn)

        print(f"N={n_points:3d} | Data-only MSE: {mse_do:.6f} | PINN MSE: {mse_pinn:.6f}")

    plt.figure(figsize=(8, 5))
    plt.plot(data_sizes, results_do, "ro-", label="Data-Only NN")
    plt.plot(data_sizes, results_pinn, "bs-", label="PINN")
    plt.xlabel("Number of Training Points")
    plt.ylabel("Test MSE")
    plt.title("Effect of Data Sparsity on Model Accuracy")
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.yscale("log")
    plt.tight_layout()
    plt.savefig("results/sparsity_comparison.png", dpi=150)
    plt.close()
    print("Saved sparsity plot to results/sparsity_comparison.png")

What does this experiment measure?

This function trains both a data-only model and a PINN at each data size (5, 10, 15, and 30 points), then measures how far each model's predictions deviate from the true analytical solution on a dense 200-point test set.

  • The log-scale y-axis makes it easy to see order-of-magnitude differences in error.
  • The PINN should show an increasingly larger advantage as data becomes sparser, because the physics constraint fills in the gaps between data points.
  • Save src/experiments.py.

You won't run the script yet. The next substep adds the lambda experiment and the main runner block.

Vary the physics weight lambda

The physics weight lambda_phys controls how much the model trusts the physics equation versus the data. Too low and physics is ignored. Too high and the model prioritizes satisfying the ODE over fitting the noisy measurements.

  • In src/experiments.py, add the lambda sweep experiment below the experiment_data_sparsity() function:
def experiment_lambda():
    """Explore the effect of physics weight."""
    rng = np.random.default_rng(RANDOM_STATE)
    t_exp = np.sort(rng.uniform(0, 10, 15))
    C_exp = analytical_solution(t_exp) + rng.normal(0, 0.02, 15)

    lambdas = [0.01, 0.1, 1.0, 10.0, 100.0]
    results = []

    t_test = np.linspace(0, 10, 200)
    C_test = analytical_solution(t_test)
    t_test_tensor = torch.tensor(t_test, dtype=torch.float32).unsqueeze(1)

    for lam in lambdas:
        model = train_model(t_exp, C_exp, use_physics=True, lambda_phys=lam)
        with torch.no_grad():
            pred = model(t_test_tensor).numpy().flatten()
        mse = np.mean((pred - C_test) ** 2)
        results.append(mse)
        print(f"lambda={lam:6.2f} | Test MSE: {mse:.6f}")

    plt.figure(figsize=(8, 5))
    plt.plot(lambdas, results, "go-", linewidth=2)
    plt.xlabel("Physics Weight (lambda)")
    plt.ylabel("Test MSE")
    plt.title("Effect of Physics Loss Weight")
    plt.xscale("log")
    plt.yscale("log")
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.savefig("results/lambda_sweep.png", dpi=150)
    plt.close()
    print("Saved lambda sweep plot to results/lambda_sweep.png")

What does this experiment reveal?

This function sweeps five lambda values across three orders of magnitude (0.01 to 100.0) to find the sweet spot between data fidelity and physics compliance.

  • At lambda=0.01, the physics term barely contributes. The model behaves almost like a data-only fit.
  • At lambda=100.0, the physics term dominates. The model satisfies the ODE but may ignore noisy data points.
  • The plot reveals intermediate lambda values give the best predictions. This is the core tradeoff in all hybrid models.
  • Below the experiment_lambda() function, add the main runner block:
if __name__ == "__main__":
    os.makedirs("results", exist_ok=True)

    print("=" * 50)
    print("Experiment 1: Data Sparsity")
    print("=" * 50)
    experiment_data_sparsity()

    print("\n" + "=" * 50)
    print("Experiment 2: Lambda Sweep")
    print("=" * 50)
    experiment_lambda()

What does this block do?

The if __name__ == "__main__": guard ensures the experiments only run when you execute the script directly. It creates the results/ directory if needed, then runs both experiments in sequence.

  • Save src/experiments.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/experiments.py before running it.

ⓧ I'd like to double check the full code

Here is the complete src/experiments.py file:

import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt
import os
from pinn import PINN
from generate_data import analytical_solution

RANDOM_STATE = 42

def train_model(t_exp, C_exp, use_physics=False, epochs=5000, lr=1e-3,
                lambda_phys=1.0, n_collocation=200):
    """Train a PINN with optional physics loss."""
    torch.manual_seed(RANDOM_STATE)

    t_tensor = torch.tensor(t_exp, dtype=torch.float32).unsqueeze(1)
    C_tensor = torch.tensor(C_exp, dtype=torch.float32).unsqueeze(1)

    model = PINN()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.MSELoss()

    for epoch in range(epochs):
        optimizer.zero_grad()
        C_pred = model(t_tensor)
        loss = loss_fn(C_pred, C_tensor)

        if use_physics:
            t_collocation = torch.linspace(0, 10, n_collocation).unsqueeze(1)
            loss_physics = model.physics_loss(t_collocation, k=0.1, n=2)
            loss = loss + lambda_phys * loss_physics

        loss.backward()
        optimizer.step()

    return model

def experiment_data_sparsity():
    """Compare models with varying amounts of training data."""
    rng = np.random.default_rng(RANDOM_STATE)
    data_sizes = [5, 10, 15, 30]
    results_do = []
    results_pinn = []

    # Dense test set
    t_test = np.linspace(0, 10, 200)
    C_test = analytical_solution(t_test)
    t_test_tensor = torch.tensor(t_test, dtype=torch.float32).unsqueeze(1)

    for n_points in data_sizes:
        t_exp = np.sort(rng.uniform(0, 10, n_points))
        C_exp = analytical_solution(t_exp) + rng.normal(0, 0.02, n_points)

        # Data-only
        model_do = train_model(t_exp, C_exp, use_physics=False)
        with torch.no_grad():
            pred_do = model_do(t_test_tensor).numpy().flatten()
        mse_do = np.mean((pred_do - C_test) ** 2)
        results_do.append(mse_do)

        # PINN
        model_pinn = train_model(t_exp, C_exp, use_physics=True)
        with torch.no_grad():
            pred_pinn = model_pinn(t_test_tensor).numpy().flatten()
        mse_pinn = np.mean((pred_pinn - C_test) ** 2)
        results_pinn.append(mse_pinn)

        print(f"N={n_points:3d} | Data-only MSE: {mse_do:.6f} | PINN MSE: {mse_pinn:.6f}")

    plt.figure(figsize=(8, 5))
    plt.plot(data_sizes, results_do, "ro-", label="Data-Only NN")
    plt.plot(data_sizes, results_pinn, "bs-", label="PINN")
    plt.xlabel("Number of Training Points")
    plt.ylabel("Test MSE")
    plt.title("Effect of Data Sparsity on Model Accuracy")
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.yscale("log")
    plt.tight_layout()
    plt.savefig("results/sparsity_comparison.png", dpi=150)
    plt.close()
    print("Saved sparsity plot to results/sparsity_comparison.png")

def experiment_lambda():
    """Explore the effect of physics weight."""
    rng = np.random.default_rng(RANDOM_STATE)
    t_exp = np.sort(rng.uniform(0, 10, 15))
    C_exp = analytical_solution(t_exp) + rng.normal(0, 0.02, 15)

    lambdas = [0.01, 0.1, 1.0, 10.0, 100.0]
    results = []

    t_test = np.linspace(0, 10, 200)
    C_test = analytical_solution(t_test)
    t_test_tensor = torch.tensor(t_test, dtype=torch.float32).unsqueeze(1)

    for lam in lambdas:
        model = train_model(t_exp, C_exp, use_physics=True, lambda_phys=lam)
        with torch.no_grad():
            pred = model(t_test_tensor).numpy().flatten()
        mse = np.mean((pred - C_test) ** 2)
        results.append(mse)
        print(f"lambda={lam:6.2f} | Test MSE: {mse:.6f}")

    plt.figure(figsize=(8, 5))
    plt.plot(lambdas, results, "go-", linewidth=2)
    plt.xlabel("Physics Weight (lambda)")
    plt.ylabel("Test MSE")
    plt.title("Effect of Physics Loss Weight")
    plt.xscale("log")
    plt.yscale("log")
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.savefig("results/lambda_sweep.png", dpi=150)
    plt.close()
    print("Saved lambda sweep plot to results/lambda_sweep.png")

if __name__ == "__main__":
    os.makedirs("results", exist_ok=True)

    print("=" * 50)
    print("Experiment 1: Data Sparsity")
    print("=" * 50)
    experiment_data_sparsity()

    print("\n" + "=" * 50)
    print("Experiment 2: Lambda Sweep")
    print("=" * 50)
    experiment_lambda()
  • Run the experiments from your src/ directory by running this command:
cd src && python3 experiments.py

What should I see?

The script trains 8 models for the sparsity experiment (2 models at each of 4 data sizes) and 5 models for the lambda sweep. This takes 1-2 minutes on a typical machine.

You should see MSE values printed for each configuration, followed by confirmation that both plots were saved. The PINN MSE should be consistently lower than the data-only MSE, especially at smaller data sizes.

  • Confirm the plots were generated by running:
ls results/

You should see sparsity_comparison.png and lambda_sweep.png alongside your earlier result files.

Script erroring out?

  • Make sure you are running the script from inside the src/ directory. The imports from pinn import PINN and from generate_data import analytical_solution rely on being in the same folder as those modules.
  • If training seems frozen, it is not stuck. Each model trains for 5000 epochs without print output inside the helper. Give it up to two minutes.
  • If you see a ModuleNotFoundError, confirm your virtual environment is still activated. You should see (.venv) at the start of your terminal prompt.

Why are my experiments failing or producing unexpected results?

Why does this matter for real hybrid models?

The sparsity plot quantifies the key selling point of physics-informed learning: the fewer data points you have, the more the physics constraint helps. In real engineering, experimental data is expensive and sparse.

The lambda sweep reveals that hybrid modeling requires tuning the balance between data fidelity and physics compliance. This tradeoff appears in every hybrid model you will build.

Commit and push to GitHub

Your repository now contains complete, reproducible evidence that physics-informed training outperforms data-only training on sparse data. Time to commit everything and push.

  • Return to your project root directory by running:
cd ..
  • Stage all files and commit by running these commands:
git add .
git commit -m "Add PINN experiments: data sparsity and lambda sweep"
  • Push to your remote repository by running:
git push origin main

Why reproducibility matters here

Because you set RANDOM_STATE = 42 and seeded both torch.manual_seed() and NumPy's random generator, anyone who clones your repo, installs the dependencies, and runs the scripts will get the same results. This is the standard for shareable ML research.

  • Verify the push succeeded by checking your GitHub repository page in a browser. You should see all your files including src/experiments.py and the results/ directory.

Push rejected or remote not set?

  • If you have not added a remote yet, create a new repository on GitHub and add it with git remote add origin <your-repo-url>.
  • If the push is rejected because the remote has commits you do not have locally, run git pull --rebase origin main first, then push again.

Help me push my PINN project to GitHub.

You now have quantitative evidence that physics constraints act as a powerful regularizer, especially when data is scarce. Next up, you'll take this even further in the Secret Mission by discovering unknown physics parameters directly from data.

Secret mission

Discover Unknown Physics Parameters from Data

Your PINN currently knows the rate constant k=0.1 because you hardcoded it. In real research, you rarely know the true parameters. What if the network could discover them from data alone? In this mission, you'll turn k into a learnable parameter and watch the PINN converge toward the true value using nothing but sparse measurements and the ODE structure.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Python virtual environment (.venv directory).
  • Local project files (src/, data/, results/, CLAUDE.md).
  • GitHub repository.

Keep everything running

No action needed. Choose this if you plan to extend your PINN project further or scale it to real DAE systems.

  • Your virtual environment stays active and ready for new experiments.
  • Your GitHub repo is ready for future commits as you build on this foundation.
  • All saved model weights and plots remain in results/ for reference.

Pause - I'll come back to this later

Shut down the virtual environment to free up terminal state. Your files stay on disk and you can reactivate any time.

  • Deactivate your virtual environment by running:
deactivate

When you come back, reactivate it by running source .venv/bin/activate (macOS/Linux) or .venv\Scripts\activate (Windows) from your project folder.

Delete - I don't want to use this again

Remove all project resources from your local machine and GitHub. You can always recreate the environment from your repo if you change your mind.

  • Deactivate your virtual environment first:
deactivate
  • Remove the virtual environment by running:
rm -rf .venv
  • Remove the entire project folder from your Desktop by running:

macOS

cd ~/Desktop
rm -rf pinn-project

Windows

cd ~\Desktop
rmdir /s /q pinn-project
  • Delete the GitHub repository by going to your repo page on GitHub.
  • Click Settings at the top of the repo page.
  • Scroll to the bottom and click Delete this repository in the Danger Zone section.
  • Confirm by typing the repository name when prompted.

Nice Work!

Nice Work!

Nice work! You just built a Physics-Informed Neural Network from scratch in PyTorch that combines sparse experimental data with known physics to make better predictions than a data-only model.

You've learned how to:

  • Built a neural network from scratch in PyTorch with custom training loops, MSE loss, and the Adam optimizer.
  • Implemented a physics-informed loss function using automatic differentiation to enforce ODE constraints as a regularizer during training.
  • Demonstrated quantitatively that hybrid (data + physics) training outperforms data-only training on sparse data, and explored how data sparsity and physics weight tradeoffs govern model accuracy.
  • Secret Mission: Used the PINN to discover an unknown reaction rate parameter from data alone, the foundation for calibrating real physics models with experimental measurements.

Ready to quiz yourself?