Phase-Locking Value (PLV)#

Compute phase-locking value between two phase time series. PLV quantifies the consistency of the phase relationship between two signals: 1.0 means perfectly locked, 0.0 means no coupling.

import matplotlib.pyplot as plt
import numpy as np

import gastropy as gp

plt.rcParams["figure.dpi"] = 100
plt.rcParams["figure.facecolor"] = "white"

Basic PLV: Locked vs. Random Phases#

rng = np.random.default_rng(42)
n = 300
t = np.arange(n)

# Two phase series with a constant offset -> perfect coupling
phase_a = np.angle(np.exp(1j * 2 * np.pi * 0.05 * t))
phase_b = phase_a + 0.5  # constant lag
print(f"Locked PLV:  {gp.phase_locking_value(phase_a, phase_b)}")

# Two independent random phase series -> near-zero coupling
rand_a = rng.uniform(-np.pi, np.pi, n)
rand_b = rng.uniform(-np.pi, np.pi, n)
print(f"Random PLV:  {gp.phase_locking_value(rand_a, rand_b):.4f}")
Locked PLV:  1.0
Random PLV:  0.0746

Vectorized PLV across Multiple Signals#

When phase_a is 2D (timepoints x signals), PLV is computed for each signal against the shared reference phase_b. This is how voxelwise coupling maps are computed.

# 5 "voxels" locked to the reference with different offsets + noise
offsets = [0.1, 0.3, 0.5, 0.7, 0.9]
bold_phases = np.column_stack([phase_a + off for off in offsets])
bold_phases += 0.3 * rng.standard_normal(bold_phases.shape)

plv_vec = gp.phase_locking_value(bold_phases, phase_a)
print(f"PLV per voxel: {plv_vec.round(4)}")
PLV per voxel: [0.9564 0.9528 0.9572 0.9558 0.9516]

Complex PLV: Magnitude and Preferred Phase Lag#

phase_locking_value_complex returns the complex mean of the phase difference. Its magnitude is the PLV; its angle gives the preferred phase lag between the two signals.

cplv = gp.phase_locking_value_complex(bold_phases, phase_a)
print(f"Magnitude (= PLV): {np.abs(cplv).round(4)}")
print(f"Preferred lag (deg): {np.rad2deg(np.angle(cplv)).round(1)}")
Magnitude (= PLV): [0.9564 0.9528 0.9572 0.9558 0.9516]
Preferred lag (deg): [ 5.3 16.1 26.2 39.  50.9]
fig, ax = plt.subplots(figsize=(8, 3))
lags = np.rad2deg(np.angle(cplv))
colors = plt.cm.viridis(np.linspace(0.2, 0.8, len(offsets)))
bars = ax.bar(range(len(offsets)), np.abs(cplv), color=colors)
for bar, lag in zip(bars, lags, strict=True):
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, f"{lag:.0f}\u00b0", ha="center", fontsize=9)
ax.set_xticks(range(len(offsets)))
ax.set_xticklabels([f"Voxel {i + 1}" for i in range(len(offsets))])
ax.set_ylabel("PLV")
ax.set_ylim(0, 1.1)
ax.set_title("PLV with Preferred Phase Lag")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
fig.tight_layout()
plt.show()
../_images/6a975f8670af0da9f46d815e0f6efed4bb8a0b258b5f70e57873e1002895eda4.png

See also: Surrogate Testing, Circular Statistics, Coupling Tutorial