Building Vigil, a radar fall-detection demo for senior living: on a fixed 360-event synthetic set the naive baseline matched 1.0 recall at 0.167 specificity.
Machine LearningHealthcare TechnologyAI Safety

The Naive Baseline in My Radar Fall Detector Hit the Same 1.0 Recall. It Also Fired Seven False Alarms in One Night.

Ashutosh SinghalAshutosh SinghalJuly 18, 202613 min read

On the synthetic night shift I built for Vigil, the off-the-shelf baseline fires nine alerts between 02:00 and 06:00 and seven of them are wrong. A ceiling fan, caught at a peak velocity of 5.0 m/s. A therapy dog, radar cross-section 0.27. A resident sitting down hard onto a seat at 2.92 m/s. Two of those nine alerts are real falls, and one of them is in a bathroom: a centroid trace running from 1.53 m standing down through 1.07, 0.84, 0.625 and 0.344 before settling at 0.119 m, floor level, breathing present, no recovery.

Every event on that shift is synthetic, labeled and physically grounded, generated from a fixed seed, and I wrote both detectors. Which is why I can say the uncomfortable part plainly. The baseline caught that bathroom fall too.

Vigil is the intelligence layer I built to sit between a radar feature stream and a nurse-call system in senior living. It returns ALERT, SUPPRESS or ROUTE TO HUMAN, with a reason attached to every one that a facility can file. The demo route is https://veriprajna.com/demos/smart-facility-fall-detection. I started the build assuming the hard part was seeing the fall. The benchmark disagreed with me on the first run.

Recall was the number I wanted to lead with

I ran the benchmark expecting fall sensitivity to be the headline, and it is a good number: 1.0 recall for the cascade over a fixed set of 360 labeled, noisy synthetic events. The column beside it is what changed the piece I thought I was writing. The naive baseline is two clauses of arithmetic, any fast or low motion is a fall (peak_v > 2.0 OR min_cz < 0.45), and on the same set it also scores 1.0 recall. Sensitivity is where fall-detection marketing lives, and both detectors are pinned at the top of it.

The separation sits entirely in the row nobody puts on a slide. Confounder specificity is 1.0 for the cascade and 0.167 for the baseline, a false-alarm rate of 0.833 per benign event. Project that at the 30 benign motion triggers per room per day the benchmark assumes and the baseline lands at 25.0 false alarms per room per day. The published incumbent range for off-the-shelf sensors is 5 to 15 false alarms per room per day, and alarm fatigue rather than sensor sensitivity is documented as the leading reason these deployments fail.

I should say this before a technical reader says it for me. The fusion weights in data/fall_model.json were fit by tools/fit_fall_classifier.py on the demo's own scenario generators, the same generators that produce the 360-event set. That is the strongest objection anyone can raise against my two 1.0s, and it is also why I care more about the 0.167 than about either of them. The baseline's failure is not an artifact of my training setup. It is what a threshold does when the world contains ceiling fans.

The seven false alarms decide whether anyone is still listening when the real one arrives.

The confounders were built to defeat a single feature

My first instinct was to make the classifier better, and it was the wrong instinct. I spent the early part of the build treating this as a discrimination problem: find the feature that separates a fall from a not-fall, weight it hard, move on. The scenario generators I had already written made that impossible on purpose.

Each confounder is generated to overlap a real fall on some individual feature. The hard sit at Cam 5 carries a velocity burst of 2.92 m/s, the magnitude of a fall, and settles at 0.46 m. Its bathroom cousin at Cam 10 peaks at 3.31 m/s and settles at 0.44 m. The therapy dog and the bend to pick up a towel both bring the centroid down, which is the other half of the baseline's rule. Any single test I could write was beaten by construction, which is why the baseline is genuinely fooled at 0.167 rather than fooled by a strawman I set up to lose.

Velocity was the feature I was most sure of, and it is the one that did not survive into the classifier. What survived is a logistic model over four features, floor proximity, impact energy, descent drop and a radar cross-section proxy, fused into a calibrated P(fall). No velocity term reaches P(fall) at all. There is still a stale line in the module docstring from when I thought it would. It is plain numpy, small enough that someone can open classifier.py and hold the whole thing in their head, which on a life-safety path is worth more to me than another point of AUC.

I put the Cam 10 suppression in front of people first, because the panel states the entire disagreement in a single line.

Vigil's Cam 10 Bathroom detail panel showing a SUPPRESS decision with the reason line: velocity burst but centroid settled at 0.44 m, seat height, not floor, no hard impact.
Cam 10 · Bathroom is the bathroom hard sit, peaking at 3.31 m/s. Vigil logs SUPPRESS with the deciding feature in the reason line: the centroid settled at 0.44 m, seat height, not floor, with no hard impact. That velocity alone satisfies the naive baseline's `peak_v > 2.0` clause.

Four conditions, one 8-second window

I wrote the temporal narrative verifier as the piece I would want to read as an outsider. temporal.py requires four conditions inside the same 8-second window, with standing established in its opening fifth: a median centroid above 1.2 m, a descent greater than 0.6 m together with a peak velocity above 1.8 m/s somewhere in the window, a sustained broadband impact whose 3-frame rolling mean exceeds 0.50, and the centroid actually reaching below 0.30 m. The sustained-impact test exists because a single-frame spike is cheap and a body hitting a floor is not.

The reason string the app emits reads "standing → descent → impact → floor", which is how a nurse reads an incident, but the implementation ANDs those conditions across the window rather than enforcing an order. It is not a state machine, and I would rather write that myself than have an engineer find it in the source and wonder what else the copy rounded off.

Only then does the gate add breathing confirmation above 0.20 and a fall confidence of at least 0.70. Those three values, floor level 0.30 m, breathing 0.20 and the 0.70 confidence floor, live in plain code outside every model. The direction is what matters: the deterministic conditions have to hold before the model's score is consulted at all, so a confidence number can never manufacture an alert by itself. A lower P(fall) can still turn an ALERT into a SUPPRESS, which is the right asymmetry for a layer that is allowed to stay silent and not allowed to invent. One more determinative threshold sits outside that documented block, a hardcoded p_fall >= 0.40 in gate.py that can send a multi-occupancy event to a human check on the model's score alone. I name it because "three documented thresholds" is otherwise doing more work than it earns.

The suppressions are the record a state survey actually asks for

I built the Decision Ledger before I built anything that looks like a product, because the question I could not answer was never "did you catch it". It was "why was no alert raised in Room 203 at 2:13", and the answer has to already be in a written record by the time anyone asks. Ten of the twelve events on the shift are suppressions, and each carries the feature value logged against it: the Cam 6 non-human target at radar cross-section 0.27 against a 0.55 human minimum, the Cam 7 and Cam 12 bends where the centroid stops at 0.60 m and 0.59 m with impact energy of 0.07 against a 0.50 threshold.

Vigil's floor view and Decision Ledger, listing SUPPRESS rows for Cam 12 down to Cam 6, each with its reason text.
The Decision Ledger after the shift, with the active incident on Cam 3 · Bathroom at 99% confidence. Every suppressed row carries its deciding reason: Cam 10's 0.44 m seat-height settle, Cam 6's radar cross-section of 0.27 below the human minimum, Cam 7 and Cam 12's downward motion that returned to standing.
Those ten suppressed rows are what a surveyor asks about, because they are the events where nothing happened and someone still has to explain why.

Only part of the per-room calibration is actually wired into a decision. Cam 1's ceiling fan is suppressed because Room 214's clutter map carries a fixed-location Doppler entry at (1.5, 1.5, 2.45 m) and check_clutter masks it at that voxel. That path is real. The per-room seat and bed heights in rooms.json, 0.42 m in Room 118 Bathroom and 0.45 m in Room 203, and the grab-bar entries beside them, are calibration data that no V1 code path reads; the seat band I use to label a hard sit is one global 0.38 to 0.60 m test. There is even a long_lie_sec: 180.0 key in that file that nothing consumes. Per-room calibration is the integration work a real deployment pays for, and in this build only the Doppler masks are connected to a decision.

The export is a shift audit JSON covering every alert, route and suppression with its deciding feature values and policy reason, which is what a CMS F689 or QAPI binder wants. The clinical incident note is composed separately from that structured evidence and shown in the incident panel, not inside the JSON.

The alert I let it raise, and the fall I would not let it assert

I put the climax of the shift in a bathroom deliberately. It is the highest-risk room and the one place a camera is not a usable option: nineteen US states have enacted laws governing cameras in nursing-home rooms, generally permitting them in a resident's room with consent, while bathrooms stay excluded in practice on privacy grounds. Radar features carry no image, which is precisely why they can go where a camera cannot.

Cam 3 is that event, and Vigil returns ALERT, category long_lie, confidence 0.99, floor time 4.8 s, with the escalation ladder armed at CNA now, Charge Nurse at 90 s and DON at 180 s. The nurse-call badge text reads "Room 118B Bathroom: Fall Detected, 99% confidence. Resident on floor 5s. Breathing confirmed." Dispatch emits both a legacy Rauland dry-contact signal and an Ascom/Austco MQTT/REST payload, and both go through a logged adapter stub. No nurse-call hardware is attached to any of this. The reason it is worth building anyway is the long lie: half of elderly people who lie on the floor for over an hour die within six months.

Vigil's Cam 3 Bathroom incident detail: the Room 118B nurse-call badge, the escalation ladder, the payload at confidence 0.99, and the clinical incident note.
The Cam 3 · Bathroom alert opened out (Room 118B in the badge, payload and note). The dispatch payload records confidence 0.99, floor_time_sec 4.8, breathing true and long_lie_risk true, with the CNA, Charge Nurse and DON ladder beside it and the incident note composed from that structured evidence.

One number on that panel I refuse to sell. The 7.0 seconds impact to alert is computed in gate.py as the hold timer plus three seconds, a constant. The app displays it, and I will quote the display, but it is arithmetic rather than a measured system speed, and calling it benchmarked latency would be the sort of small untruth that costs you the large true things sitting next to it.

The event I am prouder of is the one Vigil declines. Cam 2 is a real fall in the ground truth and P(fall) reaches 0.99, and Vigil still does not assert it: the room holds two targets, single-person tracking is outside V1 coverage, and the gate returns ROUTE TO HUMAN at low confidence. The naive baseline auto-fires and collects credit for a catch it did not earn. Two real falls happened on that shift. Vigil alerted on one and sent the other to a staff check, and I will not describe that as catching every fall, because it isn't. Across the benchmark, 40 of 40 multi-occupancy falls route to a human, with no over-alerts and none missed.

What the scoreboard is allowed to claim

The caveat line under the Shift Results modal is the one part of that panel I drafted first. The modal reports 0 false alarms for the engine against 7 for the incumbent on this shift, 1 of 2 real falls caught, 1 routed to a human, 100% confounder specificity over 360 labeled events, and 0.0 against 25 projected false alarms per room per day.

Vigil's Shift Results modal for the 02:00 to 06:00 night shift: 0.0 versus 25 false alarms per room per day, 1 of 2 real falls caught, 1 routed to human, 100% confounder specificity over 360 labeled events.
The Shift Results scoreboard. The engine's 0.0 false alarms per room per day sits against the incumbent's 25, with 1 of 2 real falls alerted and 1 routed to a human check. The footer states the scope: 360 labeled noisy events, a synthetic radar feature stream, no live sensors, no PHI, no camera.

Those figures describe a fixed synthetic golden set and nothing else. Not production accuracy, not a clinical result, not a validated medical claim, and never a guarantee to a facility. A real pilot targets under 2 false alarms per room per day after shadow-mode calibration, and that is the number I would put in front of a Director of Nursing, because it is the one I could be held to. The 0.0 is evidence that the mechanism separates falls from confounders on a set I can hand you; it is not a promise about a building I have never walked through.

The standard I now hold a life-safety alert to

I came out of this build with a much narrower definition of what fall detection has to be good at. Detection is a threshold, and a threshold already scores 1.0 recall on my own test set. The work that earns a nurse's attention is the refusal: the clutter map that knows which voxel the fan occupies, the impact test that will not accept a single frame, the floor-reach condition that separates a hard sit from a fall, and a gate written so that a state surveyor rather than a model can read why the system did what it did.

The full walkthrough lives at https://veriprajna.com/demos/smart-facility-fall-detection, and the ten suppressed rows in the ledger are where the shift is actually decided.

And if you would rather watch the shift than read me describe it, here is the whole night running end to end.

A system that alarms on the ceiling fan gets muted inside a week, and a muted system detects nothing at all. The most sophisticated behavior I could give this layer was the ability to decline, on the record, with the deciding number attached. On Cam 2 that number was 0.99, and the right call was still to hand the event to a person.

Related Research

Also Published On

Build Your AI with Confidence.

Partner with a team that has deep experience in building the next generation of enterprise AI. Let us help you design, build, and deploy an AI strategy you can trust.

Veriprajna Deep Tech Consultancy specializes in building safety-critical AI systems for healthcare, finance, and regulatory domains. Our architectures are validated against established protocols with comprehensive compliance documentation.