NF Protocols#

A neurofeedback protocol decides when and how much to reward. It sits between the raw NF feature value (e.g. alpha power) and the feedback signal delivered to the participant. MNE-RT ships ten protocols, covering the full spectrum from simple fixed-threshold designs to adaptive psychophysics staircases, reinforcement-learning thresholds, operant conditioning schedules, cross-session transfer, and double-blind sham control.

All protocols share the same two-value contract:


Choosing a Protocol#

Protocol Best for Key property Requires baseline?
ThresholdProtocol Clinical NF, demos, debugging Fixed or slowly-adapting threshold; most interpretable No
ZScoreProtocol Within-session adaptation, variable signals Rewards deviation from running mean — self-calibrating No (warmup windows)
PercentileProtocol Gamification, progressive training Threshold tracks own rolling distribution — difficulty auto-scales No
LinearTrendProtocol Slow-learning paradigms, gradual skill acquisition Rewards slope, not level — sustained change wins No
ShamProtocol RCTs, sham control conditions Wraps any protocol; shuffles feedback on fraction of windows Depends on inner
UpDownStaircaseProtocol Threshold estimation, psychophysics Converges threshold to target success rate via n-up/n-down rule No
MultiBandProtocol Dual-band NF (alpha↑ + theta↓, SMR↑ + theta↓) AND/OR combination of two independent inner protocols Depends on inner protocols
RLProtocol Automated threshold search, fully adaptive training ε-greedy exploration + hit-rate-driven threshold update No (warmup windows)
OperantProtocol Partial reinforcement, ratio/interval schedules Wraps any protocol; gates rewards by FR/VR/FI/VI schedule Depends on inner
TransferProtocol Cross-session transfer, prior-seeded z-score Seeds running statistics from a prior session file — zero warmup No (prior file)

Threshold Protocol#

Reward rule:   crossed  =  x > θ   (direction = "up")  |  x < θ   (direction = "down")

The simplest and most widely used protocol. A fixed threshold \(\theta\) is placed on the NF feature scale and the participant receives feedback whenever the signal crosses it.

\[\begin{split}\text{crossed} = \begin{cases} x_t > \theta & \text{if direction = "up"} \\ x_t < \theta & \text{if direction = "down"} \end{cases} \qquad \text{magnitude} = \begin{cases} |x_t - \theta| & \text{if crossed} \\ 0 & \text{otherwise} \end{cases}\end{split}\]

The adapt_rate parameter adds slow threshold drift to maintain a target success rate over time — increase \(\theta\) after rewarded windows, decrease after missed windows.

When to use: Quick prototyping, clinical applications where the threshold is set by an expert, or as the inner protocol for ShamProtocol or MultiBandProtocol.

from mne_rt.protocols import ThresholdProtocol

proto = ThresholdProtocol(threshold=1.2e-10, direction="up")
crossed, mag = proto.evaluate(alpha_power)

Z-Score Protocol#

Reward rule:   z  =  (x − μ) / σ   →   reward if z > zthr

During a short warmup phase (typically 20 windows) the protocol collects enough values to seed its running mean \(\hat\mu\) and standard deviation \(\hat\sigma\) using Welford’s online algorithm. From then on, each new value is z-scored against the participant’s own distribution:

\[z_t = \frac{x_t - \hat\mu_t}{\hat\sigma_t}, \qquad \text{crossed} = z_t > z_\text{thr}\]

Because the statistics adapt to each individual, the same threshold \(z_\text{thr}\) (e.g. 0.5) produces approximately the same success rate regardless of signal amplitude differences between participants or sessions.

Typical trajectory

window:  1  2  3  4  5 … 20 | 21  22  23  24  25
value:   1  2  1  3  2 … 2  |  3   1   5   2   4
z-score: —  —  —  —  — … —  |0.2  -0.8 2.1 0.0 1.3
reward:  ·  ·  ·  ·  · … ·  |  ·   ·   ✓   ·   ✓     (z_thr = 0.5)
warmup phase ───────────────┘ live phase ──────────

When to use: When signal amplitude varies across sessions or participants and you want consistent difficulty without manually re-setting thresholds.

from mne_rt.protocols import ZScoreProtocol

proto = ZScoreProtocol(direction="up", zscore_threshold=0.5, warmup_windows=20)
for value in nf_stream:
    crossed, mag = proto.evaluate(value)

Percentile Protocol#

Reward rule:   reward if x > Pn(history)   |   typically n = 75

A rolling buffer of the last history_len values is maintained. The N-th percentile of this buffer acts as the current threshold:

\[\theta_t = \operatorname{Percentile}_n\!\bigl(\{x_{t-W}, \ldots, x_{t-1}\}\bigr)\]

Because the threshold tracks the participant’s own recent distribution, difficulty automatically scales up during peak performance and relaxes during fatigue — producing a self-calibrating reward rate of approximately \((100 - n)\%\).

When to use: Training paradigms where the goal is relative improvement over recent performance rather than crossing an absolute target. The 75th-percentile default rewards the best ~25 % of windows.

from mne_rt.protocols import PercentileProtocol

proto = PercentileProtocol(percentile=75.0, direction="up", history_len=100)
crossed, mag = proto.evaluate(alpha_power)

Linear-Trend Protocol#

Reward rule:   OLS slope > slopethr  and  R² ≥ min_r2

Rather than comparing the current value to a threshold, this protocol fits an ordinary-least-squares regression over the last window values and rewards when the slope is in the target direction with sufficient goodness-of-fit:

\[\begin{split}[a,\, b] &= \operatorname{OLS} \!\bigl(\{1, \ldots, W\},\, \{x_{t-W+1}, \ldots, x_t\}\bigr) \\ \text{crossed} &= a > a_\text{thr} \;\land\; R^2 \geq R^2_\text{min}\end{split}\]

This avoids rewarding transient spikes and instead encourages sustained directional change — a more meaningful signal for genuine learning.

Typical trajectory

values over 5-window regression:

spike (not rewarded)     sustained rise (rewarded)
▲  ·                      ▲          ·
│ ╭╮                      │        ╭─╯
│╭╯╰╮                     │      ╭─╯
│╯  ╰──                   │    ╭─╯
└──────────               └──────────
slope ≈ 0, R² low         slope > 0, R² high

When to use: Slow-learning paradigms (e.g. tinnitus suppression, chronic pain, depression) where the target is gradual signal reorganisation over minutes rather than fast-reacting moment-to-moment control.

from mne_rt.protocols import LinearTrendProtocol

proto = LinearTrendProtocol(direction="up", window=20, slope_threshold=0.0, min_r2=0.3)
crossed, mag = proto.evaluate(alpha_power)

Sham Protocol#

Design:   wraps any inner protocol — on sham_rate fraction of windows, returns a randomly-drawn historical value instead of the real one.

In a double-blind NF design the participant should not be able to tell when feedback is real and when it is sham. ShamProtocol intercepts the inner protocol’s output on a configurable fraction of windows and substitutes a randomly-drawn historical reward value:

window:    1     2     3     4     5     6     7     8  …
real:    (T,1) (F,0) (T,2) (F,0) (T,3) (F,0) (T,1) (F,0)
output:  (T,1) (F,0) (T,1) (F,0) (T,3) (T,2) (T,1) (F,0)
sham?:     no    no   yes    no    no   yes    yes    no

The inner protocol’s state always advances correctly — only the delivered feedback is sometimes replaced. The sham_log attribute records which windows were sham for post-session unblinding.

Important: the inner protocol must be configured separately; the ShamProtocol wrapper is agnostic to the inner protocol type.

When to use: Any experiment that requires a within-session sham control condition. Set sham_rate=0.5 for a 50/50 real/sham split, or use rng_seed for exact reproducibility.

from mne_rt.protocols import ZScoreProtocol
from mne_rt.protocols.sham import ShamProtocol

inner = ZScoreProtocol(direction="up")
proto = ShamProtocol(inner, sham_rate=0.5, rng_seed=42)
for value in nf_stream:
    crossed, magnitude = proto.evaluate(value)

# After session:
sham_indices = [i for i, s in enumerate(proto.sham_log) if s]

Up-Down Staircase Protocol#

Design:   threshold rises after nup successes, falls after ndown failures — converges to a target success rate.

The classic psychophysics staircase of Levitt[1]. The threshold \(\theta\) is adjusted after each window based on the participant’s recent success/failure run:

\[\begin{split}\theta_{t+1} = \begin{cases} \theta_t + \Delta & \text{after } n_\text{up} \text{ consecutive successes}\\ \theta_t - \Delta & \text{after } n_\text{down} \text{ consecutive failures} \end{cases}\end{split}\]

The step size \(\Delta\) is halved after every n_reversals_before_halving direction reversals (a reversal is when the staircase changes direction), zooming in progressively on the participant’s current threshold.

Convergence levels for common rules:

Rule Success rate Use case
1-up / 1-down50 %Equal success/fail balance
1-up / 2-down70.7 %Standard; motivating default
1-up / 3-down79.4 %Easier start, more rewards early

Typical staircase trajectory

threshold
0.70  ·         ·─·
0.65  ·─·     ·─╯ ╰─·
0.60    ╰─·─·─╯     ╰─·
0.55                  ╰─·─·
0.50  initial             ╰──    (converging)
      1  2  3  4  5  6  7  8  9  window
      S  S  F  F  S  S  F  S  F   (S=success, F=fail, 1-up/2-down)
reversal points ↑ stored in reversal_thresholds

When to use: When you need an objective, data-driven estimate of the participant’s NF threshold — analogous to threshold tracking in audiometry or psychophysics.

from mne_rt.protocols.staircase import UpDownStaircaseProtocol

proto = UpDownStaircaseProtocol(
    initial_threshold=0.5, direction="up",
    n_up=1, n_down=2, step_size=0.05,
)
for value in nf_stream:
    crossed, mag = proto.evaluate(value)

# After session — estimate perceptual threshold:
threshold_estimate = float(np.mean(proto.reversal_thresholds[-6:]))

References: Levitt[1], Garcıa-Pérez[2]


Multi-Band Protocol#

Design:   reward = (alpha ↑ AND theta ↓)  |  combined magnitude = √(magup × magdown)

Many clinical NF protocols target two frequency bands simultaneously — reward alpha up-regulation while penalising theta up-regulation (focus training), or reward SMR while suppressing theta (ADHD protocol [3]).

MultiBandProtocol wraps two independent inner protocols and combines their outputs:

\[\begin{split}\text{crossed} &= \text{crossed}_\text{up} \;\land\; \text{crossed}_\text{down} \quad \text{(AND, require\_both=True)} \\ \text{magnitude} &= \sqrt{\text{mag}_\text{up} \times \text{mag}_\text{down}} \quad \text{(geometric mean)}\end{split}\]

The geometric mean ensures both bands contribute equally: a very large alpha reward cannot compensate for zero theta suppression — both must be non-zero to produce a non-zero combined magnitude.

AND vs OR logic

window:     1     2     3     4
alpha↑:   (T,2) (T,1) (F,0) (T,2)
theta↓:   (F,0) (T,1) (T,1) (T,0.5)

AND logic:  ✗     ✓     ✗     ✓     (both must cross)
magnitude:  0   √1×1=1  0   √2×0.5≈1.0

OR logic:   ✓     ✓     ✓     ✓     (either crosses)

When to use: Any protocol that targets two brain rhythms simultaneously. The two inner protocols are independent and can be of different types — e.g. a ThresholdProtocol for alpha and a ZScoreProtocol for theta.

from mne_rt.protocols import ZScoreProtocol
from mne_rt.protocols.multiband import MultiBandProtocol

alpha_proto = ZScoreProtocol(direction="up",   warmup_windows=20)
theta_proto = ZScoreProtocol(direction="down",  warmup_windows=20)

proto = MultiBandProtocol(
    protocol_up=alpha_proto,
    protocol_down=theta_proto,
    require_both=True,
    up_label="alpha",
    down_label="theta",
)
for alpha_val, theta_val in zip(alpha_stream, theta_stream):
    crossed, magnitude = proto.evaluate(alpha_val, theta_val)

References: Sterman and Egner[3]


RL Protocol#

Reward rule:   threshold adapts via hit-rate error  |  ε-greedy exploration prevents early convergence

A lightweight reinforcement-learning protocol that finds the right reward threshold automatically, without requiring any manual calibration. During a short warmup phase no rewards are issued while the protocol collects statistics. Afterwards, the threshold \(\theta\) is adjusted after each window to keep the rolling hit rate close to the target:

\[\begin{split}\delta_t &= \hat{h}_t - h^* \qquad (\hat{h}_t = \text{rolling hit rate},\; h^* = \text{target hit rate}) \\[4pt] \theta_{t+1} &= \theta_t + \eta \cdot \delta_t \quad (\text{direction = "up"})\end{split}\]

With probability \(\varepsilon\) (the epsilon parameter) the current value is treated as a forced hit (exploration step), preventing the threshold from drifting so high that the participant never succeeds.

When to use: When there is no prior calibration data and you want the protocol to self-tune from scratch. Works particularly well in conjunction with ShamProtocol for within-session sham control.

from mne_rt.protocols import RLProtocol

proto = RLProtocol(
    direction="up",
    target_hit_rate=0.70,
    lr=0.01,
    epsilon=0.1,
    warmup_windows=20,
    history_len=50,
)
for value in nf_stream:
    crossed, mag = proto.evaluate(value)

Operant Protocol#

Design:   wraps any inner protocol — gates the reward output through a classical operant conditioning schedule (FR / VR / FI / VI).

Partial reinforcement schedules are more resistant to extinction than continuous reinforcement Ferster and Skinner[4]. OperantProtocol wraps any existing NF protocol and filters its reward output through one of four classical schedules:

Schedule Abbreviation Rule
Fixed RatioFRReward on every N-th hit
Variable RatioVREach hit rewarded with probability 1/N
Fixed IntervalFIFirst hit after exactly T seconds is rewarded
Variable IntervalVIFirst hit after a random interval (mean T) is rewarded

The inner protocol’s state always advances regardless of whether the schedule releases a reward — so running statistics (z-score, staircase threshold, etc.) continue to update correctly.

When to use: Whenever reduced reward density is experimentally desirable (e.g. to study partial reinforcement extinction effects in NF, or to maintain engagement over long sessions by preventing saturation).

from mne_rt.protocols import ZScoreProtocol, OperantProtocol

inner = ZScoreProtocol(direction="up", warmup_windows=20)
proto = OperantProtocol(inner, schedule="VR", ratio=3, rng_seed=42)
for value in nf_stream:
    crossed, mag = proto.evaluate(value)

References: Ferster and Skinner[4]


Transfer Protocol#

Design:   seeds running z-score statistics from a prior session file — rewards from the very first window of the new session.

The standard ZScoreProtocol needs a warmup phase to estimate \(\hat\mu\) and \(\hat\sigma\) before rewards can be issued. TransferProtocol eliminates warmup by loading the population statistics from a previous session’s beh.json file and seeding the Welford accumulators directly:

\[\hat\mu_0 = \bar{x}_\mathrm{prior}, \qquad \hat\sigma_0 = \sigma_\mathrm{prior}, \qquad n_0 = N_\mathrm{prior}\]

From the first window onward, each new value is z-scored against this informed prior and updates the statistics via Welford’s algorithm, gradually replacing the prior with session-specific data.

Session file format (BIDS-compatible beh.json):

{
  "meta": {"modalities": ["sensor_power"]},
  "data": {"sensor_power": [0.12, 0.14, 0.11, …]}
}

Such files are written automatically by save().

When to use: Multi-session training programmes where consistent reward rates across sessions improve participant motivation. Also useful in studies where the first session’s statistics serve as the participant’s personalised baseline.

from mne_rt.protocols import TransferProtocol

proto = TransferProtocol(
    fname="sub-01_ses-01_task-nf_beh.json",
    modality="sensor_power",
    direction="up",
    zscore_threshold=0.5,
)
for value in nf_stream:
    crossed, mag = proto.evaluate(value)

References#