Detecting a seizure: from scalp EEG to a Raspberry Pi monitor

This is the first article in the precision-medicine track, and it is a project rather than a lesson: take the tools from lesson 6 and lesson 7, point them at a clinical problem, and carry the result all the way to a device. The problem is seizure detection from scalp EEG. The data are real: seven seizures from one child in the CHB-MIT database. The detector is five features and a logistic regression, and it catches all seven with a five-second delay. The second half of the article turns it into a service that runs on a Raspberry Pi with an open-source EEG board, with the diagrams to build it. Throughout, it is specific about what this can and cannot detect, because that distinction is the whole of the difference between a project and a medical device.

Which seizures, exactly

“Seizure” covers very different events, and a detector built from scalp EEG can only find the ones that change the scalp EEG. The International League Against Epilepsy classifies seizures by where they start (focal or generalised), by awareness, and by whether there is movement. For a detector the more useful split is electrographic: does the seizure produce a rhythm at the scalp that is large, sustained and different from the background? The seizures in this article do. All seven start in the right hemisphere, under the right temporal chain of electrodes, as a rhythm that grows over seconds and spreads; they last 27 to 101 seconds; and they are what the CHB-MIT documentation describes as intractable (drug-resistant) seizures in a child being evaluated for surgery. In the ILAE language they are focal-onset seizures with a clear scalp EEG correlate, several of which spread to both hemispheres. Here is the honest scope of what the methods on this site can and cannot do with them:

EventWhat the scalp EEG showsThis detector
Focal seizure with a scalp signature (the seven here: right-hemisphere onset, 27 to 101 s)Rhythmic activity that starts in one region, grows, spreads and slows; large and sustainedYes. This is what it was built and tested on
Focal to bilateral tonic-clonic (a “grand mal” that starts focally)Same start, then very large, then muscle artefact swamps everythingYes, by the onset; the later part is mostly muscle, which also trips the features
Absence seizure (typical, childhood)3-per-second spike-and-wave, generalised, abrupt, 5 to 20 sProbably: large rhythmic delta/theta is exactly what the weights reward, but it is untested here; see the exercises
Focal aware seizure with little or no scalp changeOften nothing visible; the source is small or deepNo. Nothing to detect at the scalp
Myoclonic jerkA single brief burst, under a secondNo. Shorter than the window and the persistence rule
Non-epileptic event (fainting, a psychogenic seizure)No ictal rhythm; often movement artefactCannot tell; may false-alarm on the artefact
Which type a seizure isNeeds the clinical picture and usually videoNo. Detection is not diagnosis

Two more boundaries. Everything here is detection: raising an alarm while the seizure is happening. Prediction, raising it minutes before, is a different problem that decades of work have not solved reliably, and anyone selling it to you should be asked for their false-alarm rate. And the detector is patient-specific: trained on this child’s seizures and tested on her other seizures. A detector that works across patients it has never seen is a harder problem, and the state of the art there is still a human reading the trace.

The data

The CHB-MIT Scalp EEG Database was collected at Boston Children’s Hospital: 22 children with drug-resistant epilepsy, monitored for days while their medication was reduced to provoke seizures ahead of possible surgery, 23 channels at 256 Hz, with every seizure marked by a clinician. It is the dataset most seizure-detection papers of the last fifteen years were tested on. Patient chb01 is an eleven-year-old girl with seven seizures over the 40 hours of her recording. The channels are bipolar: each is the difference between two neighbouring electrodes, which cancels what they share, the same idea as lesson 7’s Laplacian. The file holds two chains of four, running front to back on the left (FP1-F7 to P7-O1) and on the right (FP2-F8 to P8-O2).

Step 1: look at one

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import butter, filtfilt

dat = np.load("chbmit_chb01.zip")
eeg = dat["eeg"].astype(float)                 # 8 channels x samples, microvolts
fs = float(dat["fs"])                          # 128 samples per second
names = list(dat["channels"])                  # bipolar pairs: each channel is the difference between two electrodes
sz_start, sz_end = dat["seizure_start"], dat["seizure_end"]      # sample indices of the seven seizures
seg_start = dat["segment_start"]               # where each recording segment begins in the array
print(eeg.shape, "=", eeg.shape[1] / fs / 60, "minutes;", len(sz_start), "seizures lasting", ((sz_end - sz_start) / fs).astype(int), "seconds")
print("channels:", names)
# (8, 494336) = 64.36666666666666 minutes; 7 seizures lasting [ 40  27  40  51  90  93 101] seconds
# channels: ['FP1-F7', 'F7-T7', 'T7-P7', 'P7-O1', 'FP2-F8', 'F8-T8', 'T8-P8', 'P8-O2']

k = 0                                          # the first seizure
t0 = sz_start[k] / fs
window = slice(int((t0 - 30) * fs), int((t0 + 60) * fs))
t = np.arange(eeg.shape[1]) / fs - t0
fig, ax = plt.subplots(figsize=(11, 6))
for i in range(8):
    ax.plot(t[window], eeg[i, window] - i * 400, lw=0.5)
ax.axvline(0, color="orange"); ax.axvline((sz_end[k] - sz_start[k]) / fs, color="orange", ls="--")
ax.set(yticks=-np.arange(8) * 400, yticklabels=names, xlabel="time from seizure onset (s)")
plt.show()
Seizure 1 of 7, on all eight channels. Thirty seconds of ordinary background, then at time zero a rhythm begins on the right-hand chain (blue), grows over ten seconds, spreads to the left chain, and stops forty seconds later, leaving a slower, flatter trace. The spectrogram of F8-T8 below shows the same thing as lesson 6 would: a burst of power that starts across many frequencies and slows as the seizure evolves. The horizontal line at 16 Hz is an equipment artefact present throughout.
Seizure 1 of 7, on all eight channels. Thirty seconds of ordinary background, then at time zero a rhythm begins on the right-hand chain (blue), grows over ten seconds, spreads to the left chain, and stops forty seconds later, leaving a slower, flatter trace. The spectrogram of F8-T8 below shows the same thing as lesson 6 would: a burst of power that starts across many frequencies and slows as the seizure evolves. The horizontal line at 16 Hz is an equipment artefact present throughout.

Everything a detector needs is visible by eye. The seizure is larger than the background, it is rhythmic, it evolves (fast at first, slowing toward the end, which is the signature that distinguishes a seizure from an artefact of fixed frequency), and it lasts. The features below are each a way of putting a number on one of those.

Step 2: five numbers per second

Cut the recording into two-second windows, one per second, as in lesson 7. For each window compute the power in four bands, delta through beta, the way lesson 6 did, and one more feature: line length, the average absolute difference between consecutive samples, which is large when the trace is both big and fast and is the single most used feature in the seizure-detection literature because it is cheap and it works. Take each feature on every channel and keep the loudest channel, so that a seizure confined to one side is not diluted by the quiet side. Five numbers per window. Label a window as seizure if its centre falls inside one of the clinician’s marked intervals.

def features(window_data, fs):
    """One row of numbers describing a 2-second window of multichannel EEG."""
    out = []
    for lo, hi in [(1, 4), (4, 8), (8, 13), (13, 30)]:                        # delta, theta, alpha, beta
        b, a = butter(3, [lo / (fs / 2), hi / (fs / 2)], "band")
        out.append(np.log(filtfilt(b, a, window_data, axis=1).var(axis=1) + 1e-6).max())   # band power, loudest channel
    out.append(np.log(np.abs(np.diff(window_data, axis=1)).mean(axis=1)).max())           # line length: how wiggly the trace is
    return np.array(out)

win, step = int(2 * fs), int(1 * fs)
starts = np.arange(0, eeg.shape[1] - win, step)                    # one window per second
X = np.array([features(eeg[:, s:s + win], fs) for s in starts])   # windows x 5 features
label = np.zeros(len(starts), dtype=int)
for s, e in zip(sz_start, sz_end):
    label[(starts + win / 2 >= s) & (starts + win / 2 <= e)] = 1  # a window is "seizure" if its centre is inside one
print(X.shape, "windows x features;", label.sum(), "seizure windows,", (label == 0).sum(), "normal windows")
# (3860, 5) windows x features; 449 seizure windows, 3411 normal windows
What the detector sees through seizure 1, one window per second. Top: delta and theta power on the loudest channel, which rise ten to fifty fold at onset. Middle: line length, which triples. Bottom: the classifier's score, with the threshold dashed and the alarm (red) seven seconds after onset. The two line-length spikes after the seizure are movement artefacts, and the score ignores them because their band powers do not match.
What the detector sees through seizure 1, one window per second. Top: delta and theta power on the loudest channel, which rise ten to fifty fold at onset. Middle: line length, which triples. Bottom: the classifier’s score, with the threshold dashed and the alarm (red) seven seconds after onset. The two line-length spikes after the seizure are movement artefacts, and the score ignores them because their band powers do not match.

Step 3: the classifier, tested honestly

The classifier is lesson 3‘s logistic regression on the five features, with a small penalty on the weights. The important part is how it is tested. The 449 seizure windows and 3,411 normal windows are not independent: windows from the same seizure are near-copies of each other, and a random split would put copies of every test seizure in the training set and report an accuracy that means nothing. So the split is by seizure: for each of the seven, train on the other six segments and score this one. Every score below was produced by a model that had never seen the seizure it was scoring. The seizure-free quarter hour is scored by a model trained on all seven.

def train_decoder(X, y, lr=0.1, steps=3000):
    w, b = np.zeros(X.shape[1]), 0.0
    for _ in range(steps):
        p = 1 / (1 + np.exp(-(X @ w + b)))
        w -= lr * (X.T @ (p - y) / len(y) + 0.01 * w)
        b -= lr * (p - y).mean()
    return w, b

def score(X, w, b):
    return 1 / (1 + np.exp(-(X @ w + b)))

segment_of = np.searchsorted(seg_start, starts, side="right") - 1    # which recording segment each window came from
scores = np.zeros(len(starts))
for seg in range(len(sz_start)):                                      # leave one seizure's segment out, test on it
    train = segment_of != seg
    mean, sd = X[train].mean(axis=0), X[train].std(axis=0)
    w, b = train_decoder((X[train] - mean) / sd, label[train])
    scores[segment_of == seg] = score((X[segment_of == seg] - mean) / sd, w, b)
mean, sd = X[segment_of < 7].mean(axis=0), X[segment_of < 7].std(axis=0)   # the seizure-free hour: scored by a model trained on all seven
w, b = train_decoder((X[segment_of < 7] - mean) / sd, label[segment_of < 7])
scores[segment_of == 7] = score((X[segment_of == 7] - mean) / sd, w, b)
print("weights (delta, theta, alpha, beta, line length):", w.round(2))
# weights (delta, theta, alpha, beta, line length): [1.21 1.38 0.49 0.4  0.42]

The weights are readable. Delta and theta power carry the most, line length and the faster bands less. That matches the figure: this patient’s seizures are slow rhythms, 2 to 8 Hz, that get large.

Step 4: from a score to an alarm

A score above 0.5 on a single window is not an alarm. Backgrounds have one-second bumps all the time, and a device that buzzed on each of them would be switched off within a day. Real detectors add two pieces of logic that have nothing to do with machine learning. Persistence: the score has to stay above threshold for several windows in a row. Refractory period: after an alarm, stay quiet for a while, so that a ninety-second seizure produces one alarm and not thirty. Both trade latency and sensitivity against false alarms, and the numbers that matter clinically are exactly those three: how many seizures were caught, how long after onset, and how many false alarms per hour.

The decision logic on a made-up score trace. A single window over the threshold is ignored. The third consecutive window fires the alarm. For the next thirty seconds the detector stays silent.
The decision logic on a made-up score trace. A single window over the threshold is ignored. The third consecutive window fires the alarm. For the next thirty seconds the detector stays silent.
def alarms(scores, threshold=0.5, persistence=3, refractory=30):
    """Fire when the score has been above threshold for `persistence` windows in a row; then stay quiet for `refractory` windows."""
    fired, streak, quiet_until = [], 0, -1
    for i, s in enumerate(scores):
        streak = streak + 1 if s > threshold else 0
        if streak >= persistence and i > quiet_until:
            fired.append(i); quiet_until = i + refractory
    return np.array(fired)

def evaluate(fired):
    detected, latency = 0, []
    for s, e in zip(sz_start, sz_end):
        hits = [f for f in fired if s <= starts[f] + win / 2 <= e + 10 * fs]      # an alarm during the seizure (or within 10 s after)
        if hits:
            detected += 1; latency.append((starts[hits[0]] + win / 2 - s) / fs)
    false = [f for f in fired if label[max(0, f - 5):f + 6].sum() == 0]           # alarms with no seizure within 5 s
    hours_normal = (label == 0).sum() / 3600
    return detected, np.mean(latency) if latency else np.nan, len(false) / hours_normal

for persistence in [1, 3, 5]:
    d, lat, fa = evaluate(alarms(scores, persistence=persistence))
    print(f"persistence {persistence} windows: {d} of 7 seizures detected, mean latency {lat:4.1f} s, {fa:4.1f} false alarms per hour")
# persistence 1 windows: 7 of 7 seizures detected, mean latency  6.9 s, 23.2 false alarms per hour
# persistence 3 windows: 7 of 7 seizures detected, mean latency  5.1 s,  3.2 false alarms per hour
# persistence 5 windows: 7 of 7 seizures detected, mean latency  9.9 s,  1.1 false alarms per hour
All 64 minutes of the file: the classifier score, the clinician's seizure intervals in orange, alarms as triangles, false alarms in red. The seven seizures are each caught within seconds. The three false alarms all fall in the last quarter hour, the recording from a different hour of the day that no training segment resembled.
All 64 minutes of the file: the classifier score, the clinician’s seizure intervals in orange, alarms as triangles, false alarms in red. The seven seizures are each caught within seconds. The three false alarms all fall in the last quarter hour, the recording from a different hour of the day that no training segment resembled.

All seven seizures caught at every setting. With persistence 3, the alarm comes 5 seconds after onset on average and there are 3.2 false alarms per hour; with persistence 5, latency doubles and false alarms drop to about one an hour. That is the trade-off every detector lives on, and where to sit on it is a question for the person wearing it, not the engineer. The odd result, that persistence 1 has longer latency than persistence 3, is the refractory period at work: a single-window false alarm just before a seizure silences the detector for thirty seconds and delays the real alarm.

Look at where the false alarms are. All three fall in the fifteen minutes from a different hour, a recording whose background the model had never seen. Within the segments around seizures, the background was learned and the false-alarm rate is zero; one hour later, the same patient, awake or asleep or moving differently, and the rate is twelve an hour. This is the central practical problem of seizure detection and of clinical machine learning generally: the data you test on must come from a different time than the data you trained on, or the number you report is fiction. It is lesson 3’s train-test rule again, with time as the thing to hold out.

Step 5: save the model

Everything the device needs is five weights, a bias, the standardisation constants, and the three decision parameters. Write them to a file.

import json
model = {"fs": fs, "weights": w.tolist(), "bias": float(b), "mean": mean.tolist(), "sd": sd.tolist(),
         "threshold": 0.5, "persistence": 3, "refractory": 30}
json.dump(model, open("model.json", "w"), indent=1)
print("saved model.json:", {k: (np.round(v, 2).tolist() if isinstance(v, list) else v) for k, v in model.items()})
# saved model.json

Building the device

A detector that runs on a laptop against a file is an analysis. A detector that runs on a small computer against electrodes on a head, unattended, for days, is a system, and most of what makes it one is not the classifier. This section describes a build from open parts and gives the code. The pipeline is the one you have just run; what changes is that the data arrive one sample at a time and never stop.

The detector as a loop. Everything up to the classifier is lessons 6 and 7, the classifier is lesson 3, and the decision logic is step 4. On a device this loop runs once a second, forever.
The detector as a loop. Everything up to the classifier is lessons 6 and 7, the classifier is lesson 3, and the decision logic is step 4. On a device this loop runs once a second, forever.

Hardware

  • Electrodes. Eight gold-cup electrodes with conductive paste, placed by the 10-20 system at the positions the bipolar chains need (FP1, F7, T7, P7, O1 and the right-side equivalents), or a dry-electrode headband for a less fussy and noisier setup. Paste electrodes on a child for days is how the hospital did it, and it is a skill.
  • Amplifier. The signal is microvolts; the board that turns it into numbers is the one part that is hard to improvise. Two open-hardware options use the same Texas Instruments ADS1299 chip that research amplifiers use: the OpenBCI Cyton, an 8-channel board that streams at 250 Hz to a USB dongle and is supported by the BrainFlow library, and the PiEEG, an 8-channel shield that plugs directly onto a Raspberry Pi and talks over SPI. Both are open-source designs with published schematics.
  • Computer. A Raspberry Pi 4 or 5. The detector loop needs a few milliseconds a second; the Pi spends the rest idle. Power it from a battery bank if the wearer moves.
  • Outputs. A buzzer on a GPIO pin for the room, and a push notification for a phone elsewhere; the free ntfy service does the latter with one HTTP request and no account.
What talks to what. The amplifier board turns microvolts into numbers and hands them to the Pi over USB or SPI; one process fills a ring buffer, the detector reads the last two seconds from it once a second, and alarms go to a buzzer, a phone and a log. A systemd unit keeps it running.
What talks to what. The amplifier board turns microvolts into numbers and hands them to the Pi over USB or SPI; one process fills a ring buffer, the detector reads the last two seconds from it once a second, and alarms go to a buzzer, a phone and a log. A systemd unit keeps it running.

The service

One file. It has two sources that look identical to the rest of the program: a FileSource that replays a recording at real speed (or faster) and a BrainFlowSource that reads a Cyton. The Detector holds the last two seconds in a ring buffer, computes the same five features as step 2 on them, applies the saved weights, and runs the persistence and refractory logic. The main loop steps the detector once per second of data, not of wall-clock time, which is the kind of detail that only matters when you run it for real: a laggy USB read must not turn into alarms with the wrong timestamp.

"""monitor.py: a seizure detector as a long-running service.

Run on a laptop with a recording:   python monitor.py --source file --file chbmit_chb01.zip
Run on a Raspberry Pi with a board:  python monitor.py --source brainflow --port /dev/ttyUSB0
"""
import argparse, json, time, logging, urllib.request
import numpy as np
from collections import deque
from scipy.signal import butter, filtfilt

log = logging.getLogger("monitor")


# ---------- sources: anything with .read() that returns the newest samples as channels x n ----------
class FileSource:
    """Replays a recording at real speed, so the rest of the program cannot tell it from a live board."""
    def __init__(self, path, fs):
        d = np.load(path); self.eeg = d["eeg"].astype(float); self.fs = fs; self.pos = 0; self.t = time.time()
    def read(self):
        due = int((time.time() - self.t) * self.fs) - self.pos              # how many samples have "arrived" since last call
        chunk = self.eeg[:, self.pos:self.pos + due]; self.pos += chunk.shape[1]
        return chunk


class BrainFlowSource:
    """An OpenBCI Cyton (or any BrainFlow board) over USB. pip install brainflow."""
    def __init__(self, port, channels=8):
        from brainflow.board_shim import BoardShim, BrainFlowInputParams, BoardIds
        params = BrainFlowInputParams(); params.serial_port = port
        self.board = BoardShim(BoardIds.CYTON_BOARD, params)
        self.rows = BoardShim.get_eeg_channels(BoardIds.CYTON_BOARD)[:channels]
        self.fs = BoardShim.get_sampling_rate(BoardIds.CYTON_BOARD)         # 250
        self.board.prepare_session(); self.board.start_stream()
    def read(self):
        return self.board.get_board_data()[self.rows]                         # everything since the last call, in uV


# ---------- the detector: the same five features and the weights trained in the article ----------
class Detector:
    def __init__(self, model, fs, threshold=0.5, persistence=3, refractory=30):
        self.w, self.b = np.array(model["weights"]), model["bias"]
        self.mean, self.sd = np.array(model["mean"]), np.array(model["sd"])
        self.fs = fs; self.buffer = deque(maxlen=int(2 * fs))                 # the last two seconds, per sample
        self.threshold, self.persistence, self.refractory = threshold, persistence, refractory
        self.streak, self.quiet_until = 0, 0

    def features(self, window):
        out = []
        for lo, hi in [(1, 4), (4, 8), (8, 13), (13, 30)]:
            b, a = butter(3, [lo / (self.fs / 2), hi / (self.fs / 2)], "band")
            out.append(np.log(filtfilt(b, a, window, axis=1).var(axis=1) + 1e-6).max())
        out.append(np.log(np.abs(np.diff(window, axis=1)).mean(axis=1)).max())
        return (np.array(out) - self.mean) / self.sd

    def step(self, now):
        """Called once a second. Returns the score, and True if an alarm should fire."""
        window = np.array(self.buffer).T                                      # channels x samples
        if window.shape[1] < self.buffer.maxlen:
            return None, False
        s = 1 / (1 + np.exp(-(self.features(window) @ self.w + self.b)))
        self.streak = self.streak + 1 if s > self.threshold else 0
        fire = self.streak >= self.persistence and now > self.quiet_until
        if fire:
            self.quiet_until = now + self.refractory
        return s, fire


def notify(topic, text):
    """Push to a phone with ntfy.sh (free, no account): subscribe to the topic in the ntfy app."""
    try:
        urllib.request.urlopen(urllib.request.Request(f"https://ntfy.sh/{topic}", data=text.encode(), headers={"Priority": "high"}), timeout=5)
    except Exception as e:
        log.warning("notification failed: %s", e)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--source", choices=["file", "brainflow"], default="file")
    ap.add_argument("--file", default="chbmit_chb01.zip"); ap.add_argument("--port", default="/dev/ttyUSB0")
    ap.add_argument("--model", default="model.json"); ap.add_argument("--topic", default=None)
    ap.add_argument("--speed", type=float, default=1.0, help="replay faster than real time (file source only)")
    args = ap.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")

    model = json.load(open(args.model))
    source = FileSource(args.file, model["fs"] * args.speed) if args.source == "file" else BrainFlowSource(args.port)
    det = Detector(model, model["fs"], model["threshold"], model["persistence"], model["refractory"])
    log.info("running; source=%s", args.source)
    fs = model["fs"]
    n_samples = 0                                                             # count data, not wall-clock time
    while True:
        chunk = source.read()
        for col in chunk.T:
            det.buffer.append(col)
            n_samples += 1
            if n_samples % int(fs) == 0:                                      # another second of data has arrived
                now = n_samples / fs
                s, fire = det.step(now)
                if fire:
                    log.warning("ALARM at %d s (score %.2f)", now, s)
                    if args.topic: notify(args.topic, f"Possible seizure at {time.strftime('%H:%M:%S')}")
        if args.source == "file" and source.pos >= source.eeg.shape[1]:
            log.info("end of recording"); break
        time.sleep(0.05)


if __name__ == "__main__":
    main()

Try it against the article’s file, at fifty times real speed:

pip install numpy scipy
python monitor.py --source file --file chbmit_chb01.zip --speed 50
# 15:06:49 WARNING ALARM at 247 s (score 0.96)
# 15:06:51 WARNING ALARM at 644 s (score 0.97)
# 15:06:53 WARNING ALARM at 1032 s (score 0.77)
# ... one alarm per seizure within seconds of onset, three false alarms in the last quarter hour

The replay produces the same alarms as step 4, which is the test that the streaming code is equivalent to the offline analysis. That equivalence is worth checking every time you change either, because the two versions drift apart in practice: the offline version sees the whole recording and filters it in one pass; the online one sees two seconds and must get the same answer from them.

Packaging it

On the Pi, the service needs to start when the power comes on, restart if it crashes, and leave a log. That is what systemd is for, and the whole configuration is this file, installed with sudo cp seizure-monitor.service /etc/systemd/system/ && sudo systemctl enable --now seizure-monitor. The log is journalctl -u seizure-monitor -f.

[Unit]
Description=Seizure monitor (research build, not a medical device)
After=network-online.target

[Service]
User=pi
WorkingDirectory=/home/pi/seizure-monitor
ExecStart=/home/pi/seizure-monitor/venv/bin/python monitor.py --source brainflow --port /dev/ttyUSB0 --topic my-secret-topic-name
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

The model file it loads is the one step 5 wrote, so retraining on new seizures is a matter of replacing one small file:

{
 "fs": 128.0,
 "weights": [
  1.2117927709917629,
  1.3755688993410002,
  0.4899623463951435,
  0.40485118358151967,
  0.42444341307141376
 ],
 "bias": -4.152963406295196,
 "mean": [
  7.391718334533521,
  5.870881518587343,
  4.750163443873513,
  3.9429359195743587,
  2.5285384028505717
 ],
 "sd": [
  1.2656932215114118,
  0.9868093864383984,
  0.9293513440481552,
  1.1640161259724342,
  0.5585923924815547
 ],
 "threshold": 0.5,
 "persistence": 3,
 "refractory": 30
}

Three things the production version of this would add, in order of importance. An impedance check at startup, because an electrode that has come loose produces huge slow artefacts that look exactly like what the delta weight rewards; the ADS1299 can measure electrode impedance and both boards expose it. Artefact rejection before the classifier: the two line-length spikes after seizure 1 in the features figure are movements, and a blink detector from the ICA primer or a simple amplitude ceiling would catch most of them. Storage of every alarm’s EEG, so that a human can later decide which alarms were real, which is how the threshold and the model get better over time, and how you would ever know the device’s true false-alarm rate on its wearer.

What you built, and what it is worth

  • A seizure detector from scalp EEG: five features, a logistic regression, and two rules, that catches all seven of one patient’s focal seizures within about five seconds at three false alarms an hour.
  • An honest evaluation: tested by leaving each seizure out, with the metrics clinicians use, and with the false alarms traced to the recording the model had not seen the background of.
  • A clear statement of scope: focal seizures with a scalp signature, yes; silent focal seizures and brief jerks, no; seizure type, never.
  • A streaming service that gives the same answers as the analysis, packaged to run unattended on a Raspberry Pi with an open-hardware amplifier.

The gap between this and a product is not the classifier; published clinical detectors use features much like these. It is the thousand hours of recordings across many patients needed to know the false-alarm rate, the artefact handling, the electrodes that stay on for a week, the battery, the failure modes when a wire breaks at 3 a.m., and the regulatory evidence that the whole thing does more good than harm. That is years of work by a team, and it is why those products cost what they do. What you have is the understanding of how they work, which is the part that cannot be bought.

Exercises

  1. Move the threshold from 0.5 to 0.3 and to 0.7 and redo step 4. Plot sensitivity and false alarms per hour against the threshold: this curve is how detectors are compared in papers.
  2. Download another CHB-MIT patient (chb03 and chb05 are good) and run steps 1 to 4 on them with the same code. Then test chb01’s model on them without retraining. How well does one child’s detector transfer to another?
  3. Add a sixth feature: the ratio of power on the right chain to the left chain. For this patient every seizure starts on the right. Does the detector get faster? What would happen on a patient whose seizures start on the left?
  4. Harder: the false alarms live in the seizure-free quarter hour. Download the full chb01_01.edf (42 MB) and chb01_02.edf from PhysioNet, add two hours of seizure-free background to the training set, and measure the false-alarm rate again on a third hour. This is what fixing a detector actually looks like.

Data: CHB-MIT Scalp EEG Database (Shoeb 2009; Goldberger et al. 2000) via PhysioNet, patient chb01, Open Data Commons Attribution licence. Line length as a seizure feature: Esteller et al. 2001. The analysis runs in about fifteen seconds; the monitor replay at 50x takes a minute and a half.