WebPulse: EfficientPhys Training and CI

Train EfficientPhys with leakage checks, checkpoints, and GitHub Actions CI.

Introduction

30 Second Summary

A successful-looking result can hide a basic mistake: the same people may appear in the examples used for learning and in the final check. Engineering tests can prove that a pipeline behaves correctly without proving heart-rate accuracy.

In this project, you will trace WebPulse's EfficientPhys model from raw video frames to predicted pulse values. You will turn that map into a subject-independent CPU smoke-training pipeline protected by continuous integration checks.

What You'll Build

By the end, you will push your branch and see a green ML smoke gates check prove that WebPulse's EfficientPhys training path works on a fresh CPU runner.

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

  • Your paper-to-code map explains how 11 raw RGB frames become 10 first-derivative BVP predictions.
  • A planned subject leakage failure prints overlapping subject IDs. The result shows why random window splits can mislead validation.
  • Your paper-aligned CPU training gate uses AdamW. Its objective is negative Pearson loss. It runs locally or through GitHub Actions. You see a restored checkpoint plus a green ML smoke gates check.
  • Secret Mission: Require the ML smoke gates check before changes can merge into main.

Are there any prerequisites?

You'll need Python 3.10 or later plus permission to push a branch to Devarajan-Maheshwaran/WebPulse. Repository administration permission is only needed for the optional Secret Mission.

Before We Start

This checkpoint sets the boundary for the evidence you are about to create: smoke tests prove engineering code behavior only. Real physiological performance claims require an approved dataset, subject-independent evaluation, and reported metrics.

Prepare a Reproducible CPU Training Environment

The repository currently has a broad unpinned environment. That makes the training path difficult to reproduce on a fresh computer.

A dedicated Python virtual environment isolates the ML dependency. A pinned CPU build of PyTorch keeps the smoke run independent of AMD GPU support.

In this step, get ready to:
  • Prepare WebPulse on the ml-training-ci branch in VS Code.
  • Create the Windows .venv-ml environment with compatible Python.
  • Install the pinned CPU build of PyTorch.
Clone WebPulse and create the branch

Git creates a local copy of WebPulse. Windows PowerShell gives you a consistent place to run the repository commands.

  • Press the Windows key to open search.
  • Type PowerShell into the search field.
  • Press Enter to open Windows PowerShell.
  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The cd command changes your current location. The ~/Desktop path places the WebPulse folder somewhere easy to find.

  • Clone the WebPulse repository onto your Desktop by running this command:
git clone https://github.com/Devarajan-Maheshwaran/WebPulse.git

What does this command do?

The git clone command downloads the public WebPulse repository into a new WebPulse folder. Git also configures the repository remote as origin.

PowerShell prints the files it receives from GitHub. The command prompt returns when the clone is complete.

Clone not completing?

  • Check your internet connection if the download stops before the command prompt returns.
  • Look for an existing WebPulse folder on your Desktop if Git cannot create the destination.
  • Rename the existing folder if it contains work you want to keep.

Need another pair of eyes? Help me troubleshoot the WebPulse clone.

  • Enter the cloned repository by running the commands below:
cd WebPulse
git switch -c ml-training-ci

What do these commands do?

  • The cd WebPulse command moves PowerShell into the cloned repository.
  • The git switch -c ml-training-ci command creates the local branch. It also selects that branch for your work.

PowerShell confirms that Git selected the new ml-training-ci branch.

Branch not created?

  • Confirm that the clone finished before entering the WebPulse folder.
  • Select the existing branch if Git reports that ml-training-ci already exists.

Need help reading the Git response? Help me troubleshoot the branch switch.

  • Press the Windows key to open search.
  • Type VS Code into the search field.
  • Press Enter to open VS Code.
  • Press Ctrl+K Ctrl+O to open the folder picker.
  • Select the WebPulse folder on your Desktop.
  • Press Enter to open the selected folder.

You should see the WebPulse repository files in the file sidebar. The repository is now ready for the isolated training setup.

Verify Python and pin the environment

PyTorch 2.14.1 requires Python 3.10 or later. Checking the interpreter first prevents an incompatible environment from hiding behind a successful folder creation.

  • Return to the Windows PowerShell window from earlier.
  • Confirm the available Python version by running this command:
python --version

What does this command check?

The command prints the Python interpreter selected by PowerShell. The version determines whether the pinned PyTorch package can be installed.

✔️ I see version 3.10 or higher

Your Python interpreter is compatible. You can create the isolated environment below.

ⓧ I see an older version

The current interpreter is too old for PyTorch 2.14.1. Upgrade Python before creating the environment.

  • Download a Windows installer for Python 3.10 or later from the official Python downloads page.
  • Run the downloaded Python installer.
  • Allow the installer to make the new Python installation available to PowerShell.
  • Close the current PowerShell window.
  • Press the Windows key to open search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell window.
  • Confirm the upgraded interpreter by running this command:
python --version

What should this confirm?

A fresh PowerShell window reloads the command mapping. The printed version should now be Python 3.10 or higher.

ⓧ Command not found

PowerShell cannot currently find a Python interpreter. Install a compatible release before continuing.

  • Download a Windows installer for Python 3.10 or later from the official Python downloads page.
  • Run the downloaded Python installer.
  • Allow the installer to make Python available to PowerShell.
  • Close every open PowerShell window.
  • Press the Windows key to open search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell window.
  • Confirm the installation by running this command:
python --version

What should this confirm?

The command should now print Python 3.10 or higher. This proves PowerShell can locate the installed interpreter.

  • Return to the WebPulse folder in PowerShell if you installed or upgraded Python.
  • Create the isolated .venv-ml environment by running this command:
python -m venv .venv-ml

What does this command do?

Python creates a separate interpreter under .venv-ml. Packages installed through that interpreter stay isolated from your system Python.

  • Confirm the environment folder exists by checking the VS Code file sidebar.

You should see a new .venv-ml folder inside WebPulse.

Environment folder missing?

  • Confirm that PowerShell is inside the cloned WebPulse folder.
  • Repeat the Python version check if environment creation stopped.
  • Refresh the VS Code file sidebar if the command completed successfully.

Still stuck? Help me create the Windows virtual environment.

The environment controls where packages are installed. The requirements file controls the exact package version that every machine installs.

  • Use the new-file control beside the WebPulse folder in the VS Code file sidebar.
  • Enter requirements-ml.txt as the file name.
  • Pin the PyTorch dependency by pasting this line into requirements-ml.txt:
torch==2.14.1

What does this dependency pin do?

The double equals sign requires the exact PyTorch release. Every installation from this file requests version 2.14.1.

  • Save requirements-ml.txt by pressing Ctrl+S.
  • Verify the saved file by reopening it from the VS Code file sidebar.

You should see one dependency line containing the exact PyTorch pin.

Dependency line looks different?

  • Check that the file name is exactly requirements-ml.txt.
  • Remove any spaces from around ==.
  • Confirm that the version is 2.14.1.

Need help comparing the file? Help me check the PyTorch requirement.

✔️ Awesome, I've got everything!

Good work. Your dependency file now gives the ML environment a reproducible package pin.

ⓧ I'd like to double check the full code

torch==2.14.1

What should match?

Your complete requirements-ml.txt file should contain this single line. Keep the file inside the WebPulse folder.

Install CPU PyTorch and verify the environment

The CPU package gives the smoke test a hardware-neutral runtime. The dedicated PyTorch package index supplies the CPU wheel requested by this project.

This download can take a while because PyTorch is a large package. A stream of installation output means PowerShell is still working.

  • Install the pinned dependency into .venv-ml from the PyTorch CPU index by running this command:
.venv-ml\Scripts\python.exe -m pip install -r requirements-ml.txt --index-url https://download.pytorch.org/whl/cpu

What does this command do?

  • The .venv-ml\Scripts\python.exe path installs the package into the isolated environment.
  • The -r requirements-ml.txt option reads the exact dependency pin from your file.
  • The --index-url option directs the installation to the PyTorch CPU package index.

PowerShell returns to the command prompt after the installation completes. The final installation lines should indicate that the pinned package was installed.

Installation not completing?

  • Confirm that requirements-ml.txt is inside the open WebPulse folder.
  • Confirm that the command uses the .venv-ml\Scripts\python.exe interpreter.
  • Check your internet connection if package downloads stop.

Need help with the installation output? Help me troubleshoot the CPU PyTorch installation.

Before you run the final check, what two pieces of evidence would prove the environment is ready?

  • Verify the PyTorch import and random tensor operation by running this command:
.venv-ml\Scripts\python.exe -c "import torch; print(torch.__version__); print(torch.rand(2, 3))"

What does this verification prove?

The import confirms that the virtual environment can load PyTorch. The random tensor operation confirms that the installed package can perform a basic CPU computation.

You should first see a version beginning with 2.14.1. Next, you should see a tensor with two rows and three values in each row.

That is the reproducibility baseline locked in. WebPulse now has a verified CPU environment that can run the training work ahead.

Import or tensor check failing?

  • Rerun the installation command if PowerShell reports that the dependency is missing.
  • Confirm that the verification command starts with .venv-ml\Scripts\python.exe.
  • Compare the version pin in requirements-ml.txt with the expected file above.

Still seeing a failure? Help me diagnose the final PyTorch verification.

Your CPU training environment is ready. Next, you will trace how WebPulse turns raw frames into BVP predictions.

Trace the Paper-to-Code Model Contract

Your pinned CPU environment gives WebPulse a reproducible PyTorch runtime. The next safeguard is understanding what the existing EfficientPhys model expects.

Training code is unsafe until you can explain the architecture's preprocessing boundary. Every tensor transition from raw frames to the predicted signal must also be clear.

In this step, get ready to:
  • Compare the EfficientPhys paper with WebPulse's model implementation.
  • Add an executable contract for the model's input and output shapes.
  • Prove that 11 RGB frames produce 10 BVP predictions.
Compare the paper with WebPulse

The paper defines a direct path from raw video frames to a first-derivative BVP signal. WebPulse packages that path inside its existing model.

  • Select rppg/models/efficientphys.py from the repository tree in VS Code.
  • Open the EfficientPhys paper in your browser.
  • Compare the paper's normalization module with inputs = inputs[1:] - inputs[:-1] inside EfficientPhys.forward.

Where does preprocessing happen?

The model receives raw RGB frames. Adjacent-frame differencing converts each pair of consecutive frames into one temporal difference.

Batch normalization follows that difference operation inside the model. This boundary keeps the paper's core preprocessing behavior attached to the architecture.

How does the implementation map to the paper?

  • Adjacent-frame differencing captures changes between consecutive RGB frames.
  • Batch normalization stabilizes the differenced frame values.
  • Temporal shift modules move information across neighboring time steps.
  • Motion convolutions extract spatial features from each temporal difference.
  • Attention masks weight the spatial regions used by the signal path.
  • Pooling compresses the spatial feature maps.
  • Dropout regularizes the learned feature representation.
  • Dense layers return one predicted BVP value per temporal difference.
Add the model contract package

A Python package keeps the training checks separate from WebPulse's existing application code. Its initializer identifies the new training package.

  • Create a training folder inside the WebPulse repository with VS Code's New Folder control.

You should now see training beside the repository's existing top-level folders.

  • Create training/__init__.py with VS Code's New File control.
  • Add the package description by pasting the code below:
"""Training smoke tests and experiment utilities for WebPulse."""

What does this file do?

The module docstring records why the package exists. The package holds smoke tests plus experiment utilities without changing the existing model implementation.

  • Save training/__init__.py.
  • Confirm that __init__.py appears directly inside training in the repository tree.

Package file in the wrong place?

Confirm that __init__.py is nested directly under training. Move the file if VS Code created it beside the folder.

Check that the filename begins with two underscores. Help me check my training package structure.

The contract module defines the dimensions used by the existing architecture. It also imports the repository's EfficientPhys class directly.

  • Create training/model_contract.py with VS Code's New File control.
  • Add the imports plus model dimensions by pasting this first section:
"""Verify the tensor contract of the existing EfficientPhys model."""

import torch

from rppg.models.efficientphys import EfficientPhys

FRAME_DEPTH = 10
IMAGE_SIZE = 72

What does this section define?

  • The module docstring states that this file verifies the existing model's tensor boundary.
  • The imports connect the contract to PyTorch plus WebPulse's existing EfficientPhys implementation.
  • FRAME_DEPTH fixes the expected temporal output count at 10.
  • IMAGE_SIZE fixes each frame's height plus width at 72 pixels.
  • Save training/model_contract.py.
  • Confirm that model_contract.py appears directly inside training.

Imports underlined in VS Code?

Confirm that WebPulse remains the folder open in VS Code. The repository path is required for Python to find rppg.models.efficientphys.

Confirm that the file is inside training. Help me resolve the model import.

The run_contract() function turns those dimensions into an executable assertion. A failed shape check stops immediately with both the expected shape and the received shape.

  • Add the contract below IMAGE_SIZE = 72 by pasting this section:
def run_contract():
    torch.manual_seed(7)
    model = EfficientPhys(frame_depth=FRAME_DEPTH, img_size=IMAGE_SIZE)
    model.eval()
    frames = torch.rand(FRAME_DEPTH + 1, 3, IMAGE_SIZE, IMAGE_SIZE)

    with torch.no_grad():
        prediction = model(frames)

    expected_shape = (FRAME_DEPTH, 1)
    actual_shape = tuple(prediction.shape)
    if actual_shape != expected_shape:
        raise RuntimeError(
            f"Expected output shape {expected_shape}, received {actual_shape}"
        )

    print("MODEL CONTRACT PASSED")
    print(f"input shape: {tuple(frames.shape)}")
    print(f"output shape: {actual_shape}")
    return prediction


if __name__ == "__main__":
    run_contract()

What does this contract prove?

  • torch.manual_seed(7) makes the generated frame fixture deterministic.
  • model.eval() puts the model into evaluation mode for the contract run.
  • frames holds 11 RGB images with a height plus width of 72 pixels.
  • torch.no_grad() disables gradient tracking because this check only evaluates the model boundary.
  • actual_shape captures the dimensions returned by the model.
  • RuntimeError prevents an incorrect output shape from passing silently.
  • The final guard runs the contract when Python executes the module directly.
  • Save training/model_contract.py.
  • Confirm that the file now contains run_contract() plus the (FRAME_DEPTH, 1) output assertion.

✔️ Awesome, I've got everything!

Your training package now contains the initializer plus the complete model contract. Save both files before continuing.

ⓧ I'd like to double check the full code

Compare each file with these complete references.

"""Training smoke tests and experiment utilities for WebPulse."""

Package initializer

This complete file contains the package's purpose as a module docstring.

"""Verify the tensor contract of the existing EfficientPhys model."""

import torch

from rppg.models.efficientphys import EfficientPhys

FRAME_DEPTH = 10
IMAGE_SIZE = 72


def run_contract():
    torch.manual_seed(7)
    model = EfficientPhys(frame_depth=FRAME_DEPTH, img_size=IMAGE_SIZE)
    model.eval()
    frames = torch.rand(FRAME_DEPTH + 1, 3, IMAGE_SIZE, IMAGE_SIZE)

    with torch.no_grad():
        prediction = model(frames)

    expected_shape = (FRAME_DEPTH, 1)
    actual_shape = tuple(prediction.shape)
    if actual_shape != expected_shape:
        raise RuntimeError(
            f"Expected output shape {expected_shape}, received {actual_shape}"
        )

    print("MODEL CONTRACT PASSED")
    print(f"input shape: {tuple(frames.shape)}")
    print(f"output shape: {actual_shape}")
    return prediction


if __name__ == "__main__":
    run_contract()

Complete contract flow

This complete file creates a deterministic 11-frame fixture. It fails if EfficientPhys returns any shape other than 10 rows with one BVP value per row.

Trace the 11-to-10 tensor flow

The temporal count changes at the model boundary because adjacent-frame differencing needs pairs of frames. The remaining layers transform features while preserving the resulting count of 10.

  • Return to rppg/models/efficientphys.py from earlier.
  • Find inputs = inputs[1:] - inputs[:-1] inside EfficientPhys.forward.
  • Follow the forward path from that difference operation to the dense signal head.

How do the shapes move?

  • Raw input starts with shape (11, 3, 72, 72).
  • Adjacent-frame differencing reduces the leading dimension from 11 to 10.
  • Batch normalization processes the differenced frames.
  • The four temporal shift modules mix information across neighboring time steps.
  • Motion convolutions build spatial features for each of the 10 differences.
  • Attention masks gate the spatial feature maps.
  • Pooling reduces the spatial dimensions.
  • Dropout regularizes the pooled representation.
  • The dense signal head returns shape (10, 1).

Before you run the contract, which three success lines do you expect the terminal to print?

  • Switch back to the PowerShell terminal from the previous step.
  • Run the model contract with the verified virtual-environment interpreter by running:
.venv-ml\Scripts\python.exe -m training.model_contract

What should you see?

This command uses the pinned interpreter to execute training.model_contract as a module. The module guard calls run_contract().

  • You should see MODEL CONTRACT PASSED.
  • You should see input shape: (11, 3, 72, 72).
  • You should see output shape: (10, 1).

That is the model boundary pinned down. WebPulse now proves that 11 raw RGB frames become 10 predicted BVP values.

Contract not passing?

  • Confirm that the PowerShell terminal is inside the WebPulse repository.
  • Confirm that training/__init__.py exists directly inside training.
  • Compare training/model_contract.py with the full-code reference above.

Help me debug the model contract.

Your training foundation now has an executable architecture contract. Next, you will expose how a naive data split can make validation leak subject identity.

Expose Subject Leakage

Your model contract now proves that 11 RGB frames become 10 BVP values. The next risk comes from how those clips are assigned to training data or validation data.

A random window split can place clips from the same person in both partitions. This subject leakage can make validation results look trustworthy even though the model has already seen that person's patterns.

In this step, get ready to:
  • Build a deterministic fixture containing clips from three synthetic subjects.
  • Partition those clips with an unsafe window-level split.
  • Run an overlap guard that exposes every leaking subject ID.
Create the deterministic fixture

A deterministic PyTorch fixture produces the same clips on every run. Each record keeps its subject identity attached to its frames.

  • In VS Code from earlier, create smoke_train.py inside the existing training folder using the file control beside that folder.
  • Add the module description and imports by pasting this code:
"""Expose subject leakage in a deterministic WebPulse smoke fixture."""

import argparse
import math
from dataclasses import dataclass

import torch

What Do These Imports Provide?

  • The module description states that this file demonstrates leakage with a deterministic fixture.
  • The argparse module provides the temporary command-line path used at the end of this step.
  • The math module supplies the value used to create a pulse timeline.
  • The dataclass decorator gives each clip a structured record.
  • Save training/smoke_train.py.
  • Confirm that smoke_train.py now appears inside the existing training folder.

Cannot Find the New Module?

  • Check that smoke_train.py sits beside __init__.py inside the training folder.
  • Confirm that the file name ends with .py.

Help me place the leakage fixture module correctly.

The fixture uses the same frame depth and image size as the model contract. A fixed seed keeps its random noise repeatable.

  • Place your cursor on a new line below import torch.
  • Add the fixture constants and clip record by pasting this code:
FRAME_DEPTH = 10
IMAGE_SIZE = 72
SEED = 42


@dataclass
class ClipRecord:
    subject_id: int
    clip_id: int
    frames: torch.Tensor
    target: torch.Tensor

What Does the Record Hold?

  • The FRAME_DEPTH value keeps each target aligned with the existing 10-value output contract.
  • The IMAGE_SIZE value keeps every frame at 72 by 72 pixels.
  • The SEED value makes fixture generation repeatable.
  • The ClipRecord class ties every frame tensor to one subject ID.
  • Save training/smoke_train.py.
  • Confirm that ClipRecord shows four fields below the three fixture constants.

Does the Record Show Errors?

  • Check that @dataclass sits directly above class ClipRecord.
  • Confirm that each field inside ClipRecord uses one level of indentation.

Help me repair the clip record definition.

Each synthetic subject receives two clips. Every clip contains 11 frames because the model converts adjacent frame pairs into 10 predictions.

  • Place your cursor on a new line below the final field in ClipRecord.
  • Add the deterministic fixture builder by pasting this code:
def build_records(seed=SEED):
    """Create a deterministic fixture, not a scientific rPPG dataset."""
    torch.manual_seed(seed)
    timeline = torch.linspace(0.0, 2.0 * math.pi, steps=FRAME_DEPTH + 1)
    records = []

    for subject_id in range(3):
        for clip_id in range(2):
            phase = subject_id * 0.25 + clip_id * 0.15
            pulse = torch.sin(timeline + phase)
            frames = []
            for pulse_value in pulse:
                noise = (torch.rand(3, IMAGE_SIZE, IMAGE_SIZE) - 0.5) * 0.002
                frame = 0.45 + subject_id * 0.01 + pulse_value * 0.03 + noise
                frames.append(frame)

            records.append(
                ClipRecord(
                    subject_id=subject_id,
                    clip_id=clip_id,
                    frames=torch.stack(frames),
                    target=torch.unsqueeze(pulse[1:], 1),
                )
            )

    return records

How Does the Fixture Work?

  • The fixed seed produces the same random frame noise on each run.
  • The timeline contains 11 values to match the model's raw-frame input.
  • The nested loops create two clips for each of three subjects.
  • The target keeps the final 10 pulse values to match the model output.
  • Save training/smoke_train.py.
  • Confirm that build_records ends with return records.

Seeing Fixture Errors?

  • Indent the clip loop inside the subject loop.
  • Indent the frame loop inside the clip loop.
  • Align return records with the first for statement.

Help me find a syntax or indentation problem in the fixture.

Add the unsafe split guard

A window-level split treats clips as independent rows. The overlap guard checks whether either partition contains clips from the same subject.

  • Locate return records at the end of build_records.
  • Add the unsafe split helpers below that line by pasting this code:
def naive_window_split(records):
    """Deliberately unsafe split used to demonstrate subject leakage."""
    return records[::2], records[1::2]


def subject_ids(records):
    return {record.subject_id for record in records}


def assert_no_subject_overlap(train_records, validation_records):
    overlap = subject_ids(train_records) & subject_ids(validation_records)
    if overlap:
        raise ValueError(f"Subject leakage detected: {sorted(overlap)}")

What Does the Guard Check?

  • The naive_window_split function alternates records between two partitions.
  • The subject_ids function reduces each partition to its unique identities.
  • The set intersection keeps identities found in both partitions.
  • The assertion rejects any non-empty overlap.
  • Save training/smoke_train.py.
  • Confirm that assert_no_subject_overlap raises its error only when overlap contains subject IDs.

Does the Guard Look Different?

  • Check that the two slices start at different positions in the same records collection.
  • Confirm that the overlap expression contains the & operator.
  • Keep the ValueError inside the overlap condition.

Help me inspect the unsafe partition guard.

The demonstration catches the guard's error so the terminal can label the result as planned. A separate error prevents this unsafe split from passing silently.

  • Place your cursor below assert_no_subject_overlap.
  • Add the planned leakage demonstration by pasting this code:
def show_naive_leakage():
    records = build_records()
    train_records, validation_records = naive_window_split(records)
    try:
        assert_no_subject_overlap(train_records, validation_records)
    except ValueError as error:
        print(f"PLANNED FAILURE: {error}")
        return
    raise RuntimeError("The naive split unexpectedly contained no subject overlap")

Why Catch the Error?

  • The overlap guard raises a ValueError when identities cross the partition boundary.
  • The exception handler labels that result as the intended demonstration.
  • The final RuntimeError rejects an unexpectedly clean result.
  • Save training/smoke_train.py.
  • Confirm that show_naive_leakage ends with the unexpected-clean-split guard.

Missing the Planned Failure Path?

  • Place the print call inside the except ValueError block.
  • Keep the final RuntimeError outside the exception handler.

Help me repair the leakage demonstration flow.

Run the planned failure

The temporary command-line path exposes only the unsafe partition demonstration. Subject-safe splitting and training remain outside this step.

  • Place your cursor below show_naive_leakage.
  • Add the temporary command-line entry point by pasting this code:
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--show-naive-split", action="store_true")
    args = parser.parse_args()

    if args.show_naive_split:
        show_naive_leakage()
        return

    parser.error("Use --show-naive-split during the leakage demonstration")


if __name__ == "__main__":
    main()

How Does the Entry Point Work?

  • The parser recognizes the --show-naive-split flag.
  • The enabled flag calls show_naive_leakage.
  • The parser rejects an invocation that omits the demonstration flag.
  • Save training/smoke_train.py.
  • Confirm that the file ends with the call to main().

Does the Entry Point Show Errors?

  • Check that the if args.show_naive_split block sits inside main.
  • Confirm that the final module condition sits outside main.

Help me repair the temporary command-line entry point.

✔️ Awesome, I've got everything!

Your temporary training/smoke_train.py module now matches the planned leakage demonstration.

ⓧ I'd like to double check the full code

"""Expose subject leakage in a deterministic WebPulse smoke fixture."""

import argparse
import math
from dataclasses import dataclass

import torch

FRAME_DEPTH = 10
IMAGE_SIZE = 72
SEED = 42


@dataclass
class ClipRecord:
    subject_id: int
    clip_id: int
    frames: torch.Tensor
    target: torch.Tensor


def build_records(seed=SEED):
    """Create a deterministic fixture, not a scientific rPPG dataset."""
    torch.manual_seed(seed)
    timeline = torch.linspace(0.0, 2.0 * math.pi, steps=FRAME_DEPTH + 1)
    records = []

    for subject_id in range(3):
        for clip_id in range(2):
            phase = subject_id * 0.25 + clip_id * 0.15
            pulse = torch.sin(timeline + phase)
            frames = []
            for pulse_value in pulse:
                noise = (torch.rand(3, IMAGE_SIZE, IMAGE_SIZE) - 0.5) * 0.002
                frame = 0.45 + subject_id * 0.01 + pulse_value * 0.03 + noise
                frames.append(frame)

            records.append(
                ClipRecord(
                    subject_id=subject_id,
                    clip_id=clip_id,
                    frames=torch.stack(frames),
                    target=torch.unsqueeze(pulse[1:], 1),
                )
            )

    return records


def naive_window_split(records):
    """Deliberately unsafe split used to demonstrate subject leakage."""
    return records[::2], records[1::2]


def subject_ids(records):
    return {record.subject_id for record in records}


def assert_no_subject_overlap(train_records, validation_records):
    overlap = subject_ids(train_records) & subject_ids(validation_records)
    if overlap:
        raise ValueError(f"Subject leakage detected: {sorted(overlap)}")


def show_naive_leakage():
    records = build_records()
    train_records, validation_records = naive_window_split(records)
    try:
        assert_no_subject_overlap(train_records, validation_records)
    except ValueError as error:
        print(f"PLANNED FAILURE: {error}")
        return
    raise RuntimeError("The naive split unexpectedly contained no subject overlap")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--show-naive-split", action="store_true")
    args = parser.parse_args()

    if args.show_naive_split:
        show_naive_leakage()
        return

    parser.error("Use --show-naive-split during the leakage demonstration")


if __name__ == "__main__":
    main()

Before you run the check, consider which subject IDs you expect to appear in both partitions.

  • Run the planned leakage check from the WebPulse repository by entering this command:
.venv-ml\Scripts\python.exe -m training.smoke_train --show-naive-split

What Should You See?

You should see PLANNED FAILURE: Subject leakage detected: [0, 1, 2].

The command builds the deterministic fixture before alternating its records between two partitions. The guard finds every identity on both sides.

You exposed the intended failure: every fixture subject crossed the partition boundary.

Do You See a Different Result?

  • Confirm that the command includes --show-naive-split.
  • Check that naive_window_split returns alternating slices from the same records collection.
  • Confirm that assert_no_subject_overlap raises its error when overlap contains values.

Help me diagnose the planned leakage result.

The leakage is now visible. Next up, you will replace the unsafe partition with a subject-independent split before training EfficientPhys.

Run Paper-Aligned Smoke Training

You caught the hidden leak in the temporary training path. Every fixture subject appeared in both partitions.

A safe split protects the validation boundary. The training objective must also match the research choices you intend to reproduce.

In this step, you will train the EfficientPhys parameters with AdamW and negative Pearson loss. You will prove that a restored checkpoint produces the same prediction.

In this step, get ready to:
  • Add a subject-safe partition and the paper-aligned training objective.
  • Train EfficientPhys on the deterministic smoke fixture.
  • Prove that the saved checkpoint restores the trained prediction.
Add the subject-safe objective

The temporary module can expose leakage. It now needs the model import plus the constants that make the training experiment reproducible.

  • In training/smoke_train.py, replace the opening code from the module docstring through SEED = 42 with this updated configuration:
"""CPU smoke training for WebPulse EfficientPhys."""

import argparse
import math
from dataclasses import dataclass
from pathlib import Path

import torch

from rppg.models.efficientphys import EfficientPhys

FRAME_DEPTH = 10
IMAGE_SIZE = 72
TRAIN_SUBJECTS = {0, 1}
VALIDATION_SUBJECTS = {2}
LEARNING_RATE = 0.001
WEIGHT_DECAY = 0.01
SEED = 42

What Does This Configuration Control?

  • The EfficientPhys import connects the fixture to WebPulse's existing model.
  • The subject constants reserve subjects 0 and 1 for training.
  • The validation constant reserves subject 2 for evaluation.
  • The learning rate and weight decay make the optimizer configuration explicit.
  • Save training/smoke_train.py.
  • Confirm that the updated imports still preserve the leakage demonstration by running:
.venv-ml\Scripts\python.exe -m training.smoke_train --show-naive-split

What Should You See?

You should still see PLANNED FAILURE: Subject leakage detected: [0, 1, 2]. This confirms that the updated module loads while preserving the deliberate leakage example.

Does the Module Fail to Load?

  • Check that Path is imported from pathlib.
  • Check that the EfficientPhys import matches the model contract module.
  • Confirm that every constant appears before the ClipRecord class.

Help me fix the updated imports in training/smoke_train.py.

A subject-independent validation split assigns each person to one partition. Every clip from subjects 0 and 1 stays in training.

  • In training/smoke_train.py, add this function directly below naive_window_split():
def split_by_subject(records):
    train_records = [
        record for record in records if record.subject_id in TRAIN_SUBJECTS
    ]
    validation_records = [
        record for record in records if record.subject_id in VALIDATION_SUBJECTS
    ]
    return train_records, validation_records

How Does the Safe Split Work?

  • The first comprehension keeps records whose subject belongs to TRAIN_SUBJECTS.
  • The second comprehension keeps records whose subject belongs to VALIDATION_SUBJECTS.
  • The function returns two partitions that can be checked by assert_no_subject_overlap().
  • Save training/smoke_train.py.
  • Check that the module remains runnable by repeating the leakage demonstration:
.venv-ml\Scripts\python.exe -m training.smoke_train --show-naive-split

What Does This Check Confirm?

The planned leakage message should appear again. The result confirms that split_by_subject() has valid syntax without changing the deliberately unsafe demonstration.

Seeing a Syntax Problem in the Split?

  • Place split_by_subject() after the closing line of naive_window_split().
  • Check that both list comprehensions remain indented inside the function.
  • Confirm that the constants use the exact names TRAIN_SUBJECTS and VALIDATION_SUBJECTS.

Help me repair the subject-safe split in training/smoke_train.py.

Negative Pearson loss measures the temporal agreement between the predicted signal and its target. Centering both tensors focuses the calculation on their changing shape over time.

  • In training/smoke_train.py, add this objective directly below show_naive_leakage():
def negative_pearson_loss(prediction, target):
    """Return one minus temporal Pearson correlation."""
    cosine = torch.nn.CosineSimilarity(dim=0, eps=1e-6)
    prediction_centered = prediction - torch.mean(
        prediction, dim=0, keepdim=True
    )
    target_centered = target - torch.mean(target, dim=0, keepdim=True)
    pearson = cosine(prediction_centered, target_centered)
    return torch.mean(1 - pearson)

What Does the Objective Measure?

  • The temporal means are removed from the prediction and target.
  • Cosine similarity measures the agreement between the centered signals.
  • Subtracting the correlation from 1 produces a loss that approaches zero as the signals align.
  • Save training/smoke_train.py.
  • Confirm that the new objective parses by running the existing leakage path:
.venv-ml\Scripts\python.exe -m training.smoke_train --show-naive-split

What Does a Passing Parse Look Like?

You should reach the same planned leakage output. Reaching that output proves Python successfully loaded negative_pearson_loss().

Does the Objective Stop the Module?

  • Check the indentation of the multiline torch.mean() call.
  • Confirm that prediction_centered and target_centered use matching spellings.
  • Check that CosineSimilarity uses dim=0.

Help me debug the negative Pearson objective in training/smoke_train.py.

Why This Training Objective?

The convolutional EfficientPhys paper reports AdamW with a learning rate of 0.001 and negative Pearson loss. This smoke experiment follows those choices.

The current rPPG-Toolbox trainer uses AdamW with MSE loss and a weight decay of 0. Recording that divergence preserves paper-to-code traceability.

This project pins weight decay to 0.01 from the documented PyTorch AdamW default. The paper does not publish a weight-decay coefficient.

Validation uses the same objective without calculating gradients. Averaging the per-clip values gives one finite loss for the held-out subject.

  • In training/smoke_train.py, add this evaluation helper directly below negative_pearson_loss():
def mean_loss(model, records):
    losses = []
    model.eval()
    with torch.no_grad():
        for record in records:
            loss = negative_pearson_loss(model(record.frames), record.target)
            losses.append(loss.item())
    return sum(losses) / len(losses)

How Does Validation Stay Separate?

  • The model enters evaluation mode before processing held-out records.
  • The torch.no_grad() context prevents validation from building a gradient graph.
  • Each scalar loss is collected before the function returns their mean.
  • Save training/smoke_train.py.
  • Confirm that the validation helper loads by running the leakage path again:
.venv-ml\Scripts\python.exe -m training.smoke_train --show-naive-split

What Should Remain Stable?

The module should print the same planned leakage message. This confirms that the safe objective helpers coexist with the original demonstration.

Does the Validation Helper Fail?

  • Check that model.eval() sits before the torch.no_grad() block.
  • Confirm that the loop appends loss.item() to losses.
  • Check that the return line sits outside the loop.

Help me fix mean_loss().

Train and restore the checkpoint

The training function now connects the safe partition to the real WebPulse model. Each training record updates the EfficientPhys parameters once.

  • In training/smoke_train.py, add the first part of train_and_checkpoint() directly below mean_loss():
def train_and_checkpoint(output_path):
    torch.manual_seed(SEED)
    records = build_records()
    train_records, validation_records = split_by_subject(records)
    assert_no_subject_overlap(train_records, validation_records)

    model = EfficientPhys(frame_depth=FRAME_DEPTH, img_size=IMAGE_SIZE)
    optimizer = torch.optim.AdamW(
        model.parameters(), lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY
    )

    model.train()
    train_losses = []
    for record in train_records:
        optimizer.zero_grad()
        prediction = model(record.frames)
        loss = negative_pearson_loss(prediction, record.target)
        loss_value = loss.item()
        if not math.isfinite(loss_value):
            raise RuntimeError("Training produced a non-finite loss")
        loss.backward()
        optimizer.step()
        train_losses.append(loss_value)

What Happens During Training?

  • The fixed seed recreates the deterministic fixture.
  • The overlap assertion stops training if a subject crosses partitions.
  • AdamW updates the model after each finite negative Pearson loss.
  • The finite-value check prevents an invalid training result from reaching the checkpoint.
  • Save training/smoke_train.py.
  • Check the first training section for syntax problems by running the leakage path:
.venv-ml\Scripts\python.exe -m training.smoke_train --show-naive-split

What Does This Syntax Check Prove?

You should reach the planned leakage output. Python has now loaded the model construction and training loop successfully.

Does the Training Loop Stop the Module?

  • Check that the optimizer call closes before model.train().
  • Confirm that the finite-loss condition sits inside the record loop.
  • Check that loss.backward() appears before optimizer.step().

Help me debug the EfficientPhys training loop in training/smoke_train.py.

A resumable checkpoint needs the model state plus the optimizer state. The saved metadata also records the partition and reproducibility choices.

  • Inside train_and_checkpoint(), add this checkpoint section directly below train_losses.append(loss_value):
    train_loss = sum(train_losses) / len(train_losses)
    validation_loss = mean_loss(model, validation_records)
    if not math.isfinite(validation_loss):
        raise RuntimeError("Validation produced a non-finite loss")

    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    torch.save(
        {
            "epoch": 1,
            "model_state_dict": model.state_dict(),
            "optimizer_state_dict": optimizer.state_dict(),
            "train_loss": train_loss,
            "validation_loss": validation_loss,
            "train_subjects": sorted(subject_ids(train_records)),
            "validation_subjects": sorted(subject_ids(validation_records)),
            "learning_rate": LEARNING_RATE,
            "weight_decay": WEIGHT_DECAY,
            "seed": SEED,
            "loss_name": "negative_pearson",
        },
        output_path,
    )

What Goes Into the Checkpoint?

  • The validation loss is checked before anything is saved.
  • The output directory is created from the supplied path.
  • The checkpoint stores model parameters plus optimizer state.
  • The metadata records losses plus subjects plus training constants.
  • Save training/smoke_train.py.
  • Confirm that the checkpoint section parses by running the leakage path:
.venv-ml\Scripts\python.exe -m training.smoke_train --show-naive-split

What Should the Module Print?

You should still reach the planned leakage output. This confirms that the validation and serialization section has valid syntax.

Does the Checkpoint Section Fail to Parse?

  • Keep every line in this section indented inside train_and_checkpoint().
  • Check that the dictionary closes before output_path.
  • Confirm that the final parenthesis closes torch.save().

Help me repair the checkpoint serialization section in training/smoke_train.py.

Saving state only proves that a file was written. Reloading both state dictionaries and comparing predictions proves that the checkpoint preserves the trained model behavior.

  • Inside train_and_checkpoint(), add this restoration section directly below the closing torch.save() parenthesis:
    checkpoint = torch.load(output_path, map_location="cpu", weights_only=True)
    restored_model = EfficientPhys(frame_depth=FRAME_DEPTH, img_size=IMAGE_SIZE)
    restored_optimizer = torch.optim.AdamW(
        restored_model.parameters(), lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY
    )
    restored_model.load_state_dict(checkpoint["model_state_dict"])
    restored_optimizer.load_state_dict(checkpoint["optimizer_state_dict"])

    model.eval()
    restored_model.eval()
    sample = validation_records[0].frames
    with torch.no_grad():
        original_prediction = model(sample)
        restored_prediction = restored_model(sample)
    restored_match = torch.allclose(original_prediction, restored_prediction)
    if not restored_match:
        raise RuntimeError("Restored checkpoint changed the model prediction")

    return {
        "train_loss": train_loss,
        "validation_loss": validation_loss,
        "train_subjects": checkpoint["train_subjects"],
        "validation_subjects": checkpoint["validation_subjects"],
        "checkpoint_restored": restored_match,
        "output_path": str(output_path),
    }

How Is Restoration Verified?

  • The checkpoint is loaded onto the CPU with its tensor-only mode enabled.
  • Fresh model and optimizer objects receive the saved state dictionaries.
  • Both models predict from the same held-out clip in evaluation mode.
  • The function fails if torch.allclose() finds a changed prediction.
  • Save training/smoke_train.py.
  • Confirm that the completed training function loads by running the leakage path:
.venv-ml\Scripts\python.exe -m training.smoke_train --show-naive-split

What Does This Final Parse Check Show?

The planned leakage output should appear again. The complete training and restoration function is now available to the command-line entry point.

Does Restoration Break the Module?

  • Check that every restoration line stays inside train_and_checkpoint().
  • Confirm that the checkpoint keys match the keys stored by torch.save().
  • Check that the returned dictionary closes after output_path.

Help me debug the checkpoint restoration section in training/smoke_train.py.

Run the smoke experiment

The command-line entry point chooses between the earlier leakage demonstration and the completed training path. Its default output path remains available for local runs.

  • In training/smoke_train.py, replace the existing main() function plus the final module guard with this completed entry point:
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--show-naive-split", action="store_true")
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("artifacts/efficientphys_smoke.pt"),
    )
    args = parser.parse_args()

    if args.show_naive_split:
        show_naive_leakage()
        return

    summary = train_and_checkpoint(args.output)
    print(f"train subjects: {summary['train_subjects']}")
    print(f"validation subjects: {summary['validation_subjects']}")
    print(f"train loss: {summary['train_loss']:.6f}")
    print(f"validation loss: {summary['validation_loss']:.6f}")
    print(f"checkpoint restored: {summary['checkpoint_restored']}")
    print(f"saved checkpoint: {summary['output_path']}")


if __name__ == "__main__":
    main()

What Does the Entry Point Report?

  • The optional leakage flag preserves the designed failure from the previous step.
  • The output option sends the checkpoint to a caller-selected path.
  • The summary prints the partitions plus both losses plus restoration status plus the saved path.
  • Save training/smoke_train.py.

Before you run this check, do you expect the training and validation subject lists to overlap?

  • Run the paper-aligned CPU smoke training with this command:
.venv-ml\Scripts\python.exe -m training.smoke_train --output artifacts\efficientphys_smoke.pt

What Should You See?

You should see train subjects: [0, 1] plus validation subjects: [2]. The two printed losses should be finite numbers.

You should also see checkpoint restored: True plus the saved artifacts\efficientphys_smoke.pt path. The restored prediction now matches the original trained prediction.

This checkpoint is engineering evidence for tensor flow plus optimization plus serialization. Synthetic fixtures provide no evidence of physiological accuracy or clinical validity.

Does the Smoke Run Fail?

  • Compare each function boundary with the completed file below if Python reports a syntax or indentation problem.
  • Check that TRAIN_SUBJECTS contains 0 plus 1.
  • Check that VALIDATION_SUBJECTS contains only 2.

Help me diagnose the failed EfficientPhys smoke run in training/smoke_train.py.

✔️ Awesome, I've got everything!

Your smoke-training file is complete. Double-check that you saved training/smoke_train.py.

ⓧ I'd like to double check the full code

"""CPU smoke training for WebPulse EfficientPhys."""

import argparse
import math
from dataclasses import dataclass
from pathlib import Path

import torch

from rppg.models.efficientphys import EfficientPhys

FRAME_DEPTH = 10
IMAGE_SIZE = 72
TRAIN_SUBJECTS = {0, 1}
VALIDATION_SUBJECTS = {2}
LEARNING_RATE = 0.001
WEIGHT_DECAY = 0.01
SEED = 42


@dataclass
class ClipRecord:
    subject_id: int
    clip_id: int
    frames: torch.Tensor
    target: torch.Tensor


def build_records(seed=SEED):
    """Create a deterministic fixture, not a scientific rPPG dataset."""
    torch.manual_seed(seed)
    timeline = torch.linspace(0.0, 2.0 * math.pi, steps=FRAME_DEPTH + 1)
    records = []

    for subject_id in range(3):
        for clip_id in range(2):
            phase = subject_id * 0.25 + clip_id * 0.15
            pulse = torch.sin(timeline + phase)
            frames = []
            for pulse_value in pulse:
                noise = (torch.rand(3, IMAGE_SIZE, IMAGE_SIZE) - 0.5) * 0.002
                frame = 0.45 + subject_id * 0.01 + pulse_value * 0.03 + noise
                frames.append(frame)

            records.append(
                ClipRecord(
                    subject_id=subject_id,
                    clip_id=clip_id,
                    frames=torch.stack(frames),
                    target=torch.unsqueeze(pulse[1:], 1),
                )
            )

    return records


def naive_window_split(records):
    """Deliberately unsafe split used to demonstrate subject leakage."""
    return records[::2], records[1::2]


def split_by_subject(records):
    train_records = [
        record for record in records if record.subject_id in TRAIN_SUBJECTS
    ]
    validation_records = [
        record for record in records if record.subject_id in VALIDATION_SUBJECTS
    ]
    return train_records, validation_records


def subject_ids(records):
    return {record.subject_id for record in records}


def assert_no_subject_overlap(train_records, validation_records):
    overlap = subject_ids(train_records) & subject_ids(validation_records)
    if overlap:
        raise ValueError(f"Subject leakage detected: {sorted(overlap)}")


def show_naive_leakage():
    records = build_records()
    train_records, validation_records = naive_window_split(records)
    try:
        assert_no_subject_overlap(train_records, validation_records)
    except ValueError as error:
        print(f"PLANNED FAILURE: {error}")
        return
    raise RuntimeError("The naive split unexpectedly contained no subject overlap")


def negative_pearson_loss(prediction, target):
    """Return one minus temporal Pearson correlation."""
    cosine = torch.nn.CosineSimilarity(dim=0, eps=1e-6)
    prediction_centered = prediction - torch.mean(
        prediction, dim=0, keepdim=True
    )
    target_centered = target - torch.mean(target, dim=0, keepdim=True)
    pearson = cosine(prediction_centered, target_centered)
    return torch.mean(1 - pearson)


def mean_loss(model, records):
    losses = []
    model.eval()
    with torch.no_grad():
        for record in records:
            loss = negative_pearson_loss(model(record.frames), record.target)
            losses.append(loss.item())
    return sum(losses) / len(losses)


def train_and_checkpoint(output_path):
    torch.manual_seed(SEED)
    records = build_records()
    train_records, validation_records = split_by_subject(records)
    assert_no_subject_overlap(train_records, validation_records)

    model = EfficientPhys(frame_depth=FRAME_DEPTH, img_size=IMAGE_SIZE)
    optimizer = torch.optim.AdamW(
        model.parameters(), lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY
    )

    model.train()
    train_losses = []
    for record in train_records:
        optimizer.zero_grad()
        prediction = model(record.frames)
        loss = negative_pearson_loss(prediction, record.target)
        loss_value = loss.item()
        if not math.isfinite(loss_value):
            raise RuntimeError("Training produced a non-finite loss")
        loss.backward()
        optimizer.step()
        train_losses.append(loss_value)

    train_loss = sum(train_losses) / len(train_losses)
    validation_loss = mean_loss(model, validation_records)
    if not math.isfinite(validation_loss):
        raise RuntimeError("Validation produced a non-finite loss")

    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    torch.save(
        {
            "epoch": 1,
            "model_state_dict": model.state_dict(),
            "optimizer_state_dict": optimizer.state_dict(),
            "train_loss": train_loss,
            "validation_loss": validation_loss,
            "train_subjects": sorted(subject_ids(train_records)),
            "validation_subjects": sorted(subject_ids(validation_records)),
            "learning_rate": LEARNING_RATE,
            "weight_decay": WEIGHT_DECAY,
            "seed": SEED,
            "loss_name": "negative_pearson",
        },
        output_path,
    )

    checkpoint = torch.load(output_path, map_location="cpu", weights_only=True)
    restored_model = EfficientPhys(frame_depth=FRAME_DEPTH, img_size=IMAGE_SIZE)
    restored_optimizer = torch.optim.AdamW(
        restored_model.parameters(), lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY
    )
    restored_model.load_state_dict(checkpoint["model_state_dict"])
    restored_optimizer.load_state_dict(checkpoint["optimizer_state_dict"])

    model.eval()
    restored_model.eval()
    sample = validation_records[0].frames
    with torch.no_grad():
        original_prediction = model(sample)
        restored_prediction = restored_model(sample)
    restored_match = torch.allclose(original_prediction, restored_prediction)
    if not restored_match:
        raise RuntimeError("Restored checkpoint changed the model prediction")

    return {
        "train_loss": train_loss,
        "validation_loss": validation_loss,
        "train_subjects": checkpoint["train_subjects"],
        "validation_subjects": checkpoint["validation_subjects"],
        "checkpoint_restored": restored_match,
        "output_path": str(output_path),
    }


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--show-naive-split", action="store_true")
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("artifacts/efficientphys_smoke.pt"),
    )
    args = parser.parse_args()

    if args.show_naive_split:
        show_naive_leakage()
        return

    summary = train_and_checkpoint(args.output)
    print(f"train subjects: {summary['train_subjects']}")
    print(f"validation subjects: {summary['validation_subjects']}")
    print(f"train loss: {summary['train_loss']:.6f}")
    print(f"validation loss: {summary['validation_loss']:.6f}")
    print(f"checkpoint restored: {summary['checkpoint_restored']}")
    print(f"saved checkpoint: {summary['output_path']}")


if __name__ == "__main__":
    main()

That is the full smoke-training loop working: subject-safe training now saves a checkpoint and reproduces its prediction after restoration. Next, you will turn these assumptions into automated regression gates.

Turn the Experiment into a CI Quality Gate

Your subject-safe smoke run now proves the complete checkpoint path on your computer.

A local pass can silently regress after a future change. This step turns each assumption into a regression test. GitHub Actions repeats the suite on a fresh CPU runner.

In this step, get ready to:
  • Add five focused regression tests.
  • Define a GitHub Actions workflow for a fresh CPU runner.
  • Publish the quality gate on the ml-training-ci branch.
Add the regression tests

A regression suite captures the behavior your experiment already proved. Future changes must satisfy the same contract.

What Will the Suite Protect?

  • The model contract must return ten BVP predictions.
  • The negative Pearson loss must return zero for identical signals.
  • The naive window split must expose subject leakage.
  • The subject-safe split must keep validation subject 2 separate.
  • The restored checkpoint must reproduce the original prediction.
  • Create test_training_pipeline.py inside the WebPulse folder by using the VS Code Explorer.
  • Paste the first test chunk into test_training_pipeline.py:
"""Regression gates for the WebPulse ML smoke-training path."""

import math
import tempfile
import unittest
from pathlib import Path

from training.model_contract import run_contract
from training.smoke_train import (
    assert_no_subject_overlap,
    build_records,
    naive_window_split,
    negative_pearson_loss,
    split_by_subject,
    subject_ids,
    train_and_checkpoint,
)


class TrainingPipelineTests(unittest.TestCase):
    def test_model_contract(self):
        prediction = run_contract()
        self.assertEqual(tuple(prediction.shape), (10, 1))

    def test_negative_pearson_identity_is_zero(self):
        target = build_records()[0].target
        loss = negative_pearson_loss(target, target)
        self.assertAlmostEqual(loss.item(), 0.0, places=6)

What Does This Test Chunk Do?

  • The imports reuse the exact model contract and smoke-training functions you already verified.
  • test_model_contract protects the expected (10, 1) output shape.
  • test_negative_pearson_identity_is_zero checks that identical signals produce zero loss to six decimal places.
  • Save test_training_pipeline.py.

Before you run this check, do you expect both initial gates to pass against the existing training code?

  • Run the first two regression gates from the PowerShell terminal with this command:
.venv-ml\Scripts\python.exe -m unittest test_training_pipeline.py

What Does This Command Check?

Python's unittest runner loads every test method currently defined in test_training_pipeline.py. A failing assertion makes the command return a failure.

You should see the model contract output. You should also see two completed tests followed by OK.

Are the First Tests Failing?

  • Confirm that test_training_pipeline.py sits directly inside the WebPulse folder.
  • Check that the PowerShell terminal is still inside the WebPulse folder.
  • Compare the imported function names with training/model_contract.py and training/smoke_train.py.

Help me diagnose a failing WebPulse model contract or negative Pearson unit test.

  • Add the remaining gates below test_negative_pearson_identity_is_zero in test_training_pipeline.py by pasting this code:
    def test_naive_split_exposes_leakage(self):
        train_records, validation_records = naive_window_split(build_records())
        with self.assertRaises(ValueError):
            assert_no_subject_overlap(train_records, validation_records)

    def test_subject_split_has_no_leakage(self):
        train_records, validation_records = split_by_subject(build_records())
        assert_no_subject_overlap(train_records, validation_records)
        self.assertEqual(subject_ids(train_records), {0, 1})
        self.assertEqual(subject_ids(validation_records), {2})

    def test_checkpoint_round_trip(self):
        with tempfile.TemporaryDirectory() as temporary_directory:
            output_path = Path(temporary_directory) / "efficientphys_smoke.pt"
            summary = train_and_checkpoint(output_path)
            self.assertTrue(output_path.exists())
            self.assertTrue(summary["checkpoint_restored"])
            self.assertTrue(math.isfinite(summary["train_loss"]))
            self.assertTrue(math.isfinite(summary["validation_loss"]))


if __name__ == "__main__":
    unittest.main()

What Do These Gates Prove?

  • test_naive_split_exposes_leakage passes only when the unsafe split raises a ValueError.
  • test_subject_split_has_no_leakage locks training to subjects 0 and 1. It locks validation to subject 2.
  • test_checkpoint_round_trip uses a temporary checkpoint. It requires finite losses and matching restored predictions.
  • unittest.main() also supports running the file directly.
  • Save test_training_pipeline.py.

Before you run the full suite, do you expect the deliberately unsafe split test to pass or fail?

  • Run all five regression gates with this command:
.venv-ml\Scripts\python.exe -m unittest test_training_pipeline.py

Why Does the Leakage Test Pass?

The leakage test expects assert_no_subject_overlap to reject the naive split. Catching that planned failure proves the guard still works.

The checkpoint test repeats the CPU smoke loop inside a temporary directory. Expect this test run to take a few minutes on CPU.

You should see five completed tests followed by OK. That result protects the model contract, loss behavior, partition rule, and checkpoint restoration.

Is the Full Suite Failing?

  • Check the failing method name to identify which contract changed.
  • Confirm that TRAIN_SUBJECTS remains {0, 1}.
  • Confirm that VALIDATION_SUBJECTS remains {2}.

Help me trace a failing WebPulse training regression gate.

✔️ Awesome, I've got everything!

Your five-gate test file is complete. Keep test_training_pipeline.py saved before continuing.

ⓧ I'd like to double check the full code

"""Regression gates for the WebPulse ML smoke-training path."""

import math
import tempfile
import unittest
from pathlib import Path

from training.model_contract import run_contract
from training.smoke_train import (
    assert_no_subject_overlap,
    build_records,
    naive_window_split,
    negative_pearson_loss,
    split_by_subject,
    subject_ids,
    train_and_checkpoint,
)


class TrainingPipelineTests(unittest.TestCase):
    def test_model_contract(self):
        prediction = run_contract()
        self.assertEqual(tuple(prediction.shape), (10, 1))

    def test_negative_pearson_identity_is_zero(self):
        target = build_records()[0].target
        loss = negative_pearson_loss(target, target)
        self.assertAlmostEqual(loss.item(), 0.0, places=6)

    def test_naive_split_exposes_leakage(self):
        train_records, validation_records = naive_window_split(build_records())
        with self.assertRaises(ValueError):
            assert_no_subject_overlap(train_records, validation_records)

    def test_subject_split_has_no_leakage(self):
        train_records, validation_records = split_by_subject(build_records())
        assert_no_subject_overlap(train_records, validation_records)
        self.assertEqual(subject_ids(train_records), {0, 1})
        self.assertEqual(subject_ids(validation_records), {2})

    def test_checkpoint_round_trip(self):
        with tempfile.TemporaryDirectory() as temporary_directory:
            output_path = Path(temporary_directory) / "efficientphys_smoke.pt"
            summary = train_and_checkpoint(output_path)
            self.assertTrue(output_path.exists())
            self.assertTrue(summary["checkpoint_restored"])
            self.assertTrue(math.isfinite(summary["train_loss"]))
            self.assertTrue(math.isfinite(summary["validation_loss"]))


if __name__ == "__main__":
    unittest.main()

What Should Match?

Use this full-file reference to compare imports, method names, expected subject sets, and checkpoint assertions. Every test should remain inside TrainingPipelineTests.

Define the CI workflow

A local suite still depends on your existing environment. A fresh hosted runner proves the repository can recreate the same result from committed files.

The YAML workflow installs the CPU build of PyTorch. It runs only the focused ML checks.

  • Create .github/workflows/ml-ci.yml inside the WebPulse folder by using the VS Code Explorer.
  • Paste this workflow foundation into ml-ci.yml:
name: WebPulse ML CI

on:
  push:
    branches:
      - main
      - ml-training-ci
  pull_request:

jobs:
  ml-smoke:
    name: ML smoke gates
    runs-on: ubuntu-24.04
    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1

      - name: Set up Python
        uses: actions/setup-python@v7.0.0
        with:
          python-version: "3.10"
          cache: pip
          cache-dependency-path: requirements-ml.txt

What Does the Workflow Foundation Do?

  • WebPulse ML CI becomes the workflow name shown on GitHub.
  • The triggers cover pushes to main or ml-training-ci.
  • The workflow also covers pull requests.
  • ubuntu-24.04 gives the job a standard hosted Linux runner.
  • actions/checkout@v7.0.1 places the repository files on that runner.
  • actions/setup-python@v7.0.0 prepares Python 3.10.
  • The pip cache uses requirements-ml.txt as its dependency key.
  • Append the focused ML commands below the cache-dependency-path line:

      - name: Install CPU PyTorch
        run: python -m pip install -r requirements-ml.txt --index-url https://download.pytorch.org/whl/cpu

      - name: Run model contract
        run: python -m training.model_contract

      - name: Run training quality gates
        run: python -m unittest test_training_pipeline.py

How Does the Job Reproduce Your Check?

  • Install CPU PyTorch recreates the pinned CPU environment from requirements-ml.txt.
  • Run model contract gives the tensor boundary its own visible check.
  • Run training quality gates executes the five-test regression suite.
  • ML smoke gates becomes the status-check name used by the workflow job.
  • Save .github/workflows/ml-ci.yml.

✔️ Awesome, I've got everything!

Your workflow now recreates the focused ML checks on a fresh hosted CPU runner.

ⓧ I'd like to double check the full code

name: WebPulse ML CI

on:
  push:
    branches:
      - main
      - ml-training-ci
  pull_request:

jobs:
  ml-smoke:
    name: ML smoke gates
    runs-on: ubuntu-24.04
    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1

      - name: Set up Python
        uses: actions/setup-python@v7.0.0
        with:
          python-version: "3.10"
          cache: pip
          cache-dependency-path: requirements-ml.txt

      - name: Install CPU PyTorch
        run: python -m pip install -r requirements-ml.txt --index-url https://download.pytorch.org/whl/cpu

      - name: Run model contract
        run: python -m training.model_contract

      - name: Run training quality gates
        run: python -m unittest test_training_pipeline.py

What Should Match?

Compare the workflow name, triggers, runner, pinned actions, Python version, cache settings, and three job commands. YAML indentation controls which settings belong to each step.

Push the quality gate

A Git commit captures the test suite and workflow as one reviewable change. Pushing the branch triggers the fresh-machine check.

  • Stage the ML requirements, training package, regression test, and workflow by running this command:
git add requirements-ml.txt training test_training_pipeline.py .github/workflows/ml-ci.yml

What Does This Command Stage?

The explicit paths stage the reproducible dependency pin, training modules, regression suite, and CI workflow. The ignored local checkpoint stays outside the commit.

  • Record the staged quality gate by running this command:
git commit -m "Add paper-aligned ML training gates"

What Does This Commit Record?

The commit groups the paper-aligned training evidence with the checks that protect it. Git should print a summary of the files recorded in the commit.

Does Git Report Nothing to Commit?

  • Confirm that both new files are saved in VS Code.
  • Check that test_training_pipeline.py sits directly inside WebPulse.
  • Check that ml-ci.yml sits inside .github/workflows.

Help me find why Git did not stage my WebPulse ML test and workflow files.

Before you push, do you expect the fresh runner to reproduce your local five-test pass?

  • Push ml-training-ci to origin by running this command:
git push -u origin ml-training-ci

What Does This Push Trigger?

The push updates the remote ml-training-ci branch. Its branch name matches the workflow trigger.

The -u option sets the remote branch as the upstream for later pushes.

The hosted run can spend a few minutes waiting for a runner or installing the CPU package. That delay is the clean environment being rebuilt.

  • Return to the WebPulse repository page on GitHub.
  • Open the repository's GitHub Actions runs.
  • Select the WebPulse ML CI workflow run for ml-training-ci.
  • Select the ML smoke gates job to confirm its result.

You should see a green ML smoke gates job. Its logs should show the passing model contract and focused unit tests.

That is the quality gate live. WebPulse now proves its core training assumptions on a fresh machine after every covered change.

Is the Workflow Missing or Red?

  • Confirm that the pushed branch contains .github/workflows/ml-ci.yml.
  • Check that the workflow trigger contains ml-training-ci with matching spelling.
  • Open the failed job step to identify whether installation, the model contract, or the unit tests failed.

Help me diagnose a missing or failing WebPulse ML smoke gates workflow.

Secret mission

Protect `main` with the ML Gate

Your ML tests already report whether a commit is safe to merge. In this mission, you will create the active `WebPulse ML gate` ruleset for `main`. The ruleset will require `ML smoke gates` before merging.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the WebPulse resources, pause your work, or remove the local files plus remote controls. Standard GitHub Actions runners are free for this public repository.

Resources you used:

  • The .venv-ml Python environment containing PyTorch 2.14.1 for CPU smoke tests.
  • The local artifacts/efficientphys_smoke.pt checkpoint. This smoke artifact is ignored by Git. It is not a scientifically validated model.
  • The remote ml-training-ci branch containing .github/workflows/ml-ci.yml.
  • The active WebPulse ML gate ruleset protecting main with the required ML smoke gates check.

Keep everything running

No action is needed. Choose this if you plan to keep developing the WebPulse training pipeline.

  • Retain .venv-ml so you can rerun the model contract or regression tests.
  • Keep artifacts/efficientphys_smoke.pt for local checkpoint restoration tests.
  • Leave the remote ml-training-ci branch available for review.
  • Keep WebPulse ML gate active so changes cannot merge into main without ML smoke gates.
  • Remember that the GitHub Actions pip cache remains subject to repository cache storage limits.

Pause - I'll come back to this later

Pause your changes while keeping every resource ready for your return. GitHub Actions has no continuously running process.

  • Close the PowerShell terminal from earlier.
  • Close the VS Code window from earlier.
  • Leave .venv-ml on disk for your next local test run.
  • Leave artifacts/efficientphys_smoke.pt on disk for future restoration checks.
  • Keep the remote ml-training-ci branch available on GitHub.
  • Leave WebPulse ML gate active while its required workflow remains available.

Delete - I don't want to use this again

Remove the project-generated environment, checkpoint, remote branch, and merge ruleset. The existing WebPulse repository source files stay on your machine.

Deletion Order Matters

The active ruleset requires ML smoke gates. Removing the workflow first can leave main waiting for a check that no longer exists.

  • Return to the repository's Settings page on GitHub.
  • Select Rulesets in the sidebar.
  • Select Rulesets on the rulesets page.
  • Select WebPulse ML gate.
  • Use the ruleset page's deletion option to delete WebPulse ML gate.
  • Confirm the ruleset list no longer shows WebPulse ML gate.

The merge gate is now detached from main. The workflow can disappear without leaving a required status check behind.

  • Switch back to the PowerShell terminal for the WebPulse repository from earlier.
  • Delete the remote branch by running this command:
git push origin --delete ml-training-ci

What Does This Command Do?

This removes the remote ml-training-ci branch from origin. It also removes that branch's copy of .github/workflows/ml-ci.yml from GitHub.

  • Refresh the WebPulse repository page on GitHub.
  • Confirm the remote branches no longer list ml-training-ci.

Branch Deletion Rejected?

Check that Git is authenticated to an account with permission to push to WebPulse.

Help me diagnose a rejected remote branch deletion.

The next cleanup removes the environment and smoke checkpoint from this computer. The WebPulse repository source files stay in place.

  • Press the Windows key to open search.
  • Type File Explorer.
  • Press Enter to open File Explorer.
  • Navigate to the WebPulse repository folder currently open in VS Code.
  • Delete the .venv-ml folder.
  • Delete the artifacts folder.
  • Refresh File Explorer.
  • Confirm .venv-ml no longer appears in WebPulse.
  • Confirm artifacts no longer appears in WebPulse.

Cleanup complete. You removed every teardownable resource created by this project.

Nice Work!

Nice Work!

You did it! WebPulse now has a paper-aligned EfficientPhys smoke-training foundation backed by an enforced CI quality gate.

You've learned how to:

  • Explain the paper-to-code model contract that turns 11 RGB frames into 10 BVP predictions.
  • Run subject-independent smoke training on deterministic synthetic fixtures with AdamW plus negative Pearson loss to create a restorable checkpoint.
  • Guard the full training path with five automated regression tests in GitHub Actions.
  • Secret Mission: Protected main with the active WebPulse ML gate ruleset so ML smoke gates must pass before merging.

Ready to quiz yourself?