SciTeX Seizure Metrics (scitex-seizure-metrics)
Unified evaluation library for seizure detection and forecasting — sample-based, alarm-based, and the bridge between them.
Full Documentation · uv pip install scitex-seizure-metrics[all]
Problem and Solution
| # | Problem | Solution |
|---|---|---|
| 1 | Scattered metrics — Cook 2013, Karoly 2017, Maturana 2020, Kuhlmann 2018 and Proix 2021 each report different numbers; no two can share one axis. | One MetricsReport through one API; bridge.sample_to_alarm bounds the unreported side. |
| 2 | Untooled collapse — Andrade 2024: 50/56 patients beat chance sample-based, only 6/46 alarm-based; no packaged tool applies both. | Two evaluators in one library; same input, both regimes side-by-side. |
| 3 | Vague FP/hr — total vs interictal time, unstated refractory rules. | Explicit AlarmPolicy on every alarm-aware function; every number reproducible. |
Table 1. Why seizure evaluation needs one library: scattered metrics, the untooled sample-vs-alarm collapse, and vague FP/hr denominators.
Comparison with existing tools
| Tool | Language | Sample-based | Event-based | Forecasting (SPH/SOP) | IoC vs surrogate | Cross-paper convertor | Status |
|---|---|---|---|---|---|---|---|
timescoring (SzCORE engine, Dan et al. 2024) |
Python | ✅ | ✅ | ❌ | ❌ | ❌ | maintained |
szcore-evaluation (BIDS wrapper) |
Python | ✅ | ✅ | ❌ | ❌ | ❌ | maintained |
EPILAB (Direito et al. 2011) |
MATLAB | ✅ | ◐ | ✅ | ✅ | ❌ | last release 2018 |
PySeizure (2025) |
Python | ✅ | ❌ | ❌ | ❌ | ❌ | early — focused on detection |
SeizyML (2024) |
Python | ✅ | ✅ | ❌ | ❌ | ❌ | detection scope |
| Andrade et al. 2024 (paper) | — | ✅ | ✅ | ✅ | ✅ | ❌ | research code, not a package |
| scitex-seizure-metrics | Python | ✅ | ✅ | ✅ | ✅ | ✅ | this repo |
Supported Metrics
Quick definitions for the metrics and policy knobs that recur throughout the README, the docstrings, and the cited papers.
Sample-based metrics
| Term | Meaning |
|---|---|
| AUROC | Area Under the Receiver Operating Characteristic curve. Probability the model ranks a random positive window above a random negative window. Threshold-free; insensitive to class prevalence. |
| AUPRC | Area Under the Precision–Recall curve. Threshold-free; sensitive to class prevalence — the value to read on heavily-imbalanced seizure data when AUROC looks deceptively high. |
| Brier | Mean squared error between predicted probability and the 0/1 label. Lower is better. Decomposes into reliability + resolution + uncertainty (scitex_seizure_metrics.calibration). |
| MCC | Matthews Correlation Coefficient. A single balanced summary statistic robust to class imbalance; ranges from −1 (anti-correlation) through 0 (chance) to +1 (perfect). |
| Balanced accuracy | (Sensitivity + Specificity) / 2. The accuracy you would get if the prevalence were 50/50. |
| Sensitivity (recall) | Fraction of true seizures detected. Reported at a chosen threshold. |
| Precision (PPV) | Fraction of detections that were true seizures. Drops fast under low prevalence. |
| ECE | Expected Calibration Error. Average gap between predicted probability and observed frequency across bins. |
Alarm-based metrics
| Term | Meaning |
|---|---|
| Alarm | A single binary "warning is on" event derived from a thresholded probability stream + the AlarmPolicy. |
| FP/hr (false-positive rate per hour) | Number of alarms not followed by a seizure within (SPH, SPH + SOP], normalised by the chosen denominator (fp_denominator='total' or 'interictal'). |
| IoC | Improvement over Chance. The signed gap between the model's alarm-based sensitivity and the same statistic recomputed under a chance-baseline alarm generator (scitex_seizure_metrics.surrogates, default Poisson). Significance is read from a surrogate distribution. |
| Time-in-warning (TIW, "proportion time in warning") | Fraction of recording time spent inside an active warning window (between alarm onset and refractory end). The natural denominator that pairs with sensitivity in the Proix 2021 operating curve. |
| Sensitivity vs proportion-time-in-warning | Operating curve introduced by Proix 2021. Plotted instead of sensitivity vs FP/hr when alarm refractory periods make per-hour counts misleading. Same x-axis units as Cook 2013's "time-in-warning" reporting. |
| Beats chance (alarm) | Boolean — is the model's IoC above the surrogate distribution at the configured significance level? Andrade 2024's headline: 50/56 patients beat chance under sample-based eval but only 6/46 under alarm-based. |
| Specificity / PPV / NPV / F1 (alarm regime) | Standard confusion-matrix scores on the alarm-vs-prediction-opportunity basis. TP = caught seizures, FN = uncaught seizures, FP = alarms catching nothing; TN = interictal SOP-length "prediction opportunities" with no false alarm (n_opportunities = floor(interictal_seconds / SOP), TN = max(0, n_opportunities − FP), Snyder/Schelter/Mormann tradition). So specificity = TN/(TN+FP), ppv (alarm precision) = TP/(TP+FP), npv = TN/(TN+FN), forecasting_f1 = 2·TP/(2·TP+FP+FN). specificity/npv scale with the SOP-opportunity TN convention (n_tn / n_opportunities are reported alongside so the denominator is visible); ppv/forecasting_f1 do not depend on TN. Undefined ratios are NaN, never a silent 0. |
| Observed lead time | Per caught seizure, onset minus the earliest catching alarm (after SPH). Distinct from the SPH constraint: SPH is the minimum required gap, lead time is what the system actually delivered (always SPH ≤ lead ≤ SPH + SOP). lead_time_mean / lead_time_median summarise the distribution; the per-seizure array is in extras["lead_times_seconds"]. |
The
AlarmPolicyconfig knobs (SPH · SOP · cadence · refractory · alarm-threshold · FP-denominator) are documented inline on the dataclass and shown in the forecasting example below — they pin alarm-derivation, not metric definitions.
Quick Start
from scitex_seizure_metrics import detection, forecasting, AlarmPolicy
# Detection — per-window classification
rep = detection.evaluate(y_true, y_proba, threshold=0.5, fs=1)
print(rep.roc_auc, rep.pr_auc, rep.brier, rep.mcc)
# Forecasting — continuous stream with explicit alarm policy
policy = AlarmPolicy(
sph_seconds=300, sop_seconds=600, cadence_seconds=60,
refractory_seconds=600, alarm_threshold=0.5,
fp_denominator="interictal", # Mormann tradition
)
rep = forecasting.evaluate_stream(
proba, times, seizures, policy,
total_recording_time=24 * 3600,
)
print(rep.sensitivity, rep.fp_per_hour, rep.ioc, rep.time_in_warning_frac)
See examples/01_detection_quick_start.ipynb, examples/02_forecasting_quick_start.ipynb, and the other notebooks under examples/ for end-to-end workflows.
Forecasting classification metrics + lead time (v0.2.0)
The alarm regime reports the full confusion matrix and the warning time
it actually delivered. TP = caught seizures, FN = uncaught,
FP = alarms catching nothing; TN = interictal SOP-length
"prediction opportunities" with no false alarm
(n_opportunities = floor(interictal_seconds / SOP),
TN = max(0, n_opportunities − FP) — the Snyder/Schelter/Mormann
tradition; see docs/math/alarm_confusion_matrix.md
and ADR-0001
for why "what is a true negative" is a documented convention).
import numpy as np
from scitex_seizure_metrics import AlarmPolicy, forecasting
# 3 seizures; 3 alarms, the first two of which catch a seizure
seizures = np.array([3600.0, 7200.0, 18000.0])
alarms = np.array([3000.0, 6900.0, 12000.0])
policy = AlarmPolicy(sph_seconds=300, sop_seconds=600,
cadence_seconds=60, refractory_seconds=600,
fp_denominator="interictal")
rep = forecasting.evaluate(alarms, seizures, policy,
total_recording_time=24 * 3600, n_surrogate=50)
print(rep.n_tp, rep.n_fp, rep.extras["n_fn"], rep.n_tn, rep.n_opportunities)
# 2 1 1 135 136
print(round(rep.sensitivity, 3), round(rep.specificity, 3),
round(rep.ppv, 3), round(rep.npv, 3), round(rep.forecasting_f1, 3))
# 0.667 0.993 0.667 0.993 0.667
print(rep.lead_time_mean, rep.extras["lead_times_seconds"])
# 450.0 [600.0, 300.0] (delivered ≥ the 300 s SPH the policy required)
specificity / npv scale with the SOP-opportunity TN convention (read
them with n_tn / n_opportunities); ppv / forecasting_f1 do not.
Undefined ratios are NaN (fail-loud), never a silent 0. Observed lead
time is distinct from the SPH constraint — SPH is the minimum required,
lead time is what the system actually delivered
(SPH ≤ lead ≤ SPH + SOP).
Demo
from scitex_seizure_metrics import detection, forecasting, AlarmPolicy
# Per-window detection metrics (sensitivity, false-positives/hour, ...)
m = detection.evaluate(y_true=labels, y_pred=preds, fs=256)
print(m.sensitivity, m.fp_per_hour)
# Forecasting metrics (Improvement-over-chance, AUROC, alarm count)
f = forecasting.evaluate(
alarm_times=alarms, seizure_times=onsets,
policy=AlarmPolicy(sph_seconds=300, sop_seconds=600,
cadence_seconds=60, refractory_seconds=600),
total_recording_time=24 * 3600,
)
print(f.ioc, f.roc_auc)
graph LR
Labels["per-window y_true / y_pred"] --> Det["detection.evaluate"]
Onsets["seizure_times + alarm_times"] --> Fore["forecasting.evaluate"]
Det --> Out["sensitivity / FP-per-hour / latency"]
Fore --> Out2["IoC / AUROC / alarm count"]
Figure 1. Demo data flow: per-window labels feed detection, seizure/alarm times feed forecasting.
Installation
uv pip install "scitex-seizure-metrics[all]"
Python ≥ 3.10 (CI tests 3.11 / 3.12 / 3.13). Through the SciTeX umbrella:
pip install "scitex[seizure-metrics]" → scitex.seizure_metrics.*.
Extras
uv pip install scitex-seizure-metrics # runtime only
uv pip install "scitex-seizure-metrics[plots]" # + matplotlib for plots
uv pip install "scitex-seizure-metrics[all]" # plots + docs + dev
Architecture
flowchart LR
Probs["per-window proba<br/>+ ground truth"] --> Det["detection.evaluate"]
Probs --> StreamIn["forecasting.evaluate_stream"]
Policy["AlarmPolicy<br/>SPH · SOP · cadence · refractory · FP denom"] --> StreamIn
Det --> RepDet["MetricsReport<br/>AUROC · AUPRC · Brier · MCC"]
StreamIn --> RepFc["MetricsReport<br/>sensitivity · FP/hr · IoC · TIW"]
RepDet -.->|"bridge analytic bounds"| RepFc
RepFc --> Plots["plots: sensitivity vs FP/hr,<br/>IoC vs surrogate, cadence ablation"]
Figure 2. Architecture: detection and forecasting converge on MetricsReport; the bridge bounds the unreported regime.
The split mirrors how the seizure-evaluation literature itself is
organised — sample-based vs alarm-based vs the bridge — so a
paper-faithful re-implementation lives in exactly one place.
MetricsReport is the single object that travels between regimes;
AlarmPolicy is the single object that pins every reproducibility
decision an alarm-based metric requires.
6 Interfaces
scitex_seizure_metrics.forecasting — alarm-based metrics with explicit AlarmPolicy (primary)
from scitex_seizure_metrics import AlarmPolicy, forecasting
policy = AlarmPolicy(
sph_seconds=300, sop_seconds=600, cadence_seconds=60,
refractory_seconds=600, alarm_threshold=0.5,
fp_denominator="interictal",
)
rep = forecasting.evaluate_stream(
proba, times, seizures, policy,
total_recording_time=24 * 3600, n_surrogate=1000,
)
print(rep.sensitivity, rep.fp_per_hour, rep.ioc, rep.time_in_warning_frac)
# Alarm-regime confusion metrics + observed lead time
print(rep.specificity, rep.ppv, rep.npv, rep.forecasting_f1)
print(rep.lead_time_mean, rep.lead_time_median,
rep.extras["lead_times_seconds"])
# Operating curve across thresholds
df = forecasting.sweep_thresholds(proba, times, seizures, policy)
# Cadence ablation
policies = [AlarmPolicy(..., cadence_seconds=c) for c in [30, 60, 120, 300]]
df = forecasting.sweep_policies(proba, times, seizures, policies)
scitex_seizure_metrics.detection — sample-based metrics (AUROC, AUPRC, Brier, MCC, ...)
from scitex_seizure_metrics import detection
rep = detection.evaluate(y_true, y_proba, threshold=0.5, fs=1)
print(rep.roc_auc, rep.pr_auc, rep.brier, rep.mcc, rep.balanced_accuracy)
scitex_seizure_metrics.bridge — sample↔alarm analytic bounds for cross-paper comparison
from scitex_seizure_metrics import bridge
bnd = bridge.sample_to_alarm(
sample_sensitivity=0.79, sample_specificity=0.85,
sop_seconds=600, cadence_seconds=60, refractory_seconds=600,
)
print(bnd.alarm_sensitivity_upper, bnd.fp_per_hour_upper)
scitex_seizure_metrics.sensitivity_tiw — empirical sensitivity vs time-in-warning trade-off (Karoly 2017 Fig 6)
The empirical complement to the analytic bridge: sweep the decision
threshold and trace seizure-level sensitivity against time-in-warning,
the field-standard forecasting view. Chance is the diagonal
(sensitivity == time-in-warning); a forecaster carries signal only above
it.
from scitex_seizure_metrics import AlarmPolicy, plots, sensitivity_tiw
policy = AlarmPolicy(sph_seconds=0, sop_seconds=600,
cadence_seconds=60, refractory_seconds=600)
curve = sensitivity_tiw.sensitivity_tiw_curve(
scores, policy, seizure_times=onsets, times=times, target_tiw=0.20,
)
print(curve.improvement_over_chance, # AUC-like area above the diagonal
curve.sensitivity_at_target_tiw, # sensitivity at 20 % time-in-warning
curve.tiw_at_target_sensitivity) # time-in-warning at 75 % sensitivity
# Is the operating point above a time-matched coin?
sig = sensitivity_tiw.surrogate_above_chance(
scores, policy, threshold=0.5, seizure_times=onsets, times=times,
)
print(sig.p_value, sig.ci_low, sig.ci_high)
plots.sensitivity_tiw([curve], save_path="fig_sens_tiw") # png + pdf
See docs/math/sensitivity_tiw.md for the
chance-diagonal derivation and a worked example.
scitex_seizure_metrics.papers — paper-replica shims (Karoly 2017, Maturana 2020, Kuhlmann 2018, Andrade 2024)
from scitex_seizure_metrics.papers import andrade2024
out = andrade2024.metrics(
y_true=labels, y_proba=preds,
times_seconds=times, seizure_times=onsets,
)
print(out["sample_auroc"], out["alarm_sensitivity"], out["beats_chance_alarm"])
# Reproduces the side-by-side sample-vs-alarm panel from the paper.
Available shims: karoly2017, maturana2020, kuhlmann2018, andrade2024. Each metrics(...) returns a dict in the paper's preferred metric set.
scitex_seizure_metrics.calibration — Brier decomposition + reliability diagram
from scitex_seizure_metrics import calibration, plots
cal = calibration.calibration_report(y_true, y_proba, n_bins=10)
print(cal.brier, cal.reliability, cal.resolution, cal.uncertainty,
cal.expected_calibration_error)
plots.reliability_diagram(cal)
scitex_seizure_metrics.plots — relationships between metrics
from scitex_seizure_metrics import plots
plots.sensitivity_vs_fp_per_hour(sweep_df) # operating curve
plots.sensitivity_tiw([curve]) # sensitivity vs time-in-warning (Karoly 2017 Fig 6)
plots.ioc_vs_surrogate(sweep_df) # model vs chance
plots.cadence_ablation(policy_sweep_df) # FP/hr vs cadence
plots.sample_vs_alarm_scatter(per_patient_df) # the Andrade 2024 figure
plots.metric_correlation_heatmap(per_patient_df) # redundancy diagnostic
Empirical validation of the sample↔alarm bridge
The analytic bridge is validated by Monte Carlo. For each setting we synthesise a long per-window stream with a known per-window sensitivity s and specificity 1 − α plus seizures, run the AlarmPolicy, and measure the empirical alarm-sensitivity and FP/hr. We check (i) the empirical values land inside the analytic sample_to_alarm [lower, upper] bands, and (ii) the reverse alarm_to_sample recovers the true per-window s and specificity. Each seizure's SOP holds K = ceil(SOP / cadence) windows by construction, so the per-seizure detection bound is 1 − (1 − s)^K independent of prevalence — the soundness fix that replaced an earlier prevalence-shrunk K_eff which collapsed the upper bound to s at realistic low prevalence (empirical ≈ 1.0 vs that bound 0.5 → violated).
| s | specificity | prevalence | K | empirical alarm-sens | alarm-sens band | empirical FP/hr | FP/hr band | sens | FP/hr | reverse s | reverse spec |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0.50 | 0.90 | 0.05 | 10 | 0.950 | [0.50, 1.00] | 3.010 | [0.00, 5.70] | PASS | PASS | PASS | PASS |
| 0.30 | 0.95 | 0.02 | 30 | 0.942 | [0.30, 1.00] | 2.382 | [0.00, 4.00] | PASS | PASS | PASS | PASS |
| 0.70 | 0.85 | 0.10 | 5 | 0.850 | [0.70, 1.00] | 4.930 | [0.00, 8.10] | PASS | PASS | PASS | PASS |
| 0.60 | 0.99 | 0.01 | 60 | 0.992 | [0.60, 1.00] | 0.748 | [0.00, 1.19] | PASS | PASS | PASS | PASS |
All four settings pass in both directions. Reproduce with python examples/06_bridge_validation.py (writes the figure to docs/bridge_validation.{png,pdf} and the table to examples/06_bridge_validation_out/); CI guards it via tests/examples/test_06_bridge_validation.py.
References
- Andrade I, Teixeira C, Pinto M (2024). On the performance of seizure prediction machine learning methods across different databases: the sample and alarm-based perspectives. Frontiers in Neuroscience. doi:10.3389/fnins.2024.1417748.
- Cook MJ et al. (2013). Lancet Neurology. doi:10.1016/S1474-4422(13)70075-9.
- Dan J et al. (2024). SzCORE. Epilepsia. doi:10.1111/epi.18113.
- Direito B et al. (2011). EPILAB. J Neurosci Methods. doi:10.1016/j.jneumeth.2011.06.022.
- Karoly PJ et al. (2017). Brain. doi:10.1093/brain/awx173.
- Kuhlmann L et al. (2018). Brain. doi:10.1093/brain/awy210.
- Maturana MI et al. (2020). Nature Communications. doi:10.1038/s41467-020-15908-3.
- Mormann F et al. (2007). Seizure prediction: the long and winding road. Brain. doi:10.1093/brain/awl241.
- Schulze-Bonhage A et al. (2020). Performance Metrics for Online Seizure Prediction. PMC7340210.
Part of SciTeX
scitex-seizure-metrics is part of SciTeX. Install via the umbrella with pip install scitex[seizure-metrics] to use as scitex.seizure_metrics (the seizure-evaluation surface re-exported from this peer; equivalent to scitex-ml[seizure] / scitex_ml.metrics.seizure for users who only want this slice without the rest of scitex-ml).
Four Freedoms for Research
- The freedom to run your research anywhere — your machine, your terms.
- The freedom to study how every step works — from raw data to final manuscript.
- The freedom to redistribute your workflows, not just your papers.
- The freedom to modify any module and share improvements with the community.
AGPL-3.0 — because we believe research infrastructure deserves the same freedoms as the software it runs on.
Metadata
Release files for scitex-seizure-metrics 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| scitex_seizure_metrics-0.2.1.tar.gz | 2.7 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scitex_seizure_metrics-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 4.6 MB
Release files / scitex_seizure_metrics-0.2.1.tar.gz
| Download URL | scitex_seizure_metrics-0.2.1.tar.gz |
|---|---|
| Size | 2.7 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ae31e424cb88fd7d55e9d74b2531afd0a2fd27ffd976083eef0b6cb79c7cad75
|
|
BLAKE2b-256 checksum How to use checksums |
3ff4d4cdb23dc0bb5360aa313e434e52db938e71855d4317142fe4ee8e9e2f6d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|
Release files / scitex_seizure_metrics-0.2.1-py3-none-any.whl
| Download URL | scitex_seizure_metrics-0.2.1-py3-none-any.whl |
|---|---|
| Size | 2.0 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9b16c599c5ca9f755ed3be792503f1e69dbd9ad351e2fa96b939f4980859b8a0
|
|
BLAKE2b-256 checksum How to use checksums |
cabf43e9aa1c3fdf166739b9b0b1d1f834fbe265645dcc0f60a007a3f294a336
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|