augsig: A Python Package for Physiological Signal Augmentation 0.2.0

File: <base>/tests/test.py (12,443 bytes)
import numpy as np
import matplotlib.pyplot as plt

from augsig.utils import normalize
from augsig.warper import (twarp_bezier, twarp_pchip,
                            adrift_bezier, adrift_pchip,
                            amod_bezier, amod_pchip,
                            drift_linear)
from augsig.noisifier import noisify, burstify
from augsig.augmenter import augment, Augment

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
DATA_PATH = "data/sample_ppg.npy"
FS = 40  # Hz — sampling rate of the sample PPG (BIDMC dataset, PLETH channel, resampled from 125 Hz)

# ---------------------------------------------------------------------------
# Signal loading
# ---------------------------------------------------------------------------
try:
    x = np.load(DATA_PATH)
    print(f"Loaded real data: shape {x.shape} (length {len(x)})\n")
except Exception:
    print("Real data not found — using synthetic PPG-like signal\n")
    t = np.linspace(0, 10, FS * 10)
    x = np.sin(2 * np.pi * 1.2 * t) + 0.3 * np.sin(2 * np.pi * 2.4 * t)

x = normalize(x)
t = np.arange(len(x)) / FS

# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def plot_aug(original, augmented, title, fname):
    plt.figure(figsize=(10, 3))
    plt.plot(t, original, label="Original", color = "blue", linewidth=1.2)
    plt.plot(t, augmented, label=title, color = "red", linewidth=1.0, alpha=0.65)
    plt.xlabel("Time (s)")
    plt.ylabel("Amplitude")
    plt.title(title)
    plt.legend()
    plt.grid(True)
    plt.tight_layout()
    plt.savefig(fname)
    plt.close()

# ---------------------------------------------------------------------------
# 1. warper — direct function API
#
# Tests each warper function in isolation, bypassing augment().
# Each call gets its own seeded RNG so outputs are independently reproducible.
# ---------------------------------------------------------------------------
print("=== warper ===")

# Time warping: resamples the signal along a non-linear time axis.
# k = number of Bezier/PCHIP control points; variance controls warp strength.
# k can be set relative to signal length; e.g., k=int(len(x) / (2*FS)) places one control point every 2 s.
out = twarp_bezier(x, k=4, variance=0.01, rng=np.random.default_rng(42))
plot_aug(x, out, "Bezier Time Warp", "test_twarp_bezier.png")

out = twarp_pchip(x, k=4, variance=0.01, rng=np.random.default_rng(42))
plot_aug(x, out, "PCHIP Time Warp", "test_twarp_pchip.png")

# Amplitude drift: adds a slow, smooth baseline shift over the signal length.
# k can be set relative to signal length; e.g., k=int(len(x) / (2*FS)) places one control point every 2 s.
out = adrift_bezier(x, k=4, variance=0.2, rng=np.random.default_rng(42))
plot_aug(x, out, "Bezier Amplitude Drift", "test_adrift_bezier.png")

out = adrift_pchip(x, k=4, variance=0.1, rng=np.random.default_rng(42))
plot_aug(x, out, "PCHIP Amplitude Drift", "test_adrift_pchip.png")

# Amplitude modulation: multiplies the signal by a smooth random envelope.
# k can be set relative to signal length; e.g., k=int(len(x) / (2*FS)) places one control point every 2 s.
out = amod_bezier(x, k=4, variance=0.2, rng=np.random.default_rng(42))
plot_aug(x, out, "Bezier Amplitude Modulation", "test_amod_bezier.png")

out = amod_pchip(x, k=4, variance=0.1, rng=np.random.default_rng(42))
plot_aug(x, out, "PCHIP Amplitude Modulation", "test_amod_pchip.png")

# Linear drift: adds a + b*t baseline. Random when a/b are ranges, fixed when scalars.
out = drift_linear(x, a=[-0.3, 0.3], b=[-0.1, 0.1], rng=np.random.default_rng(42))
plot_aug(x, out, "Linear Drift (random)", "test_drift_linear_random.png")

out = drift_linear(x, a=0.2, b=-0.1)  # deterministic — no rng needed
plot_aug(x, out, "Linear Drift (fixed)", "test_drift_linear_fixed.png")

# ---------------------------------------------------------------------------
# 2. noisifier — direct function API
#
# Tests noise injection in isolation. SNR=20 dB is mild noise, chosen to keep
# the augmented signal visually similar to the original.
# ---------------------------------------------------------------------------
print("\n=== noisifier ===")

# Four noise colors: white (flat PSD), pink (1/f), brown (1/f²), violet (f²).
for color in ("white", "pink", "brown", "violet"):
    noisy, _ = noisify(x, snr_db=20, color=color, bpass_params=[0, 1], dist="gauss", rng=np.random.default_rng(42))
    plot_aug(x, noisy, f"Noisify ({color}, SNR=20dB)", f"test_noisify_{color}.png")

# Bandpass-filtered pink noise: retains only 0.05–0.5 (Nyquist-normalized), order=4.
noisy, _ = noisify(x, snr_db=20, color="pink", bpass_params=[0.05, 0.5, 4], dist="gauss", rng=np.random.default_rng(42))
plot_aug(x, noisy, "Pink Noise + Bandpass (SNR=20dB)", "test_noisify_pink_bp.png")

# Resampled noise: noise samples are drawn (with replacement) from a pool signal.
# Here the signal itself is the pool ("self"); it can be any 1D array, e.g. a
# separate recording of known noise or another subject's signal.
noisy, _ = noisify(x, snr_db=20, color="white", bpass_params=[0, 1],
                   dist="resample", resample_pool="self", rng=np.random.default_rng(42))
plot_aug(x, noisy, "Resampled Noise (pool=self, SNR=20dB)", "test_noisify_resample.png")

# Burst noise: sporadic high-amplitude spikes simulating motion artifacts.
burst_sig, _ = burstify(x, snr_db=10, n_bursts=5, burst_width=[int(0.1*FS), int(0.5*FS)], burst_base=0.0, rng=np.random.default_rng(42))
plot_aug(x, burst_sig, "Burst Noise", "test_burstify.png")

# ---------------------------------------------------------------------------
# 3. augmenter — augment()
#
# Tests each augmentation type through the high-level augment() API.
# seed=42 is passed to every call so plots are reproducible across runs.
# Output columns: col 0 = original, col 1 = augmented variant.
# ---------------------------------------------------------------------------
print("\n=== augmenter: augment() ===")

cfg = {"r": {"Add_noise": True, "SNRdb": 20, "noise_color": "white", "bpass_params": [0, 1], "dist": "gauss"}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "Add_noise (white, SNR=20dB)", "test_aug_add_noise.png")

cfg = {"r": {"Add_noise": True, "SNRdb": 20, "noise_color": "pink", "bpass_params": [0, 1], "dist": "gauss"}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "Add_noise (pink, SNR=20dB)", "test_aug_add_noise_pink.png")

cfg = {"r": {"Flip": True}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "Flip", "test_aug_flip.png")

cfg = {"r": {"Invert": True}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "Invert", "test_aug_invert.png")

# k can be set relative to signal length; e.g., k=int(len(x) / (2*FS)) places one control point every 2 s.
cfg = {"r": {"Bezier_time_warp": True, "Bezier_time_warp_k": 4, "Bezier_time_warp_var": 0.01}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "Bezier_time_warp", "test_aug_bezier_timewarp.png")

cfg = {"r": {"PCHIP_time_warp": True, "PCHIP_time_warp_k": 4, "PCHIP_time_warp_var": 0.01}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "PCHIP_time_warp", "test_aug_pchip_timewarp.png")

# k can be set relative to signal length; e.g., k=int(len(x) / (2*FS)) places one control point every 2 s.
cfg = {"r": {"Bezier_amp_drift": True, "Bezier_amp_drift_k": 4, "Bezier_amp_drift_var": 0.2}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "Bezier_amp_drift", "test_aug_bezier_ampdrift.png")

cfg = {"r": {"PCHIP_amp_drift": True, "PCHIP_amp_drift_k": 4, "PCHIP_amp_drift_var": 0.1}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "PCHIP_amp_drift", "test_aug_pchip_ampdrift.png")

# k can be set relative to signal length; e.g., k=int(len(x) / (2*FS)) places one control point every 2 s.
cfg = {"r": {"Bezier_amp_mod": True, "Bezier_amp_mod_k": 4, "Bezier_amp_mod_var": 0.2}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "Bezier_amp_mod", "test_aug_bezier_ampmod.png")

cfg = {"r": {"PCHIP_amp_mod": True, "PCHIP_amp_mod_k": 4, "PCHIP_amp_mod_var": 0.1}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "PCHIP_amp_mod", "test_aug_pchip_ampmod.png")

cfg = {"r": {"lf_noise": True, "lf_noise_amplitude": 0.05, "lf_noise_frequency": 0.05}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "lf_noise", "test_aug_lf_noise.png")

# Powerline: frequency is Nyquist-normalized (0.3 ≈ 6 Hz at FS=40, proxy for 50/60 Hz at higher fs).
# At f_norm=1 (Nyquist), sin(π·idx) = 0 for all integer idx, so amplitude becomes
# phase-dependent — use sub-Nyquist frequencies to avoid that degenerate case.
cfg = {"r": {"powerline": True, "powerline_amplitude": 0.05, "powerline_frequency": 0.3}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "powerline", "test_aug_powerline.png")

cfg = {"r": {"burst": True, "burst_amplitude": 0.2, "burst_number": 5, "burst_width": [5, 20], "burst_frequency": 0.05}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "burst", "test_aug_burst.png")

cfg = {"r": {"drift": True, "drift_a": 0.5, "drift_b": 0.5}}
out = augment(x, cfg, seed=42)
plot_aug(out[:, 0], out[:, 1], "drift (linear)", "test_aug_drift.png")

# ---------------------------------------------------------------------------
# 4. augmenter — Augment class
#
# The Augment class holds config and seed as state, making it convenient for
# repeated calls (e.g. in a DataLoader). With seed set, each __call__ resets
# the RNG to the same state, producing identical output — useful for TTA where
# the same augmentation must be applied consistently.
# ---------------------------------------------------------------------------
print("\n=== augmenter: Augment class ===")

cfg = {"noise": {"Add_noise": True, "SNRdb": 20, "noise_color": "white", "bpass_params": [0, 1], "dist": "gauss"}}
augmentor = Augment(cfg, seed=42)
out = augmentor(x)

# Verify that a seeded Augment produces identical outputs on repeated calls.
augmentor_seeded = Augment(cfg, seed=42)
out_a = augmentor_seeded(x)
out_b = augmentor_seeded(x)
print("Seeded reproducibility:", np.allclose(out_a, out_b))

# ---------------------------------------------------------------------------
# 5. augmenter — num_copies
#
# num_copies generates multiple stochastic variants from the same config key.
# Output shape is (N, 1 + num_copies): col 0 is the original.
# ---------------------------------------------------------------------------
print("\n=== augmenter: num_copies ===")

cfg = {"r": {"num_copies": 3, "Add_noise": True, "SNRdb": 20, "noise_color": "white", "bpass_params": [0, 1], "dist": "gauss"}}
out = augment(x, cfg, seed=42)
print(f"num_copies=3 → output shape: {out.shape}")

# ---------------------------------------------------------------------------
# 6. augmenter — TTA/ITA bundle
#
# Multiple config keys produce one augmented variant each.
# This mirrors a real TTA/ITA setup where diverse augmentations are ensembled
# at inference time to improve prediction robustness.
# ---------------------------------------------------------------------------
print("\n=== augmenter: TTA/ITA bundle ===")

tta_ita_config = {
    "flip":        {"Flip": True},
    "invert":      {"Invert": True},
    "noise":       {"Add_noise": True, "SNRdb": 20, "noise_color": "white", "bpass_params": [0, 1], "dist": "gauss"},
    "bez_twarp":   {"Bezier_time_warp": True, "Bezier_time_warp_k": 4, "Bezier_time_warp_var": 0.01},
    "pchip_twarp": {"PCHIP_time_warp": True, "PCHIP_time_warp_k": 4, "PCHIP_time_warp_var": 0.01},
    "bez_amod":    {"Bezier_amp_mod": True, "Bezier_amp_mod_k": 4, "Bezier_amp_mod_var": 0.05},
    "pchip_amod":  {"PCHIP_amp_mod": True, "PCHIP_amp_mod_k": 4, "PCHIP_amp_mod_var": 0.05},
    "drift":       {"drift": True, "drift_a": [-0.3, 0.3], "drift_b": [-0.1, 0.1]},
}

out = augment(x, tta_ita_config, seed=42)
print(f"TTA/ITA bundle → output shape: {out.shape}")


if __name__ == "__main__":
    pass