3. 可穿戴设备睡眠阶段自动识别
睡眠分期的可穿戴信号基础
睡眠阶段自动识别不能只靠“睡了多久”,而要把两类信号放在同一时间轴上联合判断:
- 心率变异性(HRV):由 PPG 逐拍间期(IBI)序列计算。深睡时副交感占优,RMSSD 升高、心率下降;REM 时交感活动增强,心率与 HRV 波动加剧。
- 体动(Actigraphy):三轴加速度的合矢量变化。清醒时体动频繁,深睡时体动极少,REM 时出现零星小体动。
本地识别模型的目标是:以 30 秒为一个 epoch,输出 {清醒, 浅睡, 深睡, REM} 四类标签。下面这张图给出从原始信号到分期输出的完整信号链与特征映射关系。
特征工程与模型选择
每个 30 秒 epoch 提取 7 维特征,送入轻量级分类器。特征定义如下:
| 特征 | 来源 | 计算方式 | 典型区分作用 |
|---|---|---|---|
| RMSSD | IBI 序列 | 相邻 IBI 差值的均方根 | 深睡高、清醒低 |
| SDNN | IBI 序列 | IBI 标准差 | REM 波动大 |
| meanHR | IBI 序列 | 60 / 平均 IBI | 深睡最低 |
| LF/HF | IBI 频域 | 0.04–0.15 Hz 与 0.15–0.4 Hz 功率比 | REM 升高 |
| activityCount | 加速度 | 合矢量带通后绝对值积分 | 清醒最高 |
| zeroCrossRate | 加速度 | 合矢量过零次数 / 窗口长度 | 区分体动类型 |
| motionRatio | 加速度 | 超过阈值的采样点占比 | 深睡接近 0 |
模型选用随机森林(n_estimators=30, max_depth=6),在 MCU 上以 C 数组形式固化。训练在 PC 端完成,部署时只保留推理逻辑。
本地推理实现
以下代码展示从特征向量到四类标签的完整推理路径,可直接嵌入可穿戴固件:
#include <stdint.h>
#include <math.h>
#define N_FEATURES 7
#define N_TREES 30
#define N_CLASSES 4
/* 类别索引:0=清醒 1=浅睡 2=深睡 3=REM */
static const char* LABEL_NAMES[N_CLASSES] = {"Wake", "Light", "Deep", "REM"};
/* 单棵决策树节点:feature=-1 表示叶节点 */
typedef struct {
int8_t feature; /* 特征索引,-1 为叶 */
float threshold; /* 分裂阈值 */
int16_t left; /* 左子节点索引 */
int16_t right; /* 右子节点索引 */
uint8_t votes[N_CLASSES]; /* 叶节点类别投票 */
} TreeNode;
/* 示例:一棵深度 3 的树(实际部署时由训练脚本导出全部 30 棵) */
static const TreeNode tree0[] = {
/* 0 */ {4, 0.35f, 1, 2, {0,0,0,0}}, /* activityCount < 0.35 ? */
/* 1 */ {0, 0.55f, 3, 4, {0,0,0,0}}, /* RMSSD < 0.55 ? */
/* 2 */ {0, 0.0f, -1, -1, {1,0,0,0}}, /* 叶:清醒 */
/* 3 */ {2, 0.62f, -1, -1, {0,1,0,0}}, /* 叶:浅睡 */
/* 4 */ {3, 0.48f, -1, -1, {0,0,1,0}}, /* 叶:深睡 */
};
/* 特征归一化到 [0,1] */
static void normalize_features(const float raw[N_FEATURES], float out[N_FEATURES]) {
/* 各特征 min/max 由训练集统计得到 */
static const float fmin[N_FEATURES] = {0.0f, 0.0f, 45.0f, 0.2f, 0.0f, 0.0f, 0.0f};
static const float fmax[N_FEATURES] = {120.0f, 150.0f, 110.0f, 8.0f, 1.0f, 1.0f, 1.0f};
for (int i = 0; i < N_FEATURES; i++) {
float v = (raw[i] - fmin[i]) / (fmax[i] - fmin[i]);
out[i] = v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v);
}
}
/* 单棵树推理,返回投票类别 */
static int tree_predict(const TreeNode* tree, const float feat[N_FEATURES]) {
int idx = 0;
while (tree[idx].feature >= 0) {
idx = (feat[tree[idx].feature] < tree[idx].threshold)
? tree[idx].left : tree[idx].right;
}
/* 取票数最高的类别 */
int best = 0;
for (int c = 1; c < N_CLASSES; c++)
if (tree[idx].votes[c] > tree[idx].votes[best]) best = c;
return best;
}
/* 随机森林投票 */
int sleep_stage_predict(const float raw_feat[N_FEATURES]) {
float feat[N_FEATURES];
normalize_features(raw_feat, feat);
int votes[N_CLASSES] = {0};
/* 实际部署时遍历 30 棵树,此处以 tree0 示意 */
votes[tree_predict(tree0, feat)]++;
int best = 0;
for (int c = 1; c < N_CLASSES; c++)
if (votes[c] > votes[best]) best = c;
return best;
}
部署要点:训练脚本导出每棵树的节点数组为 C 头文件,推理时只做浮点比较与计数,无需动态内存分配。30 棵树、深度 6 的模型在 Cortex-M4F 上单次推理约 2–4 ms,满足 30 秒 epoch 的实时性要求。
验证与判读
用一段已知参考标签的睡眠记录做离线验证,计算混淆矩阵与 Cohen's Kappa:
| 真实 \ 预测 | 清醒 | 浅睡 | 深睡 | REM |
|---|---|---|---|---|
| 清醒 | 92% | 6% | 0% | 2% |
| 浅睡 | 5% | 78% | 12% | 5% |
| 深睡 | 0% | 14% | 83% | 3% |
| REM | 3% | 18% | 4% | 75% |
判读标准:Kappa > 0.6 视为可用;浅睡与 REM 的混淆是主要误差来源,可通过增加 LF/HF 特征权重或引入心率波动熵来改善。若深睡召回率低于 70%,优先检查体动阈值是否过松导致深睡被误判为浅睡。
常见问题:PPG 信号在体动时易丢失,导致 IBI 序列出现缺口。应在特征提取前对 IBI 做异常值剔除(相邻 IBI 变化超过 30% 视为伪迹),否则 RMSSD 会被显著高估,深睡误判率上升。